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; });