local-services.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. /**
  2. * 本地服务管理器
  3. * 在 Electron 主进程中自动启动和管理 Node 服务端与 Python 发布服务
  4. *
  5. * 路径策略:
  6. * 开发模式:直接引用源码目录 (project-root/server/)
  7. * 打包模式:引用 extraResources 打包进来的 resources/server/
  8. */
  9. const { spawn, execSync } = require('child_process');
  10. const path = require('path');
  11. const fs = require('fs');
  12. const http = require('http');
  13. const { app } = require('electron');
  14. interface ServiceProcess {
  15. process: ReturnType<typeof spawn> | null;
  16. name: string;
  17. port: number;
  18. ready: boolean;
  19. }
  20. const NODE_PORT = 3000;
  21. const PYTHON_PORT = 5005;
  22. const services: { node: ServiceProcess; python: ServiceProcess } = {
  23. node: { process: null, name: 'Node Server', port: NODE_PORT, ready: false },
  24. python: { process: null, name: 'Python Service', port: PYTHON_PORT, ready: false },
  25. };
  26. // ==================== 文件日志 ====================
  27. let logFilePath = '';
  28. function getLogFilePath(): string {
  29. if (!logFilePath) {
  30. try {
  31. const logDir = app.getPath('userData');
  32. logFilePath = path.join(logDir, 'local-services.log');
  33. } catch {
  34. logFilePath = path.join(process.cwd(), 'local-services.log');
  35. }
  36. }
  37. return logFilePath;
  38. }
  39. function log(level: string, ...args: unknown[]): void {
  40. const ts = new Date().toISOString();
  41. const msg = `[${ts}] [${level}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`;
  42. if (level === 'ERROR') {
  43. console.error(msg);
  44. } else {
  45. console.log(msg);
  46. }
  47. try {
  48. fs.appendFileSync(getLogFilePath(), msg + '\n', 'utf-8');
  49. } catch { /* ignore */ }
  50. }
  51. // ==================== 核心工具 ====================
  52. function isPackaged(): boolean {
  53. return app.isPackaged;
  54. }
  55. function getServerDir(): string {
  56. if (isPackaged()) {
  57. return path.join(process.resourcesPath, 'server');
  58. }
  59. return path.resolve(__dirname, '..', '..', 'server');
  60. }
  61. function getPythonDir(): string {
  62. return path.join(getServerDir(), 'python');
  63. }
  64. function findPython(): string {
  65. const pythonDir = getPythonDir();
  66. const isWin = process.platform === 'win32';
  67. const venvPython = isWin
  68. ? path.join(pythonDir, 'venv', 'Scripts', 'python.exe')
  69. : path.join(pythonDir, 'venv', 'bin', 'python');
  70. if (fs.existsSync(venvPython)) return venvPython;
  71. return isWin ? 'python' : 'python3';
  72. }
  73. function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } {
  74. const serverDir = getServerDir();
  75. const distEntry = path.join(serverDir, 'dist', 'app.js');
  76. const srcEntry = path.join(serverDir, 'src', 'app.ts');
  77. const isWin = process.platform === 'win32';
  78. if (isPackaged()) {
  79. return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true };
  80. }
  81. if (fs.existsSync(distEntry)) {
  82. return { cmd: 'node', args: [distEntry] };
  83. }
  84. const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx');
  85. if (fs.existsSync(tsxBin)) {
  86. return { cmd: tsxBin, args: [srcEntry] };
  87. }
  88. return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] };
  89. }
  90. function checkPort(port: number): Promise<boolean> {
  91. return new Promise((resolve) => {
  92. const req = http.get(`http://127.0.0.1:${port}/`, { timeout: 2000 }, (res: any) => {
  93. res.resume();
  94. resolve(true);
  95. });
  96. req.on('error', () => resolve(false));
  97. req.on('timeout', () => { req.destroy(); resolve(false); });
  98. });
  99. }
  100. async function waitForService(port: number, timeoutMs = 30000): Promise<boolean> {
  101. const start = Date.now();
  102. while (Date.now() - start < timeoutMs) {
  103. if (await checkPort(port)) return true;
  104. await new Promise((r) => setTimeout(r, 1000));
  105. }
  106. return false;
  107. }
  108. function getEnvFilePath(): string {
  109. return path.join(getServerDir(), '.env');
  110. }
  111. function getPlaywrightBrowsersPath(): string | null {
  112. if (isPackaged()) {
  113. const bundled = path.join(process.resourcesPath, 'playwright');
  114. if (fs.existsSync(bundled)) return bundled;
  115. }
  116. return null;
  117. }
  118. // ==================== 诊断:列出 resources 目录结构 ====================
  119. function dumpResourcesDir(): void {
  120. if (!isPackaged()) return;
  121. try {
  122. const resDir = process.resourcesPath;
  123. log('INFO', `resources 目录: ${resDir}`);
  124. const topEntries = fs.readdirSync(resDir);
  125. log('INFO', `resources/ 内容: ${topEntries.join(', ')}`);
  126. const serverDir = path.join(resDir, 'server');
  127. if (fs.existsSync(serverDir)) {
  128. const serverEntries = fs.readdirSync(serverDir);
  129. log('INFO', `resources/server/ 内容: ${serverEntries.join(', ')}`);
  130. const distDir = path.join(serverDir, 'dist');
  131. if (fs.existsSync(distDir)) {
  132. const distEntries = fs.readdirSync(distDir).slice(0, 20);
  133. log('INFO', `resources/server/dist/ 内容(前20): ${distEntries.join(', ')}`);
  134. } else {
  135. log('ERROR', 'resources/server/dist/ 不存在!');
  136. }
  137. const pyDir = path.join(serverDir, 'python');
  138. if (fs.existsSync(pyDir)) {
  139. const pyEntries = fs.readdirSync(pyDir);
  140. log('INFO', `resources/server/python/ 内容: ${pyEntries.join(', ')}`);
  141. } else {
  142. log('ERROR', 'resources/server/python/ 不存在!');
  143. }
  144. const nmDir = path.join(serverDir, 'node_modules');
  145. log('INFO', `resources/server/node_modules/ 存在: ${fs.existsSync(nmDir)}`);
  146. } else {
  147. log('ERROR', 'resources/server/ 目录不存在!');
  148. }
  149. } catch (e: any) {
  150. log('ERROR', `诊断目录结构失败: ${e.message}`);
  151. }
  152. }
  153. // ==================== 启动服务 ====================
  154. function startNodeServer(): void {
  155. const serverDir = getServerDir();
  156. if (!fs.existsSync(serverDir)) {
  157. log('ERROR', `server 目录不存在: ${serverDir}`);
  158. return;
  159. }
  160. const { cmd, args, useElectronAsNode } = findNodeRunner();
  161. log('INFO', `启动 Node 服务: ${cmd} ${args.join(' ')}`);
  162. log('INFO', `工作目录: ${serverDir}`);
  163. const distApp = path.join(serverDir, 'dist', 'app.js');
  164. log('INFO', `dist/app.js 存在: ${fs.existsSync(distApp)}`);
  165. const envFile = getEnvFilePath();
  166. log('INFO', `.env 存在: ${fs.existsSync(envFile)}`);
  167. const env: Record<string, string> = {
  168. ...process.env as Record<string, string>,
  169. PORT: String(NODE_PORT),
  170. HOST: '127.0.0.1',
  171. PYTHON_PUBLISH_SERVICE_URL: `http://127.0.0.1:${PYTHON_PORT}`,
  172. };
  173. if (useElectronAsNode) {
  174. env.ELECTRON_RUN_AS_NODE = '1';
  175. log('INFO', 'ELECTRON_RUN_AS_NODE=1');
  176. }
  177. const pwPath = getPlaywrightBrowsersPath();
  178. if (pwPath) {
  179. env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
  180. }
  181. try {
  182. const child = spawn(cmd, args, {
  183. cwd: serverDir,
  184. env,
  185. stdio: ['ignore', 'pipe', 'pipe'],
  186. shell: false,
  187. windowsHide: true,
  188. });
  189. log('INFO', `Node 进程已 spawn, PID: ${child.pid}`);
  190. child.on('error', (err: Error) => {
  191. log('ERROR', `Node spawn error: ${err.message}`);
  192. });
  193. child.stdout?.on('data', (data: Buffer) => {
  194. const msg = data.toString().trim();
  195. if (msg) log('NODE', msg);
  196. if (msg.includes('listening') || msg.includes('started') || msg.includes('Server running')) {
  197. services.node.ready = true;
  198. }
  199. });
  200. child.stderr?.on('data', (data: Buffer) => {
  201. const msg = data.toString().trim();
  202. if (msg) log('NODE-ERR', msg);
  203. });
  204. child.on('exit', (code: number | null, signal: string | null) => {
  205. log('INFO', `Node 服务退出, code=${code}, signal=${signal}`);
  206. services.node.process = null;
  207. services.node.ready = false;
  208. });
  209. services.node.process = child;
  210. } catch (e: any) {
  211. log('ERROR', `Node spawn 异常: ${e.message}`);
  212. }
  213. }
  214. function startPythonService(): void {
  215. const pythonDir = getPythonDir();
  216. const appPy = path.join(pythonDir, 'app.py');
  217. if (!fs.existsSync(appPy)) {
  218. log('ERROR', `Python 入口不存在: ${appPy}`);
  219. return;
  220. }
  221. const pythonCmd = findPython();
  222. const args = [appPy, '--port', String(PYTHON_PORT), '--host', '127.0.0.1'];
  223. log('INFO', `启动 Python 服务: ${pythonCmd} ${args.join(' ')}`);
  224. log('INFO', `Python 可执行文件存在: ${fs.existsSync(pythonCmd)}`);
  225. log('INFO', `工作目录: ${pythonDir}`);
  226. const env: Record<string, string> = {
  227. ...process.env as Record<string, string>,
  228. PYTHONUNBUFFERED: '1',
  229. PYTHONIOENCODING: 'utf-8',
  230. };
  231. const pwPath = getPlaywrightBrowsersPath();
  232. if (pwPath) {
  233. env.PLAYWRIGHT_BROWSERS_PATH = pwPath;
  234. log('INFO', `Playwright 浏览器路径: ${pwPath}`);
  235. }
  236. try {
  237. const child = spawn(pythonCmd, args, {
  238. cwd: pythonDir,
  239. env,
  240. stdio: ['ignore', 'pipe', 'pipe'],
  241. shell: false,
  242. windowsHide: true,
  243. });
  244. log('INFO', `Python 进程已 spawn, PID: ${child.pid}`);
  245. child.on('error', (err: Error) => {
  246. log('ERROR', `Python spawn error: ${err.message}`);
  247. });
  248. child.stdout?.on('data', (data: Buffer) => {
  249. const msg = data.toString().trim();
  250. if (msg) log('PYTHON', msg);
  251. if (msg.includes('Running on') || msg.includes('启动服务')) {
  252. services.python.ready = true;
  253. }
  254. });
  255. child.stderr?.on('data', (data: Buffer) => {
  256. const msg = data.toString().trim();
  257. if (msg) log('PYTHON-ERR', msg);
  258. });
  259. child.on('exit', (code: number | null, signal: string | null) => {
  260. log('INFO', `Python 服务退出, code=${code}, signal=${signal}`);
  261. services.python.process = null;
  262. services.python.ready = false;
  263. });
  264. services.python.process = child;
  265. } catch (e: any) {
  266. log('ERROR', `Python spawn 异常: ${e.message}`);
  267. }
  268. }
  269. // ==================== 导出 API ====================
  270. export async function startLocalServices(): Promise<{ nodeOk: boolean; pythonOk: boolean }> {
  271. // 清空旧日志
  272. try { fs.writeFileSync(getLogFilePath(), '', 'utf-8'); } catch { /* ignore */ }
  273. log('INFO', '========== 启动本地服务 ==========');
  274. log('INFO', `模式: ${isPackaged() ? '打包' : '开发'}`);
  275. log('INFO', `execPath: ${process.execPath}`);
  276. log('INFO', `resourcesPath: ${process.resourcesPath}`);
  277. log('INFO', `Server 目录: ${getServerDir()}`);
  278. log('INFO', `Python 目录: ${getPythonDir()}`);
  279. log('INFO', `日志文件: ${getLogFilePath()}`);
  280. dumpResourcesDir();
  281. const [nodeAlive, pythonAlive] = await Promise.all([
  282. checkPort(NODE_PORT),
  283. checkPort(PYTHON_PORT),
  284. ]);
  285. if (nodeAlive) {
  286. log('INFO', `Node 服务已在 ${NODE_PORT} 端口运行`);
  287. services.node.ready = true;
  288. } else {
  289. startNodeServer();
  290. }
  291. if (pythonAlive) {
  292. log('INFO', `Python 服务已在 ${PYTHON_PORT} 端口运行`);
  293. services.python.ready = true;
  294. } else {
  295. startPythonService();
  296. }
  297. const [nodeOk, pythonOk] = await Promise.all([
  298. services.node.ready ? Promise.resolve(true) : waitForService(NODE_PORT, 30000),
  299. services.python.ready ? Promise.resolve(true) : waitForService(PYTHON_PORT, 30000),
  300. ]);
  301. services.node.ready = nodeOk;
  302. services.python.ready = pythonOk;
  303. log('INFO', `结果 — Node: ${nodeOk ? '✓ 就绪' : '✗ 未就绪'}, Python: ${pythonOk ? '✓ 就绪' : '✗ 未就绪'}`);
  304. return { nodeOk, pythonOk };
  305. }
  306. export function stopLocalServices(): void {
  307. log('INFO', '正在停止本地服务...');
  308. for (const key of ['node', 'python'] as const) {
  309. const svc = services[key];
  310. if (svc.process) {
  311. log('INFO', `停止 ${svc.name} (PID: ${svc.process.pid})`);
  312. try {
  313. if (process.platform === 'win32') {
  314. spawn('taskkill', ['/pid', String(svc.process.pid), '/f', '/t'], { windowsHide: true });
  315. } else {
  316. svc.process.kill('SIGTERM');
  317. }
  318. } catch (e: any) {
  319. log('ERROR', `停止 ${svc.name} 失败: ${e.message}`);
  320. }
  321. svc.process = null;
  322. svc.ready = false;
  323. }
  324. }
  325. }
  326. export function getServiceStatus(): { node: { ready: boolean; port: number }; python: { ready: boolean; port: number } } {
  327. return {
  328. node: { ready: services.node.ready, port: NODE_PORT },
  329. python: { ready: services.python.ready, port: PYTHON_PORT },
  330. };
  331. }
  332. export function getLogPath(): string {
  333. return getLogFilePath();
  334. }
  335. export const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
  336. export const LOCAL_PYTHON_URL = `http://127.0.0.1:${PYTHON_PORT}`;