const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron'); const { spawn, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const projectRoot = path.resolve(__dirname, '..', '..'); const outputDir = path.join(projectRoot, 'dist-release-final'); const toolLogPath = path.join(__dirname, 'oem-tool.log'); const statePath = path.join(__dirname, 'active-run.json'); const toolAppName = 'OEM Batch Builder Tool'; app.setName(toolAppName); app.setAppUserModelId('com.oem.batch-builder-tool'); let mainWindow = null; let running = false; let currentProcess = null; let latestResults = []; let activePollTimer = null; let activeLogOffset = 0; let activeSessionDir = ''; let activeResultFile = ''; let activeWorkerLogFile = ''; let activeStageStatusFile = ''; let allowQuitWhileRunning = false; const gotSingleInstanceLock = app.requestSingleInstanceLock(); if (!gotSingleInstanceLock) { appendToolLog(`single-instance lock denied pid=${process.pid} argv=${safeJson(process.argv)}`); app.quit(); } appendToolLog(`process start pid=${process.pid} ppid=${process.ppid} cwd=${process.cwd()} argv=${safeJson(process.argv)}`); function appendToolLog(message) { try { fs.appendFileSync(toolLogPath, `[${new Date().toISOString()}] ${message}\n`, 'utf8'); } catch { // ignore logging failure } } function safeJson(value) { try { return JSON.stringify(value); } catch { return String(value); } } function sendLog(payload) { const message = String(payload.message || ''); if (payload.level === 'stdout' && shouldSuppressWindowLog(message)) return; const displayPayload = { ...payload, message: message.length > 6000 ? `${message.slice(0, 6000)}\n... [full output is in build-output.log]` : message, }; appendToolLog(`${payload.level || 'info'} ${payload.jobId ? `[${payload.jobId}] ` : ''}${displayPayload.message || ''}`); if (!mainWindow || mainWindow.isDestroyed()) return; mainWindow.webContents.send('oem-tool:log', { time: new Date().toISOString(), ...displayPayload, }); } function shouldSuppressWindowLog(message) { const text = String(message || ''); return text.includes('[OK] Copied qrcode') || text.includes('[OK] Copied logo') || text.includes('[OK] Copied login-bg') || text.includes('[SKIP] pattern not matched') || text.includes('Processing: F:'); } function readState() { try { if (!fs.existsSync(statePath)) return null; return JSON.parse(fs.readFileSync(statePath, 'utf8').replace(/^\uFEFF/, '')); } catch (error) { appendToolLog(`readState failed: ${error && error.message ? error.message : String(error)}`); return null; } } function writeState(payload) { try { fs.writeFileSync(statePath, JSON.stringify(payload, null, 2), 'utf8'); } catch (error) { appendToolLog(`writeState failed: ${error && error.message ? error.message : String(error)}`); } } function clearStateIfFinished(payload) { writeState({ running: false, finishedAt: new Date().toISOString(), outputDir, results: payload.results || [], message: payload.message || '', success: Boolean(payload.success), sessionDir: activeSessionDir, }); } function createWindow() { appendToolLog('createWindow start'); const staleFailure = repairStaleRunningState(); if (staleFailure) { appendToolLog(`repaired stale active run before window creation: ${staleFailure.message}`); } mainWindow = new BrowserWindow({ width: 1320, height: 860, minWidth: 1120, minHeight: 720, title: toolAppName, backgroundColor: '#f6f7fb', show: false, webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, }, }); mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => { appendToolLog(`renderer console level=${level} ${sourceId || ''}:${line || 0} ${message}`); }); mainWindow.webContents.on('preload-error', (_event, preloadPath, error) => { appendToolLog(`preload-error ${preloadPath}: ${error && error.stack ? error.stack : error}`); }); mainWindow.webContents.on('did-crash', (_event, killed) => { appendToolLog(`renderer did-crash killed=${killed}`); }); mainWindow.webContents.on('destroyed', () => { appendToolLog('webContents destroyed'); }); mainWindow.on('unresponsive', () => { appendToolLog('window unresponsive'); }); mainWindow.on('responsive', () => { appendToolLog('window responsive'); }); const showMainWindow = () => { if (!mainWindow || mainWindow.isDestroyed()) return; appendToolLog(`showMainWindow visible=${mainWindow.isVisible()} minimized=${mainWindow.isMinimized()}`); mainWindow.setTitle(toolAppName); if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.center(); mainWindow.show(); mainWindow.focus(); mainWindow.moveTop(); appendToolLog(`showMainWindow done visible=${mainWindow.isVisible()} bounds=${JSON.stringify(mainWindow.getBounds())}`); }; mainWindow.loadFile(path.join(__dirname, 'index.html')); showMainWindow(); mainWindow.on('page-title-updated', event => { event.preventDefault(); if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setTitle(toolAppName); }); mainWindow.once('ready-to-show', showMainWindow); mainWindow.webContents.once('did-finish-load', () => setTimeout(showMainWindow, 50)); setTimeout(showMainWindow, 2000); mainWindow.webContents.on('render-process-gone', (_event, details) => { appendToolLog(`render-process-gone: ${JSON.stringify(details)}`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.reload(); } }); mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { appendToolLog(`did-fail-load: ${errorCode} ${errorDescription} ${validatedURL}`); }); mainWindow.on('close', event => { if (running && !allowQuitWhileRunning) { const payload = pollActiveSession(); if (payload) { finishActiveSession({ ...payload, outputDir }); return; } const pid = getKnownWorkerPid(); if (!pid || !isProcessAlive(pid)) { markStaleRunStopped(`window close allowed because worker is not alive. pid=${pid || 'unknown'}`); return; } event.preventDefault(); appendToolLog(`window close requested while worker is running; keep tool window open. pid=${pid}`); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); mainWindow.focus(); } } }); mainWindow.on('closed', () => { appendToolLog('window closed'); mainWindow = null; if (running) { setTimeout(() => { if (!mainWindow) createWindow(); }, 300); } }); } function readNewWorkerLogs(workerLogFile) { if (!fs.existsSync(workerLogFile)) return; const stat = fs.statSync(workerLogFile); if (stat.size <= activeLogOffset) return; const fd = fs.openSync(workerLogFile, 'r'); const buffer = Buffer.alloc(stat.size - activeLogOffset); fs.readSync(fd, buffer, 0, buffer.length, activeLogOffset); fs.closeSync(fd); activeLogOffset = stat.size; const lines = buffer.toString('utf8').split(/\r?\n/).filter(Boolean); for (const line of lines) { try { sendLog(JSON.parse(line)); } catch { sendLog({ level: 'info', message: line }); } } } function stopActivePoll() { if (activePollTimer) { clearInterval(activePollTimer); activePollTimer = null; } } function setActiveSession(sessionDir) { activeSessionDir = sessionDir; activeResultFile = path.join(sessionDir, 'result.json'); activeWorkerLogFile = path.join(sessionDir, 'worker.log'); activeStageStatusFile = path.join(sessionDir, 'stage-status.json'); activeLogOffset = 0; } function readJsonFile(filePath) { try { if (!filePath || !fs.existsSync(filePath)) return null; return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '')); } catch (error) { appendToolLog(`readJsonFile failed ${filePath}: ${error && error.message ? error.message : String(error)}`); return null; } } function readWorkerPidFromSession(sessionDir) { try { const pidFile = path.join(sessionDir, 'worker.pid'); if (!fs.existsSync(pidFile)) return null; const parsed = Number(fs.readFileSync(pidFile, 'utf8').replace(/^\uFEFF/, '').trim()); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } catch (error) { appendToolLog(`readWorkerPidFromSession failed: ${error && error.message ? error.message : String(error)}`); return null; } } function waitForWorkerPidFromSession(sessionDir, timeoutMs = 5000) { const started = Date.now(); const sleepBuffer = new SharedArrayBuffer(4); const sleepView = new Int32Array(sleepBuffer); while (Date.now() - started < timeoutMs) { const pid = readWorkerPidFromSession(sessionDir); if (pid) return pid; Atomics.wait(sleepView, 0, 0, 100); } return null; } function makeJobsFailure(jobs, sessionDir, message) { const safeJobs = Array.isArray(jobs) ? jobs : []; return { success: false, results: safeJobs.map(job => ({ id: job.id, brandName: job.brandName, version: job.version, success: false, outputDir, buildOutputFile: sessionDir ? path.join(sessionDir, 'build-output.log') : '', artifacts: [], message, })), outputDir, message, }; } function getKnownWorkerPid() { const state = readState(); if (currentProcess && currentProcess.pid) return currentProcess.pid; if (state && state.pid) return state.pid; if (activeSessionDir) return readWorkerPidFromSession(activeSessionDir); if (state && state.sessionDir) return readWorkerPidFromSession(state.sessionDir); return null; } function repairStaleRunningState() { const state = readState(); if (!state || !state.running || !state.sessionDir) return null; const resultPath = state.resultFile || path.join(state.sessionDir, 'result.json'); if (fs.existsSync(resultPath)) return null; const pid = state.pid || readWorkerPidFromSession(state.sessionDir); if (pid && isProcessAlive(pid)) return null; const launcherStartedAt = state.launcherStartedAt ? Date.parse(state.launcherStartedAt) : 0; if (!pid && launcherStartedAt && Date.now() - launcherStartedAt < 15000) return null; if (activeSessionDir !== state.sessionDir) { setActiveSession(state.sessionDir); } const failed = makeMissingResultFailure({ ...state, pid, }); finishActiveSession(failed); return failed; } function markStaleRunStopped(message) { stopActivePoll(); running = false; currentProcess = null; writeState({ ...(readState() || {}), running: false, stoppedAt: new Date().toISOString(), outputDir, sessionDir: activeSessionDir, message, }); appendToolLog(message); } function pollActiveSession() { if (!activeSessionDir) return null; readNewWorkerLogs(activeWorkerLogFile); if (!fs.existsSync(activeResultFile)) return null; try { return JSON.parse(fs.readFileSync(activeResultFile, 'utf8')); } catch (error) { return { success: false, results: latestResults, outputDir, message: error && error.message ? error.message : String(error), }; } } function makeMissingResultFailure(state) { const stage = readJsonFile(path.join(state.sessionDir, 'stage-status.json')) || readJsonFile(activeStageStatusFile); const jobs = Array.isArray(state.jobs) ? state.jobs : []; const firstJob = jobs[0] || {}; const stageText = stage && stage.stage ? ` Last stage: ${stage.stage} (${stage.status || 'unknown'}).` : ''; const message = `Worker process ${state.pid || 'unknown'} stopped without producing result.json.${stageText} See ${state.workerLogFile || activeWorkerLogFile}`; return { success: false, results: jobs.length > 0 ? jobs.map(job => ({ id: job.id, brandName: job.brandName, version: job.version, success: false, outputDir, buildOutputFile: path.join(state.sessionDir, 'build-output.log'), artifacts: [], message, stage, })) : [{ id: firstJob.id || 'oem-0', brandName: firstJob.brandName || 'OEM', success: false, outputDir, buildOutputFile: path.join(state.sessionDir, 'build-output.log'), artifacts: [], message, stage, }], outputDir, message, }; } function finishActiveSession(payload) { stopActivePoll(); latestResults = payload.results || []; running = false; currentProcess = null; clearStateIfFinished(payload); sendLog({ level: payload.success ? 'info' : 'error', message: payload.message || 'Build queue finished' }); } function finishStartupFailure(jobs, sessionDir, message) { finishActiveSession(makeJobsFailure(jobs, sessionDir, message)); } function findNodeExe() { const candidates = [ process.env.npm_node_execpath, process.env.NODE_EXE, ].filter(Boolean); for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } const result = spawnSync('where.exe', ['node.exe'], { cwd: projectRoot, encoding: 'utf8', windowsHide: true, }); const found = String(result.stdout || '') .split(/\r?\n/) .map(line => line.trim()) .find(Boolean); if (found && fs.existsSync(found)) return found; return 'node.exe'; } function launchDetachedWorker(workerPath, relativeJobsFile, relativeSessionDir) { const sessionDir = path.resolve(projectRoot, relativeSessionDir); fs.mkdirSync(sessionDir, { recursive: true }); const nodeExe = findNodeExe(); const stdoutFd = fs.openSync(path.join(sessionDir, 'worker-main.stdout.log'), 'a'); const stderrFd = fs.openSync(path.join(sessionDir, 'worker-main.stderr.log'), 'a'); appendToolLog(`launchDetachedWorker node=${nodeExe} worker=${workerPath} jobs=${relativeJobsFile} session=${relativeSessionDir}`); const worker = spawn(nodeExe, [workerPath, relativeJobsFile, relativeSessionDir], { cwd: projectRoot, windowsHide: true, detached: true, stdio: ['ignore', stdoutFd, stderrFd], }); fs.closeSync(stdoutFd); fs.closeSync(stderrFd); if (!worker.pid) { throw new Error('Build worker did not return a PID.'); } fs.writeFileSync(path.join(sessionDir, 'worker.pid'), String(worker.pid), 'utf8'); worker.unref(); return { launcherPid: null, workerPid: worker.pid, }; } function startActivePoll() { stopActivePoll(); activePollTimer = setInterval(() => { const payload = pollActiveSession(); if (payload) { finishActiveSession({ ...payload, outputDir }); } }, 700); } function isProcessAlive(pid) { if (!pid) return false; try { process.kill(Number(pid), 0); return true; } catch { return false; } } ipcMain.handle('oem-tool:diagnostic', async (_event, payload) => { const message = payload && payload.message ? payload.message : 'renderer diagnostic'; appendToolLog(`renderer diagnostic: ${message} ${safeJson(payload && payload.payload ? payload.payload : {})}`); return { ok: true }; }); ipcMain.handle('oem-tool:pick-image', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'ico', 'icns'] }], }); if (result.canceled) return null; return result.filePaths[0] || null; }); ipcMain.handle('oem-tool:run-batch', async (_event, jobs) => { appendToolLog(`run-batch invoked jobs=${Array.isArray(jobs) ? jobs.length : 'invalid'}`); if (running) { appendToolLog('run-batch rejected: already running'); return { success: false, message: 'Build queue is already running', results: latestResults, outputDir }; } if (!Array.isArray(jobs) || jobs.length === 0) { appendToolLog('run-batch rejected: no jobs'); return { success: false, message: 'No OEM jobs selected', results: [], outputDir }; } const sessionDir = path.join(__dirname, 'runs', `${Date.now()}`); const jobsFile = path.join(sessionDir, 'jobs.json'); try { fs.mkdirSync(sessionDir, { recursive: true }); fs.writeFileSync(jobsFile, JSON.stringify(jobs, null, 2), 'utf8'); running = true; latestResults = []; setActiveSession(sessionDir); const relativeSessionDir = path.relative(projectRoot, sessionDir); const relativeJobsFile = path.relative(projectRoot, jobsFile); const workerPath = path.join('tools', 'oem-batch-builder', 'worker.cjs'); writeState({ running: true, startedAt: new Date().toISOString(), pid: null, launcherPid: null, outputDir, sessionDir, jobsFile, resultFile: activeResultFile, workerLogFile: activeWorkerLogFile, stageStatusFile: activeStageStatusFile, jobs, message: 'Starting worker', launcherStartedAt: new Date().toISOString(), }); appendToolLog(`run-batch state written session=${sessionDir}`); const launchInfo = launchDetachedWorker(workerPath, relativeJobsFile, relativeSessionDir); const workerPid = launchInfo.workerPid || readWorkerPidFromSession(sessionDir); appendToolLog(`run-batch launchDetachedWorker returned launcherPid=${launchInfo.launcherPid} workerPid=${workerPid || ''}`); currentProcess = workerPid ? { pid: workerPid } : null; writeState({ ...(readState() || {}), running: true, pid: workerPid || null, launcherPid: launchInfo.launcherPid, outputDir, sessionDir, jobsFile, resultFile: activeResultFile, workerLogFile: activeWorkerLogFile, stageStatusFile: activeStageStatusFile, jobs, launcherStartedAt: new Date().toISOString(), message: workerPid ? `Worker started. pid=${workerPid}` : `Worker launcher started. launcherPid=${launchInfo.launcherPid}`, }); sendLog({ level: 'info', message: workerPid ? `Started background worker. pid=${workerPid} jobs=${jobs.length}` : `Started worker launcher. pid=${launchInfo.launcherPid} jobs=${jobs.length}` }); appendToolLog(`background worker launcherPid=${launchInfo.launcherPid} workerPid=${workerPid || ''} worker=${workerPath} jobs=${relativeJobsFile}`); startActivePoll(); appendToolLog(`run-batch accepted session=${sessionDir} launcherPid=${launchInfo.launcherPid} workerPid=${workerPid || ''}`); return { success: true, accepted: true, running: true, sessionDir, outputDir, message: `Build started. Logs: ${activeWorkerLogFile}`, results: [], }; } catch (error) { const message = `Failed to start build worker: ${error && error.message ? error.message : String(error)}`; appendToolLog(`${message}\n${error && error.stack ? error.stack : ''}`); if (running || activeSessionDir === sessionDir) { finishStartupFailure(jobs, sessionDir, message); } else { appendToolLog(message); } return { success: false, running: false, outputDir, message, results: latestResults }; } }); ipcMain.handle('oem-tool:get-status', async () => { const staleFailure = repairStaleRunningState(); if (staleFailure) { return { running: false, results: staleFailure.results || [], outputDir, sessionDir: activeSessionDir, message: staleFailure.message, }; } const state = readState(); if (state && state.running && state.sessionDir) { if (!fs.existsSync(state.sessionDir)) { const failed = { success: false, results: [], outputDir, message: `Previous build state points to a missing session directory: ${state.sessionDir}`, }; finishActiveSession(failed); return { running: false, results: [], outputDir, sessionDir: state.sessionDir, message: failed.message }; } if (!state.pid) { const workerPid = readWorkerPidFromSession(state.sessionDir); if (workerPid) { state.pid = workerPid; writeState({ ...state, pid: workerPid }); } else { const launcherStartedAt = state.launcherStartedAt ? Date.parse(state.launcherStartedAt) : 0; if (launcherStartedAt && Date.now() - launcherStartedAt < 15000) { return { running: true, results: latestResults, outputDir, sessionDir: state.sessionDir, workerLogFile: state.workerLogFile, message: 'Worker launcher is starting' }; } const failed = makeMissingResultFailure(state); finishActiveSession(failed); return { running: false, results: failed.results, outputDir, sessionDir: state.sessionDir, message: failed.message }; } } if (activeSessionDir !== state.sessionDir) { setActiveSession(state.sessionDir); running = true; startActivePoll(); } const payload = pollActiveSession(); if (payload) { finishActiveSession({ ...payload, outputDir }); return { running: false, results: latestResults, outputDir, sessionDir: state.sessionDir }; } if (state.pid && !isProcessAlive(state.pid)) { const failed = makeMissingResultFailure(state); finishActiveSession(failed); return { running: false, results: failed.results, outputDir, sessionDir: state.sessionDir, message: failed.message }; } return { running: true, results: latestResults, outputDir, sessionDir: state.sessionDir, workerLogFile: state.workerLogFile, stage: readJsonFile(state.stageStatusFile) }; } return { running, results: latestResults, outputDir, sessionDir: activeSessionDir }; }); ipcMain.handle('oem-tool:cancel', async () => { const state = readState(); const pid = currentProcess && currentProcess.pid ? currentProcess.pid : state && state.pid; if (!pid) return { success: false, message: 'No running build process' }; spawn('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, stdio: 'ignore' }); stopActivePoll(); currentProcess = null; running = false; writeState({ running: false, cancelledAt: new Date().toISOString(), outputDir, sessionDir: activeSessionDir }); sendLog({ level: 'warn', message: 'Build worker cancelled' }); return { success: true }; }); ipcMain.handle('oem-tool:open-output-dir', async () => { fs.mkdirSync(outputDir, { recursive: true }); await shell.openPath(outputDir); return { success: true, outputDir }; }); app.on('second-instance', () => { appendToolLog('second-instance received'); if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); return; } if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.center(); mainWindow.show(); mainWindow.focus(); }); if (gotSingleInstanceLock) { app.whenReady().then(() => { appendToolLog(`app ready pid=${process.pid}`); createWindow(); }).catch(error => { appendToolLog(`app ready failed: ${error && error.stack ? error.stack : error}`); }); } app.on('will-quit', () => { appendToolLog(`app will-quit running=${running} activeSession=${activeSessionDir || ''}`); }); app.on('quit', (_event, exitCode) => { appendToolLog(`app quit exitCode=${exitCode} running=${running} activeSession=${activeSessionDir || ''}`); }); app.on('before-quit', event => { appendToolLog(`before-quit running=${running} allowQuitWhileRunning=${allowQuitWhileRunning}`); if (running) { if (!allowQuitWhileRunning) { event.preventDefault(); appendToolLog('before-quit ignored while worker is running'); if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); } else { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } return; } writeState({ ...(readState() || {}), running: true, outputDir, sessionDir: activeSessionDir, resultFile: activeResultFile, workerLogFile: activeWorkerLogFile, stageStatusFile: activeStageStatusFile, }); } }); app.on('window-all-closed', () => { if (running && !allowQuitWhileRunning) { appendToolLog('window-all-closed ignored while worker is running'); return; } app.quit(); }); process.on('uncaughtException', error => { appendToolLog(`uncaughtException: ${error && error.stack ? error.stack : error}`); sendLog({ level: 'error', message: `Tool error: ${error && error.message ? error.message : String(error)}` }); }); process.on('unhandledRejection', error => { appendToolLog(`unhandledRejection: ${error && error.stack ? error.stack : error}`); sendLog({ level: 'error', message: `Tool error: ${error && error.message ? error.message : String(error)}` }); }); process.on('SIGINT', () => { appendToolLog(`process SIGINT running=${running} activeSession=${activeSessionDir || ''}`); }); process.on('SIGTERM', () => { appendToolLog(`process SIGTERM running=${running} activeSession=${activeSessionDir || ''}`); }); process.on('beforeExit', code => { appendToolLog(`process beforeExit code=${code} running=${running} activeSession=${activeSessionDir || ''}`); }); process.on('exit', code => { appendToolLog(`process exit code=${code} running=${running} activeSession=${activeSessionDir || ''}`); });