222 lines
7.0 KiB
JavaScript
222 lines
7.0 KiB
JavaScript
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);
|
|
});
|
|
|