const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const http = require('http'); const { app } = require('electron'); interface ServiceProcess { process: ReturnType | null; name: string; port: number; ready: boolean; restartCount: number; lastRestartTime: number; } const NODE_PORT = 14890; const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`; const MAX_RESTARTS = 4; const RESTART_WINDOW_MS = 2 * 60 * 1000; const services: { node: ServiceProcess } = { node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false, restartCount: 0, lastRestartTime: 0, }, }; let logFilePath = ''; function getLogPath(): string { if (!logFilePath) { try { logFilePath = path.join(app.getPath('userData'), 'local-services.log'); } catch { logFilePath = path.join(process.cwd(), 'local-services.log'); } } return logFilePath; } function log(level: string, ...args: unknown[]): void { const line = `[${new Date().toISOString()}] [${level}] ${args.map((item) => typeof item === 'string' ? item : JSON.stringify(item)).join(' ')}`; if (level === 'ERROR') { console.error(line); } else { console.log(line); } try { fs.appendFileSync(getLogPath(), line + '\n', 'utf8'); } catch { // ignore logging failures } } function isPackaged(): boolean { return app.isPackaged; } function getServerDir(): string { if (isPackaged()) { return path.join((process as any).resourcesPath, 'server'); } return path.resolve(__dirname, '..', '..', 'server'); } // 用户目录下的 Playwright 浏览器存放位置 function getUserPlaywrightBrowsersPath(): string { return path.join(app.getPath('userData'), 'playwright-browsers'); } function getPlaywrightBrowsersPath(): string { // 1) 已打包:固定使用用户目录,避免污染系统缓存; // 2) 开发环境:返回用户目录或系统默认(Playwright 会自动 fallback)。 return getUserPlaywrightBrowsersPath(); } function getCloakBrowserCacheDir(): string { return path.join(app.getPath('userData'), 'cloakbrowser'); } // 检查 Chromium 是否已下载到用户目录 function isChromiumInstalled(): boolean { const dir = getUserPlaywrightBrowsersPath(); if (!fs.existsSync(dir)) return false; try { const entries = fs.readdirSync(dir); return entries.some((name: string) => name.startsWith('chromium')); } catch { return false; } } // 进度回调:phase 取值: // 'start' 开始下载 // 'progress' 下载进度(percent 0-100) // 'done' 完成(ok 表示成功与否) export type PlaywrightInstallProgress = | { phase: 'start'; message?: string } | { phase: 'progress'; percent: number; message?: string } | { phase: 'done'; ok: boolean; message?: string }; let onPlaywrightProgress: ((p: PlaywrightInstallProgress) => void) | null = null; function setPlaywrightProgressHandler(handler: (p: PlaywrightInstallProgress) => void): void { onPlaywrightProgress = handler; } function emitProgress(p: PlaywrightInstallProgress): void { try { onPlaywrightProgress?.(p); } catch { // ignore handler errors } } // 解析 playwright CLI 输出中的下载百分比,例如: // "|████████████████ | 65% of 130.5 MiB" function parsePercent(line: string): number | null { const m = line.match(/(\d{1,3})%/); if (!m) return null; const v = Number(m[1]); if (Number.isNaN(v) || v < 0 || v > 100) return null; return v; } // 触发 Node 子进程下载 Chromium function installChromiumOnFirstRun(): Promise { return new Promise((resolve) => { if (isChromiumInstalled()) { log('INFO', 'Playwright Chromium 已存在,跳过下载'); resolve(true); return; } const serverDir = getServerDir(); const browsersPath = getUserPlaywrightBrowsersPath(); fs.mkdirSync(browsersPath, { recursive: true }); const cliEntry = path.join(serverDir, 'node_modules', 'playwright', 'cli.js'); if (!fs.existsSync(cliEntry)) { log('ERROR', 'playwright/cli.js not found, cannot install chromium'); emitProgress({ phase: 'done', ok: false, message: '未找到 playwright CLI' }); resolve(false); return; } log('INFO', '首次启动:开始下载 Playwright Chromium 到', browsersPath); emitProgress({ phase: 'start', message: '开始下载 Chromium 浏览器...' }); const child = spawn(process.execPath, [cliEntry, 'install', 'chromium'], { cwd: serverDir, env: { ...(process.env as Record), ELECTRON_RUN_AS_NODE: '1', PLAYWRIGHT_BROWSERS_PATH: browsersPath, // 让 playwright 输出更详细的进度 DEBUG: process.env.DEBUG || '', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let lastPercent = -1; const handleLine = (raw: string): void => { const line = raw.trim(); if (!line) return; log('PW', line); const percent = parsePercent(line); if (percent !== null && percent !== lastPercent) { lastPercent = percent; emitProgress({ phase: 'progress', percent, message: line }); } }; child.stdout?.on('data', (chunk: Buffer) => chunk.toString().split(/\r?\n/).forEach(handleLine)); child.stderr?.on('data', (chunk: Buffer) => { // playwright 通常把进度写到 stderr chunk.toString().split(/\r?\n/).forEach((l: string) => { const line = l.trim(); if (!line) return; log('PW-ERR', line); const percent = parsePercent(line); if (percent !== null && percent !== lastPercent) { lastPercent = percent; emitProgress({ phase: 'progress', percent, message: line }); } }); }); child.on('exit', (code: number | null) => { const ok = code === 0 && isChromiumInstalled(); log(ok ? 'INFO' : 'ERROR', `Playwright Chromium install exit code=${code}`); emitProgress({ phase: 'done', ok, message: ok ? '浏览器下载完成' : `下载失败 (exit ${code})` }); resolve(ok); }); child.on('error', (err: Error) => { log('ERROR', 'Playwright install spawn error', err.message); emitProgress({ phase: 'done', ok: false, message: err.message }); resolve(false); }); }); } function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } { const serverDir = getServerDir(); const distEntry = path.join(serverDir, 'dist', 'app.js'); const srcEntry = path.join(serverDir, 'src', 'app.ts'); const isWin = process.platform === 'win32'; if (isPackaged()) { return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true }; } if (fs.existsSync(distEntry)) { return { cmd: 'node', args: [distEntry] }; } const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx'); if (fs.existsSync(tsxBin)) { return { cmd: tsxBin, args: [srcEntry] }; } return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] }; } function checkHealth(port: number): Promise { return new Promise((resolve) => { const req = http.get(`http://127.0.0.1:${port}/api/health`, { timeout: 2500 }, (res: any) => { res.resume(); resolve(res.statusCode >= 200 && res.statusCode < 500); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } async function waitForService(port: number, timeoutMs = 45000): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { if (await checkHealth(port)) { return true; } await new Promise((resolve) => setTimeout(resolve, 1000)); } return false; } function shouldRestart(service: ServiceProcess): boolean { const now = Date.now(); if (now - service.lastRestartTime > RESTART_WINDOW_MS) { service.restartCount = 0; } service.lastRestartTime = now; service.restartCount += 1; return service.restartCount <= MAX_RESTARTS; } function startNodeServer(): void { const service = services.node; const serverDir = getServerDir(); const { cmd, args, useElectronAsNode } = findNodeRunner(); const env: Record = { ...(process.env as Record), PORT: String(NODE_PORT), HOST: '127.0.0.1', USE_REDIS_QUEUE: 'false', LOCAL_NODE_ONLY_AUTOMATION: 'true', NODE_OPTIONS: '--max-old-space-size=512', }; if (useElectronAsNode) { env.ELECTRON_RUN_AS_NODE = '1'; } if (isPackaged()) { const uploadsDir = path.join(app.getPath('userData'), 'uploads'); fs.mkdirSync(uploadsDir, { recursive: true }); env.UPLOAD_PATH = uploadsDir; } if (isPackaged()) { env.PLAYWRIGHT_BROWSERS_PATH = getPlaywrightBrowsersPath(); } env.CLOAKBROWSER_CACHE_DIR = getCloakBrowserCacheDir(); log('INFO', 'Starting node service', cmd, args.join(' ')); const child = spawn(cmd, args, { cwd: serverDir, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true, }); service.process = child; service.ready = false; child.stdout?.on('data', (chunk: Buffer) => { const message = chunk.toString().trim(); if (!message) return; log('NODE', message); if (message.includes('Server running') || message.includes('listening')) { service.ready = true; } }); child.stderr?.on('data', (chunk: Buffer) => { const message = chunk.toString().trim(); if (message) log('NODE-ERR', message); }); child.on('error', (error: Error) => { log('ERROR', 'Node spawn error', error.message); }); child.on('exit', (code: number | null, signal: string | null) => { log('WARN', `Node service exited code=${code} signal=${signal}`); service.process = null; service.ready = false; if (signal === 'SIGTERM' || signal === 'SIGINT') { return; } if (!shouldRestart(service)) { log('ERROR', 'Node service restart budget exhausted'); return; } const backoffMs = Math.min(service.restartCount * 4000, 15000); log('INFO', `Restarting node service in ${backoffMs}ms`); setTimeout(() => startNodeServer(), backoffMs); }); } async function startLocalServices(): Promise<{ nodeOk: boolean }> { // 在 packaged 模式下,首次启动时按需下载 Chromium if (isPackaged()) { try { await installChromiumOnFirstRun(); } catch (err) { log('ERROR', 'installChromiumOnFirstRun failed', err); } } if (!services.node.process) { startNodeServer(); } const nodeOk = await waitForService(NODE_PORT); services.node.ready = nodeOk; return { nodeOk }; } function stopLocalServices(): void { const service = services.node; service.ready = false; if (!service.process) return; try { service.process.kill('SIGTERM'); } catch (error) { log('ERROR', 'Failed to stop node service', error); } service.process = null; } function getServiceStatus() { return { node: { ready: services.node.ready, port: services.node.port, restartCount: services.node.restartCount, }, }; } export { NODE_PORT, LOCAL_NODE_URL, startLocalServices, stopLocalServices, getServiceStatus, getLogPath, setPlaywrightProgressHandler, };