import { execSync, spawn, type ChildProcess } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); interface JsonResponse { status: number; json: Record; } /** 怎么拉起后端:源码模式是 .venv 的 python + backend/main.py,打包后是 resources/backend 里的冻结 exe。 */ export interface BackendLaunch { command: string; args: string[]; cwd: string; env: Record; } export function devBackendLaunch(rootDir: string): BackendLaunch { const venv = process.platform === 'win32' ? path.join(rootDir, '.venv', 'Scripts', 'python.exe') : path.join(rootDir, '.venv', 'bin', 'python'); const command = fs.existsSync(venv) ? venv : process.platform === 'win32' ? 'python' : 'python3'; return { command, args: ['backend/main.py'], cwd: rootDir, env: {} }; } export function packagedBackendLaunch(resourcesPath: string, homeDir: string): BackendLaunch { const executable = process.platform === 'win32' ? 'school-assistant-backend.exe' : 'school-assistant-backend'; return { command: path.join(resourcesPath, 'backend', executable), args: [], cwd: homeDir, env: { ASSISTANT_HOME: homeDir, KNOWLEDGE_DIR: path.join(resourcesPath, 'knowledge'), }, }; } /** Python FastAPI 后端的生命周期:Electron 是它唯一的所有者。 */ export class BackendManager { private child: ChildProcess | null = null; constructor(private readonly launch: BackendLaunch, private readonly apiPort: number) {} get apiBase(): string { return `http://127.0.0.1:${this.apiPort}/api`; } request(method: string, pathname: string, body?: unknown, timeoutMs = 1500): Promise { return new Promise((resolve, reject) => { const data = body === undefined ? null : JSON.stringify(body); const req = http.request({ host: '127.0.0.1', port: this.apiPort, path: `/api${pathname}`, method, headers: { 'Content-Type': 'application/json', ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), }, timeout: timeoutMs, }, res => { let buffer = ''; res.on('data', chunk => { buffer += chunk; }); res.on('end', () => { let json: Record = {}; try { json = buffer ? JSON.parse(buffer) as Record : {}; } catch { json = {}; } resolve({ status: res.statusCode ?? 0, json }); }); }); req.on('timeout', () => req.destroy(new Error('timeout'))); req.on('error', reject); if (data) req.write(data); req.end(); }); } async info(): Promise | null> { try { const res = await this.request('GET', '/health'); return res.status === 200 ? res.json : null; } catch { return null; } } private pidsOnPort(): number[] { if (process.platform !== 'win32') return []; try { const out = execSync('netstat -ano -p tcp', { encoding: 'utf8' }); const pids = new Set(); for (const line of out.split(/\r?\n/)) { const match = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)$/); if (match && parseInt(match[1], 10) === this.apiPort) pids.add(parseInt(match[2], 10)); } return [...pids].filter(pid => pid > 0); } catch { return []; } } /** 端口上若已有后端(上次没退干净 / 手动起的),先关掉,保证连的是最新代码。 */ async stopStale(): Promise { const info = await this.info(); if (!info) return; console.log(`[electron] 端口 ${this.apiPort} 上已有后端 (pid ${String(info.pid ?? '?')}),先关闭它`); try { await this.request('POST', '/shutdown'); } catch { /* 旧版本没有 /shutdown */ } for (let i = 0; i < 10; i += 1) { await sleep(300); if (!(await this.info())) return; } for (const pid of this.pidsOnPort()) { try { process.kill(pid, 'SIGKILL'); console.log(`[electron] 已强制结束 pid ${pid}`); } catch (error) { console.warn(`[electron] 结束 pid ${pid} 失败: ${(error as Error).message}`); } } for (let i = 0; i < 10; i += 1) { await sleep(300); if (!(await this.info())) return; } console.warn('[electron] 旧后端仍在运行,新后端可能无法绑定端口'); } start(extraEnv: Record): void { if (!fs.existsSync(this.launch.command) && path.isAbsolute(this.launch.command)) { console.error(`[electron] 找不到后端可执行文件: ${this.launch.command}`); } this.child = spawn(this.launch.command, this.launch.args, { cwd: this.launch.cwd, env: { ...process.env, PYTHONUTF8: '1', API_PORT: String(this.apiPort), ...this.launch.env, ...extraEnv }, stdio: 'inherit', windowsHide: true, }); this.child.on('exit', code => { console.log(`[electron] 后端进程退出 code=${String(code)}`); this.child = null; }); this.child.on('error', error => console.error('Failed to start backend:', error)); } async waitReady(maxAttempts = 120): Promise> { for (let i = 0; i < maxAttempts; i += 1) { const info = await this.info(); if (info) return info; await sleep(500); } throw new Error('Backend not reachable'); } stop(): void { if (!this.child) return; this.request('POST', '/shutdown').catch(() => undefined); const child = this.child; this.child = null; setTimeout(() => { try { child.kill(); } catch { /* already gone */ } }, 500); } /** 渲染层 IPC 用的代理:把请求转给后端并抛出可读错误。 */ async apiFetch(pathname: string, init: { method?: string; body?: unknown } = {}): Promise { const response = await fetch(`${this.apiBase}${pathname}`, { method: init.method ?? 'GET', headers: { 'Content-Type': 'application/json' }, body: init.body === undefined ? undefined : JSON.stringify(init.body), }); const data = await response.json().catch(() => ({})) as Record; if (!response.ok) { const detail = typeof data.detail === 'string' ? data.detail : response.statusText; throw new Error(detail); } return data; } }