498 lines
17 KiB
JavaScript
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;
|
|
});
|