/** * 本地服务管理器 * 在 Electron 主进程中自动启动和管理 Node 服务端与 Python 发布服务 * * 路径策略: * 开发模式:直接引用源码目录 (project-root/server/) * 打包模式:引用 extraResources 打包进来的 resources/server/ */ const { spawn, execSync } = 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; } const NODE_PORT = 3000; const PYTHON_PORT = 5005; const services: { node: ServiceProcess; python: ServiceProcess } = { node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false }, python: { process: null, name: 'Python Service', port: PYTHON_PORT, ready: false }, }; // ==================== 文件日志 ==================== let logFilePath = ''; function getLogFilePath(): string { if (!logFilePath) { try { const logDir = app.getPath('userData'); logFilePath = path.join(logDir, 'local-services.log'); } catch { logFilePath = path.join(process.cwd(), 'local-services.log'); } } return logFilePath; } function log(level: string, ...args: unknown[]): void { const ts = new Date().toISOString(); const msg = `[${ts}] [${level}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`; if (level === 'ERROR') { console.error(msg); } else { console.log(msg); } try { fs.appendFileSync(getLogFilePath(), msg + '\n', 'utf-8'); } catch { /* ignore */ } } // ==================== 核心工具 ==================== function isPackaged(): boolean { return app.isPackaged; } function getServerDir(): string { if (isPackaged()) { return path.join(process.resourcesPath, 'server'); } return path.resolve(__dirname, '..', '..', 'server'); } function getPythonDir(): string { return path.join(getServerDir(), 'python'); } function findPython(): string { const pythonDir = getPythonDir(); const isWin = process.platform === 'win32'; const venvPython = isWin ? path.join(pythonDir, 'venv', 'Scripts', 'python.exe') : path.join(pythonDir, 'venv', 'bin', 'python'); if (fs.existsSync(venvPython)) return venvPython; return isWin ? 'python' : 'python3'; } 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 checkPort(port: number): Promise { return new Promise((resolve) => { const req = http.get(`http://127.0.0.1:${port}/`, { timeout: 2000 }, (res: any) => { res.resume(); resolve(true); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } async function waitForService(port: number, timeoutMs = 30000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (await checkPort(port)) return true; await new Promise((r) => setTimeout(r, 1000)); } return false; } function getEnvFilePath(): string { return path.join(getServerDir(), '.env'); } function getPlaywrightBrowsersPath(): string | null { if (isPackaged()) { const bundled = path.join(process.resourcesPath, 'playwright'); if (fs.existsSync(bundled)) return bundled; } return null; } // ==================== 诊断:列出 resources 目录结构 ==================== function dumpResourcesDir(): void { if (!isPackaged()) return; try { const resDir = process.resourcesPath; log('INFO', `resources 目录: ${resDir}`); const topEntries = fs.readdirSync(resDir); log('INFO', `resources/ 内容: ${topEntries.join(', ')}`); const serverDir = path.join(resDir, 'server'); if (fs.existsSync(serverDir)) { const serverEntries = fs.readdirSync(serverDir); log('INFO', `resources/server/ 内容: ${serverEntries.join(', ')}`); const distDir = path.join(serverDir, 'dist'); if (fs.existsSync(distDir)) { const distEntries = fs.readdirSync(distDir).slice(0, 20); log('INFO', `resources/server/dist/ 内容(前20): ${distEntries.join(', ')}`); } else { log('ERROR', 'resources/server/dist/ 不存在!'); } const pyDir = path.join(serverDir, 'python'); if (fs.existsSync(pyDir)) { const pyEntries = fs.readdirSync(pyDir); log('INFO', `resources/server/python/ 内容: ${pyEntries.join(', ')}`); } else { log('ERROR', 'resources/server/python/ 不存在!'); } const nmDir = path.join(serverDir, 'node_modules'); log('INFO', `resources/server/node_modules/ 存在: ${fs.existsSync(nmDir)}`); } else { log('ERROR', 'resources/server/ 目录不存在!'); } } catch (e: any) { log('ERROR', `诊断目录结构失败: ${e.message}`); } } // ==================== 启动服务 ==================== function startNodeServer(): void { const serverDir = getServerDir(); if (!fs.existsSync(serverDir)) { log('ERROR', `server 目录不存在: ${serverDir}`); return; } const { cmd, args, useElectronAsNode } = findNodeRunner(); log('INFO', `启动 Node 服务: ${cmd} ${args.join(' ')}`); log('INFO', `工作目录: ${serverDir}`); const distApp = path.join(serverDir, 'dist', 'app.js'); log('INFO', `dist/app.js 存在: ${fs.existsSync(distApp)}`); const envFile = getEnvFilePath(); log('INFO', `.env 存在: ${fs.existsSync(envFile)}`); const env: Record = { ...process.env as Record, PORT: String(NODE_PORT), HOST: '127.0.0.1', PYTHON_PUBLISH_SERVICE_URL: `http://127.0.0.1:${PYTHON_PORT}`, }; if (useElectronAsNode) { env.ELECTRON_RUN_AS_NODE = '1'; log('INFO', 'ELECTRON_RUN_AS_NODE=1'); } const pwPath = getPlaywrightBrowsersPath(); if (pwPath) { env.PLAYWRIGHT_BROWSERS_PATH = pwPath; } try { const child = spawn(cmd, args, { cwd: serverDir, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true, }); log('INFO', `Node 进程已 spawn, PID: ${child.pid}`); child.on('error', (err: Error) => { log('ERROR', `Node spawn error: ${err.message}`); }); child.stdout?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg) log('NODE', msg); if (msg.includes('listening') || msg.includes('started') || msg.includes('Server running')) { services.node.ready = true; } }); child.stderr?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg) log('NODE-ERR', msg); }); child.on('exit', (code: number | null, signal: string | null) => { log('INFO', `Node 服务退出, code=${code}, signal=${signal}`); services.node.process = null; services.node.ready = false; }); services.node.process = child; } catch (e: any) { log('ERROR', `Node spawn 异常: ${e.message}`); } } function startPythonService(): void { const pythonDir = getPythonDir(); const appPy = path.join(pythonDir, 'app.py'); if (!fs.existsSync(appPy)) { log('ERROR', `Python 入口不存在: ${appPy}`); return; } const pythonCmd = findPython(); const args = [appPy, '--port', String(PYTHON_PORT), '--host', '127.0.0.1']; log('INFO', `启动 Python 服务: ${pythonCmd} ${args.join(' ')}`); log('INFO', `Python 可执行文件存在: ${fs.existsSync(pythonCmd)}`); log('INFO', `工作目录: ${pythonDir}`); const env: Record = { ...process.env as Record, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', }; const pwPath = getPlaywrightBrowsersPath(); if (pwPath) { env.PLAYWRIGHT_BROWSERS_PATH = pwPath; log('INFO', `Playwright 浏览器路径: ${pwPath}`); } try { const child = spawn(pythonCmd, args, { cwd: pythonDir, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true, }); log('INFO', `Python 进程已 spawn, PID: ${child.pid}`); child.on('error', (err: Error) => { log('ERROR', `Python spawn error: ${err.message}`); }); child.stdout?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg) log('PYTHON', msg); if (msg.includes('Running on') || msg.includes('启动服务')) { services.python.ready = true; } }); child.stderr?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg) log('PYTHON-ERR', msg); }); child.on('exit', (code: number | null, signal: string | null) => { log('INFO', `Python 服务退出, code=${code}, signal=${signal}`); services.python.process = null; services.python.ready = false; }); services.python.process = child; } catch (e: any) { log('ERROR', `Python spawn 异常: ${e.message}`); } } // ==================== 导出 API ==================== export async function startLocalServices(): Promise<{ nodeOk: boolean; pythonOk: boolean }> { // 清空旧日志 try { fs.writeFileSync(getLogFilePath(), '', 'utf-8'); } catch { /* ignore */ } log('INFO', '========== 启动本地服务 =========='); log('INFO', `模式: ${isPackaged() ? '打包' : '开发'}`); log('INFO', `execPath: ${process.execPath}`); log('INFO', `resourcesPath: ${process.resourcesPath}`); log('INFO', `Server 目录: ${getServerDir()}`); log('INFO', `Python 目录: ${getPythonDir()}`); log('INFO', `日志文件: ${getLogFilePath()}`); dumpResourcesDir(); const [nodeAlive, pythonAlive] = await Promise.all([ checkPort(NODE_PORT), checkPort(PYTHON_PORT), ]); if (nodeAlive) { log('INFO', `Node 服务已在 ${NODE_PORT} 端口运行`); services.node.ready = true; } else { startNodeServer(); } if (pythonAlive) { log('INFO', `Python 服务已在 ${PYTHON_PORT} 端口运行`); services.python.ready = true; } else { startPythonService(); } const [nodeOk, pythonOk] = await Promise.all([ services.node.ready ? Promise.resolve(true) : waitForService(NODE_PORT, 30000), services.python.ready ? Promise.resolve(true) : waitForService(PYTHON_PORT, 30000), ]); services.node.ready = nodeOk; services.python.ready = pythonOk; log('INFO', `结果 — Node: ${nodeOk ? '✓ 就绪' : '✗ 未就绪'}, Python: ${pythonOk ? '✓ 就绪' : '✗ 未就绪'}`); return { nodeOk, pythonOk }; } export function stopLocalServices(): void { log('INFO', '正在停止本地服务...'); for (const key of ['node', 'python'] as const) { const svc = services[key]; if (svc.process) { log('INFO', `停止 ${svc.name} (PID: ${svc.process.pid})`); try { if (process.platform === 'win32') { spawn('taskkill', ['/pid', String(svc.process.pid), '/f', '/t'], { windowsHide: true }); } else { svc.process.kill('SIGTERM'); } } catch (e: any) { log('ERROR', `停止 ${svc.name} 失败: ${e.message}`); } svc.process = null; svc.ready = false; } } } export function getServiceStatus(): { node: { ready: boolean; port: number }; python: { ready: boolean; port: number } } { return { node: { ready: services.node.ready, port: NODE_PORT }, python: { ready: services.python.ready, port: PYTHON_PORT }, }; } export function getLogPath(): string { return getLogFilePath(); } export const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`; export const LOCAL_PYTHON_URL = `http://127.0.0.1:${PYTHON_PORT}`;