Initial clean project import
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user