| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409 |
- /**
- * 本地服务管理器
- * 在 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<typeof spawn> | 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<boolean> {
- 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<boolean> {
- 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<string, string> = {
- ...process.env as Record<string, string>,
- 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<string, string> = {
- ...process.env as Record<string, string>,
- 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}`;
|