176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
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<void> => new Promise(resolve => setTimeout(resolve, ms));
|
||
|
||
interface JsonResponse {
|
||
status: number;
|
||
json: Record<string, unknown>;
|
||
}
|
||
|
||
/** 怎么拉起后端:源码模式是 .venv 的 python + backend/main.py,打包后是 resources/backend 里的冻结 exe。 */
|
||
export interface BackendLaunch {
|
||
command: string;
|
||
args: string[];
|
||
cwd: string;
|
||
env: Record<string, string>;
|
||
}
|
||
|
||
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<JsonResponse> {
|
||
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<string, unknown> = {};
|
||
try { json = buffer ? JSON.parse(buffer) as Record<string, unknown> : {}; } 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<Record<string, unknown> | 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<number>();
|
||
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<void> {
|
||
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<string, string>): 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<Record<string, unknown>> {
|
||
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<unknown> {
|
||
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<string, unknown>;
|
||
if (!response.ok) {
|
||
const detail = typeof data.detail === 'string' ? data.detail : response.statusText;
|
||
throw new Error(detail);
|
||
}
|
||
return data;
|
||
}
|
||
}
|