const fs = require('fs'); const path = require('path'); const yauzl = require('yauzl'); const config = require('./resource-bundles.config.cjs'); const root = path.join(__dirname, '..'); const portableBundleRoot = path.join(root, 'dist-release-final', 'win-unpacked', 'resources', 'resources-bundles'); const manifestPath = path.join(root, 'dist-release-final', 'resource-manifest.json'); const retryableFsCodes = new Set(['EBUSY', 'EACCES', 'EPERM', 'ENOTEMPTY']); function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function isRetryableFsError(error) { return error && retryableFsCodes.has(error.code); } async function retryFsOperation(label, operation, attempts = 6) { let lastError = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { return await operation(attempt); } catch (error) { lastError = error; if (!isRetryableFsError(error) || attempt === attempts) { throw error; } const delayMs = 350 * attempt; console.warn(`[hydrate-oem-portable] ${label} failed with ${error.code}; retry ${attempt}/${attempts - 1} after ${delayMs}ms`); await sleep(delayMs); } } throw lastError; } function removeIfExists(target) { if (fs.existsSync(target)) { fs.rmSync(target, { recursive: true, force: true }); } } function uniqueTempPath(target) { return `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } async function replaceWithRetry(source, destination) { await retryFsOperation(`replace ${destination}`, async () => { removeIfExists(destination); fs.renameSync(source, destination); }); } function readJson(file, fallback) { if (!fs.existsSync(file)) return fallback; try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } } function extractZipOnce(zipPath, destination) { return new Promise((resolve, reject) => { const finalDestinationRoot = path.resolve(destination); const stagingDestination = uniqueTempPath(destination); removeIfExists(stagingDestination); fs.mkdirSync(stagingDestination, { recursive: true }); const destinationRoot = path.resolve(stagingDestination); yauzl.open(zipPath, { lazyEntries: true }, (openError, zipFile) => { if (openError || !zipFile) { reject(openError || new Error(`Cannot open zip: ${zipPath}`)); return; } let settled = false; const finish = (error) => { if (settled) return; settled = true; try { zipFile.close(); } catch { // Ignore close errors after the original extraction failure. } if (error) { removeIfExists(stagingDestination); reject(error); return; } replaceWithRetry(stagingDestination, finalDestinationRoot) .then(() => resolve()) .catch(replaceError => { removeIfExists(stagingDestination); reject(replaceError); }); }; zipFile.readEntry(); zipFile.on('entry', (entry) => { if (settled) return; const normalizedName = entry.fileName.replace(/\\/g, '/'); const destPath = path.resolve(destinationRoot, normalizedName); if (!destPath.startsWith(destinationRoot + path.sep) && destPath !== destinationRoot) { finish(new Error(`Unsafe zip entry: ${entry.fileName}`)); return; } try { if (/\/$/.test(normalizedName)) { fs.mkdirSync(destPath, { recursive: true }); zipFile.readEntry(); return; } fs.mkdirSync(path.dirname(destPath), { recursive: true }); } catch (error) { finish(error); return; } zipFile.openReadStream(entry, (streamError, readStream) => { if (streamError || !readStream) { finish(streamError || new Error(`Cannot read zip entry: ${entry.fileName}`)); return; } const writeStream = fs.createWriteStream(destPath); readStream.pipe(writeStream); writeStream.on('finish', () => { if (!settled) zipFile.readEntry(); }); writeStream.on('error', finish); readStream.on('error', finish); }); }); zipFile.on('end', () => finish()); zipFile.on('error', finish); }); }); } function extractZip(zipPath, destination) { return retryFsOperation( `extract ${path.basename(zipPath)} to ${destination}`, () => extractZipOnce(zipPath, destination) ); } async function copyDirectory(source, destination) { if (!fs.existsSync(source)) return false; await retryFsOperation(`copy ${source} to ${destination}`, async () => { fs.mkdirSync(path.dirname(destination), { recursive: true }); removeIfExists(destination); fs.cpSync(source, destination, { recursive: true, force: true }); }); return true; } function pruneWindowsOnlyBundles() { const chromiumRoot = path.join(portableBundleRoot, 'playwright', 'chromium-1200'); const macChromium = path.join(chromiumRoot, 'chrome-mac'); const winChromium = path.join(chromiumRoot, 'chrome-win64'); if (process.platform === 'win32' && fs.existsSync(winChromium)) { removeIfExists(macChromium); } } function directoryHasFiles(dir) { if (!fs.existsSync(dir)) return false; const walk = (current) => { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); if (entry.isFile()) return true; if (entry.isDirectory() && walk(full)) return true; } return false; }; return walk(dir); } async function main() { const unpackedDir = path.join(root, 'dist-release-final', 'win-unpacked'); if (!fs.existsSync(unpackedDir)) { throw new Error(`win-unpacked does not exist: ${unpackedDir}`); } const manifest = readJson(manifestPath, { bundles: {} }); fs.mkdirSync(portableBundleRoot, { recursive: true }); const hydrated = []; for (const bundle of config.bundles) { const destination = path.join(portableBundleRoot, bundle.name); const archiveName = manifest.bundles?.[bundle.name]?.archive; const archivePath = archiveName ? path.join(config.outputDir, archiveName) : ''; if (archivePath && fs.existsSync(archivePath)) { await extractZip(archivePath, destination); hydrated.push(bundle.name); } else if (await copyDirectory(bundle.source, destination)) { hydrated.push(bundle.name); } else if (bundle.required) { throw new Error(`Required resource bundle is missing: ${bundle.name}. Expected archive ${archivePath || '(none)'} or source ${bundle.source}`); } if (bundle.required && !directoryHasFiles(destination)) { throw new Error(`Required resource bundle was not hydrated: ${bundle.name} (${destination})`); } } pruneWindowsOnlyBundles(); console.log(`[hydrate-oem-portable] hydrated bundles: ${hydrated.join(', ') || 'none'}`); } main().catch((error) => { console.error('[hydrate-oem-portable] failed:', error); process.exit(1); });