Files
WYF-koubo/tools/oem-batch-builder/worker.cjs
T
2026-06-19 18:45:55 +08:00

498 lines
17 KiB
JavaScript

const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const projectRoot = process.cwd();
const jobsFile = path.resolve(projectRoot, process.argv[2]);
const sessionDir = path.resolve(projectRoot, process.argv[3]);
const outputDir = path.join(projectRoot, 'dist-release-final');
const buildTempDir = path.join(projectRoot, 'build', 'oem-batch-temp');
const logFile = path.join(sessionDir, 'worker.log');
const resultFile = path.join(sessionDir, 'result.json');
const buildOutputFile = path.join(sessionDir, 'build-output.log');
const stageStatusFile = path.join(sessionDir, 'stage-status.json');
let resultWritten = false;
let lastProgressLogAt = 0;
const HEARTBEAT_INTERVAL_MS = 30000;
const liveChildren = new Map();
function ensureSessionDir() {
fs.mkdirSync(sessionDir, { recursive: true });
fs.mkdirSync(buildTempDir, { recursive: true });
}
function formatError(error) {
if (!error) return 'Unknown error';
if (error.stack) return error.stack;
if (error.message) return error.message;
return String(error);
}
function writeLog(payload) {
ensureSessionDir();
fs.appendFileSync(logFile, JSON.stringify({
time: new Date().toISOString(),
...payload,
}) + '\n', 'utf8');
}
function appendBuildOutput(chunk) {
ensureSessionDir();
fs.appendFileSync(buildOutputFile, chunk, 'utf8');
}
function summarizeOutput(text) {
const lines = String(text || '')
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
if (lines.length === 0) return '';
const lastLine = lines[lines.length - 1];
return lastLine.length > 700 ? `${lastLine.slice(0, 700)}...` : lastLine;
}
function formatElapsed(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
function stripAnsi(text) {
return String(text || '').replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
}
function shouldMirrorOutput(text) {
const value = stripAnsi(text);
if (!value.trim()) return false;
if (/^\s*(Compressing|Processing):\s+/m.test(value)) return false;
if (/^\s*dist[\\/]/m.test(value)) return false;
if (/^\s*dist-electron[\\/]/m.test(value)) return false;
if (!/(error|failed|exception|eperm|eacces|ebusy|enoent|enotempty|cannot|unsafe)/i.test(value)) {
if (value.includes('\\dist-release-final\\win-unpacked\\resources\\')) return false;
if (value.includes('/dist-release-final/win-unpacked/resources/')) return false;
}
if (value.includes('[plugin:vite:reporter]')) return false;
if (value.includes('dynamic import will not move module into another chunk')) return false;
return true;
}
function writeCommandOutputLog(job, level, text) {
appendBuildOutput(text);
if (shouldMirrorOutput(text)) {
writeLog({ jobId: job.id, level, message: text });
return;
}
const now = Date.now();
if (now - lastProgressLogAt < 5000) return;
lastProgressLogAt = now;
const summary = summarizeOutput(text);
if (summary) {
writeLog({ jobId: job.id, level: 'info', message: `[${level}] ${summary}` });
}
}
function writeResult(payload) {
ensureSessionDir();
fs.writeFileSync(resultFile, JSON.stringify(payload, null, 2), 'utf8');
resultWritten = true;
}
function writeStageStatus(payload) {
ensureSessionDir();
fs.writeFileSync(stageStatusFile, JSON.stringify({
time: new Date().toISOString(),
...payload,
}, null, 2), 'utf8');
}
function sanitizeIdentifier(value, fallback) {
const normalized = String(value || '')
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w.-]/g, '');
return normalized || fallback;
}
function jsString(value) {
return JSON.stringify(String(value || ''));
}
function normalizeApiBaseUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
const normalized = raw
.replace(/\/+$/, '')
.replace(/\/+api$/i, '/api');
return /\/api$/i.test(normalized) ? normalized : `${normalized}/api`;
}
function validateJob(job) {
if (!job.brandName || !job.brandName.trim()) {
throw new Error('Software name is required.');
}
job.apiBaseUrl = normalizeApiBaseUrl(job.apiBaseUrl);
if (!job.apiBaseUrl) {
throw new Error('API base URL is required.');
}
try {
new URL(job.apiBaseUrl);
} catch {
throw new Error(`Invalid API base URL: ${job.apiBaseUrl}`);
}
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(job.version || '')) {
throw new Error(`Invalid version: ${job.version || '(empty)'}`);
}
const assets = job.assets || {};
const required = [
['qrcodePath', 'QR code'],
['logoPath', 'Logo'],
['iconPath', 'Icon'],
['loginBgPath', 'Login background'],
];
for (const [key, label] of required) {
if (!assets[key] || !fs.existsSync(assets[key])) {
throw new Error(`${label} file does not exist: ${assets[key] || '(empty)'}`);
}
}
}
function repairKnownMojibakePath(filePath) {
return String(filePath || '')
.replace(/cursor澶囦唤/g, 'cursor备份')
.replace(/cursor锟斤拷锟斤拷/g, 'cursor备份');
}
function repairJobPaths(job) {
if (!job.assets) return;
for (const key of ['qrcodePath', 'logoPath', 'iconPath', 'loginBgPath']) {
job.assets[key] = repairKnownMojibakePath(job.assets[key]);
}
}
function copyAssets(job) {
const assetsDir = path.join(projectRoot, 'oem-assets');
fs.mkdirSync(assetsDir, { recursive: true });
fs.copyFileSync(job.assets.qrcodePath, path.join(assetsDir, 'qrcode.png'));
fs.copyFileSync(job.assets.logoPath, path.join(assetsDir, 'logo.png'));
fs.copyFileSync(job.assets.iconPath, path.join(assetsDir, 'icon.png'));
fs.copyFileSync(job.assets.loginBgPath, path.join(assetsDir, 'login-bg.png'));
}
function writeOemConfig(job) {
const shortName = sanitizeIdentifier(job.brandShortName || job.brandName, 'OEMApp');
const displayName = job.brandDisplayName && job.brandDisplayName.trim()
? job.brandDisplayName.trim()
: job.brandName.trim();
const slogan = job.brandSlogan && job.brandSlogan.trim() ? job.brandSlogan.trim() : '';
const content = `module.exports = {
appVersion: ${jsString(job.version)},
brandName: ${jsString(job.brandName)},
brandSlogan: ${jsString(slogan)},
brandShortName: ${jsString(shortName)},
brandDisplayName: ${jsString(displayName)},
qrcodePath: "./oem-assets/qrcode.png",
logoPath: "./oem-assets/logo.png",
iconPath: "./oem-assets/icon.png",
loginBgPath: "./oem-assets/login-bg.png",
apiBaseUrl: ${jsString(job.apiBaseUrl)},
website: "",
websiteGithub: "",
websiteGitee: "",
};
`;
fs.writeFileSync(path.join(projectRoot, 'oem-config.cjs'), content, 'utf8');
}
function listArtifacts(afterMs) {
if (!fs.existsSync(outputDir)) return [];
const releaseAssetsDir = path.join(outputDir, 'release-assets');
const searchRoot = fs.existsSync(releaseAssetsDir) ? releaseAssetsDir : outputDir;
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'win-unpacked') continue;
walk(full);
} else if (entry.isFile()) {
files.push(full);
}
}
};
walk(searchRoot);
return files
.filter(filePath => {
try {
const stat = fs.statSync(filePath);
return stat.isFile() && stat.mtimeMs >= afterMs - 3000;
} catch {
return false;
}
})
.filter(filePath => /\.(exe|msi|dmg|zip|7z|blockmap|yml)$/i.test(filePath))
.sort();
}
function resolveBuildCommand(command) {
const trimmed = String(command || '').trim();
const npmRunMatch = /^npm(?:\.cmd)?\s+run\s+([^\s]+)$/i.exec(trimmed);
if (npmRunMatch) {
return { file: 'cmd.exe', args: ['/d', '/s', '/c', `npm.cmd run ${npmRunMatch[1]}`], shell: false };
}
return { file: 'cmd.exe', args: ['/d', '/s', '/c', trimmed], shell: false };
}
function resolveExternalNsisDir() {
const nsisDir = 'C:\\Program Files (x86)\\NSIS';
if (fs.existsSync(path.join(nsisDir, 'Bin', 'makensis.exe'))) {
return nsisDir;
}
return process.env.ELECTRON_BUILDER_NSIS_DIR || '';
}
function runCommand(command, job, label = command) {
return new Promise((resolve, reject) => {
const startMs = Date.now();
lastProgressLogAt = 0;
const resolved = resolveBuildCommand(command);
writeStageStatus({ jobId: job.id, stage: label, command, status: 'running', startedAt: new Date().toISOString() });
writeLog({ jobId: job.id, level: 'info', message: `Run build command: ${label}` });
writeLog({ jobId: job.id, level: 'info', message: `Spawn: ${resolved.file} ${resolved.args.join(' ')}` });
const child = spawn(resolved.file, resolved.args, {
cwd: projectRoot,
env: {
...process.env,
APP_VERSION: job.version,
OEM_VERSION: job.version,
OEM_BATCH_BUILD: '1',
EXTERNAL_ELECTRON_DEV: '1',
TEMP: buildTempDir,
TMP: buildTempDir,
TMPDIR: buildTempDir,
ELECTRON_BUILDER_NSIS_DIR: resolveExternalNsisDir(),
},
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
shell: resolved.shell,
});
if (child.pid) liveChildren.set(child.pid, label);
writeLog({ jobId: job.id, level: 'info', message: `Build process pid=${child.pid || 'unknown'}` });
const heartbeatTimer = setInterval(() => {
writeLog({
jobId: job.id,
level: 'info',
message: `Still running: ${label} elapsed ${formatElapsed(Date.now() - startMs)}`,
});
}, HEARTBEAT_INTERVAL_MS);
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
child.stdout.on('data', data => {
const text = data.toString();
writeCommandOutputLog(job, 'stdout', text);
});
child.stderr.on('data', data => {
const text = data.toString();
writeCommandOutputLog(job, 'stderr', text);
});
child.on('error', error => {
clearInterval(heartbeatTimer);
if (child.pid) liveChildren.delete(child.pid);
writeLog({ jobId: job.id, level: 'error', message: `Build process error: ${formatError(error)}` });
writeStageStatus({ jobId: job.id, stage: label, command, status: 'error', error: formatError(error), finishedAt: new Date().toISOString() });
reject(error);
});
child.on('exit', (code, signal) => {
writeLog({ jobId: job.id, level: code === 0 ? 'info' : 'error', message: `Build process exit code=${code} signal=${signal || ''}` });
});
child.on('close', (code, signal) => {
clearInterval(heartbeatTimer);
if (child.pid) liveChildren.delete(child.pid);
writeLog({ jobId: job.id, level: code === 0 ? 'info' : 'error', message: `Build process close code=${code} signal=${signal || ''}` });
writeStageStatus({ jobId: job.id, stage: label, command, status: code === 0 ? 'success' : 'failed', code, signal, finishedAt: new Date().toISOString() });
resolve({ code, signal, artifacts: listArtifacts(startMs) });
});
});
}
async function runBuildCommand(command, job) {
const normalized = String(command || '').trim().toLowerCase();
if (normalized === 'npm run build:win' || normalized === 'npm.cmd run build:win') {
const steps = [
['clean oem package output', 'node scripts/clean-oem-package-output.cjs'],
['prepare-package', 'node apply-oem.cjs && py -3 scripts/export-runtime-system-templates.py && node scripts/prepare-package.cjs'],
['build renderer/main/preload', 'node scripts/clean-build-artifacts.cjs && node_modules\\.bin\\vite.cmd build'],
['build resource artifacts', 'node scripts/build-resource-artifacts.cjs'],
['make oem builder config', 'node scripts/make-oem-builder-config.cjs'],
['electron-builder win x64', 'node_modules\\.bin\\electron-builder.cmd --config tools\\oem-batch-builder\\electron-builder.oem.generated.json --win --x64'],
['hydrate oem portable bundles', 'node scripts/hydrate-oem-portable-bundles.cjs'],
['build full offline installer', 'node scripts/build-oem-inno-installer.cjs'],
['assemble release artifacts', 'node scripts/assemble-release-artifacts.cjs'],
];
let artifacts = [];
for (const [label, stepCommand] of steps) {
const result = await runCommand(stepCommand, job, label);
artifacts = result.artifacts || artifacts;
if (result.code !== 0) {
return { ...result, artifacts };
}
}
return { code: 0, signal: null, artifacts: listArtifacts(Date.now() - 24 * 60 * 60 * 1000) };
}
return runCommand(command, job);
}
async function runOneJob(rawJob, index) {
const job = {
...rawJob,
id: rawJob.id || `oem-${Date.now()}-${index}`,
buildCommand: rawJob.buildCommand || 'npm run build:win',
};
writeLog({ jobId: job.id, level: 'info', message: `Prepare ${job.brandName} v${job.version}` });
repairJobPaths(job);
validateJob(job);
copyAssets(job);
writeOemConfig(job);
const { code, signal, artifacts } = await runBuildCommand(job.buildCommand, job);
const success = code === 0;
const result = {
id: job.id,
brandName: job.brandName,
version: job.version,
success,
code,
signal,
outputDir,
buildOutputFile,
artifacts,
message: success ? 'Build finished' : `Build failed, exit code ${code}${signal ? `, signal ${signal}` : ''}`,
};
writeLog({ jobId: job.id, level: success ? 'info' : 'error', message: result.message });
if (artifacts.length > 0) {
writeLog({ jobId: job.id, level: 'info', message: `Artifacts:\n${artifacts.join('\n')}` });
}
return result;
}
process.on('uncaughtException', error => {
const message = formatError(error);
try {
writeLog({ level: 'error', message: `Worker uncaughtException: ${message}` });
if (!resultWritten) {
writeResult({
success: false,
results: [],
outputDir,
buildOutputFile,
message: `Worker crashed: ${message}`,
});
}
} finally {
process.exit(1);
}
});
process.on('unhandledRejection', error => {
const message = formatError(error);
writeLog({ level: 'error', message: `Worker unhandledRejection: ${message}` });
if (!resultWritten) {
writeResult({
success: false,
results: [],
outputDir,
buildOutputFile,
message: `Worker rejected: ${message}`,
});
}
process.exitCode = 1;
});
process.on('exit', code => {
try {
writeLog({ level: 'info', message: `Worker exit code=${code} resultWritten=${resultWritten} liveChildren=${JSON.stringify([...liveChildren.entries()])}` });
} catch {
// ignore exit logging failure
}
if (!resultWritten) {
try {
writeResult({
success: false,
results: [],
outputDir,
buildOutputFile,
message: `Worker exited before writing a result. Exit code ${code}. See ${logFile}`,
});
} catch {
// Exit handlers cannot reliably recover from filesystem failures.
}
}
});
process.on('beforeExit', code => {
writeLog({ level: 'info', message: `Worker beforeExit code=${code} resultWritten=${resultWritten} liveChildren=${JSON.stringify([...liveChildren.entries()])}` });
});
process.on('SIGTERM', () => {
writeLog({ level: 'error', message: `Worker received SIGTERM resultWritten=${resultWritten} liveChildren=${JSON.stringify([...liveChildren.entries()])}` });
});
process.on('SIGINT', () => {
writeLog({ level: 'error', message: `Worker received SIGINT resultWritten=${resultWritten} liveChildren=${JSON.stringify([...liveChildren.entries()])}` });
});
(async () => {
ensureSessionDir();
const parsedJobs = JSON.parse(fs.readFileSync(jobsFile, 'utf8').replace(/^\uFEFF/, ''));
const jobs = Array.isArray(parsedJobs) ? parsedJobs : [parsedJobs];
const results = [];
writeLog({ level: 'info', message: `Batch started, jobs=${jobs.length}` });
for (let i = 0; i < jobs.length; i++) {
try {
results.push(await runOneJob(jobs[i], i));
} catch (error) {
const failed = {
id: jobs[i] && jobs[i].id ? jobs[i].id : `oem-${i}`,
brandName: jobs[i] && jobs[i].brandName ? jobs[i].brandName : `OEM ${i + 1}`,
success: false,
outputDir,
buildOutputFile,
artifacts: [],
message: formatError(error),
};
results.push(failed);
writeLog({ jobId: failed.id, level: 'error', message: failed.message });
}
}
const payload = { success: results.every(item => item.success), results, outputDir, buildOutputFile };
writeResult(payload);
writeLog({ level: 'info', message: 'Batch finished' });
})().catch(error => {
const payload = {
success: false,
results: [],
outputDir,
buildOutputFile,
message: formatError(error),
};
writeResult(payload);
writeLog({ level: 'error', message: payload.message });
process.exitCode = 1;
});