|
|
@@ -0,0 +1,409 @@
|
|
|
+/**
|
|
|
+ * 本地服务管理器
|
|
|
+ * 在 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}`;
|