UNPKG

@indutny/rezip-electron

Version:

Re-compress Electron macOS installer zip files for better incremental updates

194 lines (161 loc) 4.92 kB
import { createReadStream } from 'node:fs'; import { pipeline } from 'node:stream/promises'; import { once } from 'node:events'; import { promisify } from 'node:util'; import { BlockMap } from 'better-blockmap'; import createRBTree from 'functional-red-black-tree'; import yauzl from 'yauzl'; import { createArchiveTree, getTreeRange } from '../util.js'; async function getBlocks(filePath) { const generator = new BlockMap({ detectZipBoundary: true }); await pipeline(createReadStream(filePath), generator); const { files } = generator.toObject(); const [file] = files; let { offset } = file; const blocks = []; for (const [i, checksum] of file.checksums.entries()) { const size = file.sizes[i]; blocks.push({ offset, size, checksum, }); offset += size; } return blocks; } function computeDiff(oldFile, newFile) { const oldChecksums = new Map(); for (const oldBlock of oldFile) { let list = oldChecksums.get(oldBlock.checksum); if (!list) { list = []; oldChecksums.set(oldBlock.checksum, list); } list.push(oldBlock); } const diff = []; let writeOffset = 0; for (const newBlock of newFile) { const oldBlocks = oldChecksums.get(newBlock.checksum); if (oldBlocks) { const oldBlock = oldBlocks.shift(); if (oldBlocks.length === 0) { oldChecksums.delete(newBlock.checksum); } if (oldBlock.size !== newBlock.size) { throw new Error( `Block size mismatch: ${newBlock.checksum}, ` + `${oldBlock.size} != ${newBlock.size}`, ); } diff.push({ action: 'copy', size: oldBlock.size, readOffset: oldBlock.offset, writeOffset, }); writeOffset += oldBlock.size; continue; } diff.push({ action: 'download', size: newBlock.size, readOffset: newBlock.offset, writeOffset, }); writeOffset += newBlock.size; } const optimizedDiff = []; for (const entry of diff) { const last = optimizedDiff.length !== 0 ? optimizedDiff[optimizedDiff.length - 1] : undefined; const { action, readOffset, size } = entry; if ( !last || last.action !== action || last.readOffset + last.size !== readOffset ) { optimizedDiff.push(entry); continue; } last.size += size; } return optimizedDiff.filter(({ size }) => size !== 0); } export default async function compare(oldFile, newFile) { const [a, b] = await Promise.all([getBlocks(oldFile), getBlocks(newFile)]); const diff = computeDiff(a, b); const zip = await promisify(yauzl.open)(newFile, { autoClose: false, }); const entries = []; zip.on('entry', (entry) => entries.push(entry)); await once(zip, 'end'); const readLocalFileHeader = promisify(zip.readLocalFileHeader).bind(zip); const openReadStream = promisify(zip.openReadStream).bind(zip); let tree = createRBTree(); const subTrees = new Map(); await Promise.all( entries .filter((entry) => !entry.fileName.endsWith('/')) .map(async (entry) => { const { fileDataStart } = await readLocalFileHeader(entry); let subTree; if (entry.isCompressed()) { const compressedStream = await openReadStream(entry, { decompress: false, }); subTree = await createArchiveTree( compressedStream, entry.fileName.endsWith('.asar'), ); } else { subTree = createRBTree(); subTree = subTree.insert(0, 0); subTree = subTree.insert( entry.compressedSize, entry.uncompressedSize, ); } subTrees.set(entry.fileName, subTree); tree = tree.insert(fileDataStart, entry.fileName); }), ); zip.close(); let downloadSize = 0; const modifiedFiles = new Map(); for (const { action, readOffset, size } of diff) { if (action !== 'download') { continue; } downloadSize += size; const range = getTreeRange(tree, readOffset, readOffset + size); for (const { value: path, from, to } of range) { const subTree = subTrees.get(path); if (!subTree) { throw new Error(`Unknown file: ${path}`); } const subRange = getTreeRange(subTree, from, to); for (const { value, from: subFrom, to: subTo } of subRange) { let fullPath = path; if (typeof value === 'string') { fullPath = `${fullPath}/${value}`; } modifiedFiles.set( fullPath, (modifiedFiles.get(fullPath) ?? 0) + (subTo - subFrom), ); } } } const sortedFiles = Array.from(modifiedFiles.entries()) .sort((left, right) => left[1] - right[1]) .map(([path, size]) => ({ path, size })); return { downloadSize, modifiedFiles: sortedFiles, }; }