| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393 |
- const { spawn } = 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;
- restartCount: number;
- lastRestartTime: number;
- }
- const NODE_PORT = 14890;
- const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
- const MAX_RESTARTS = 4;
- const RESTART_WINDOW_MS = 2 * 60 * 1000;
- const services: { node: ServiceProcess } = {
- node: {
- process: null,
- name: 'Node Server',
- port: NODE_PORT,
- ready: false,
- restartCount: 0,
- lastRestartTime: 0,
- },
- };
- let logFilePath = '';
- function getLogPath(): string {
- if (!logFilePath) {
- try {
- logFilePath = path.join(app.getPath('userData'), 'local-services.log');
- } catch {
- logFilePath = path.join(process.cwd(), 'local-services.log');
- }
- }
- return logFilePath;
- }
- function log(level: string, ...args: unknown[]): void {
- const line = `[${new Date().toISOString()}] [${level}] ${args.map((item) => typeof item === 'string' ? item : JSON.stringify(item)).join(' ')}`;
- if (level === 'ERROR') {
- console.error(line);
- } else {
- console.log(line);
- }
- try {
- fs.appendFileSync(getLogPath(), line + '\n', 'utf8');
- } catch {
- // ignore logging failures
- }
- }
- function isPackaged(): boolean {
- return app.isPackaged;
- }
- function getServerDir(): string {
- if (isPackaged()) {
- return path.join((process as any).resourcesPath, 'server');
- }
- return path.resolve(__dirname, '..', '..', 'server');
- }
- // 用户目录下的 Playwright 浏览器存放位置
- function getUserPlaywrightBrowsersPath(): string {
- return path.join(app.getPath('userData'), 'playwright-browsers');
- }
- function getPlaywrightBrowsersPath(): string {
- // 1) 已打包:固定使用用户目录,避免污染系统缓存;
- // 2) 开发环境:返回用户目录或系统默认(Playwright 会自动 fallback)。
- return getUserPlaywrightBrowsersPath();
- }
- function getCloakBrowserCacheDir(): string {
- return path.join(app.getPath('userData'), 'cloakbrowser');
- }
- // 检查 Chromium 是否已下载到用户目录
- function isChromiumInstalled(): boolean {
- const dir = getUserPlaywrightBrowsersPath();
- if (!fs.existsSync(dir)) return false;
- try {
- const entries = fs.readdirSync(dir);
- return entries.some((name: string) => name.startsWith('chromium'));
- } catch {
- return false;
- }
- }
- // 进度回调:phase 取值:
- // 'start' 开始下载
- // 'progress' 下载进度(percent 0-100)
- // 'done' 完成(ok 表示成功与否)
- export type PlaywrightInstallProgress =
- | { phase: 'start'; message?: string }
- | { phase: 'progress'; percent: number; message?: string }
- | { phase: 'done'; ok: boolean; message?: string };
- let onPlaywrightProgress: ((p: PlaywrightInstallProgress) => void) | null = null;
- function setPlaywrightProgressHandler(handler: (p: PlaywrightInstallProgress) => void): void {
- onPlaywrightProgress = handler;
- }
- function emitProgress(p: PlaywrightInstallProgress): void {
- try {
- onPlaywrightProgress?.(p);
- } catch {
- // ignore handler errors
- }
- }
- // 解析 playwright CLI 输出中的下载百分比,例如:
- // "|████████████████ | 65% of 130.5 MiB"
- function parsePercent(line: string): number | null {
- const m = line.match(/(\d{1,3})%/);
- if (!m) return null;
- const v = Number(m[1]);
- if (Number.isNaN(v) || v < 0 || v > 100) return null;
- return v;
- }
- // 触发 Node 子进程下载 Chromium
- function installChromiumOnFirstRun(): Promise<boolean> {
- return new Promise((resolve) => {
- if (isChromiumInstalled()) {
- log('INFO', 'Playwright Chromium 已存在,跳过下载');
- resolve(true);
- return;
- }
- const serverDir = getServerDir();
- const browsersPath = getUserPlaywrightBrowsersPath();
- fs.mkdirSync(browsersPath, { recursive: true });
- const cliEntry = path.join(serverDir, 'node_modules', 'playwright', 'cli.js');
- if (!fs.existsSync(cliEntry)) {
- log('ERROR', 'playwright/cli.js not found, cannot install chromium');
- emitProgress({ phase: 'done', ok: false, message: '未找到 playwright CLI' });
- resolve(false);
- return;
- }
- log('INFO', '首次启动:开始下载 Playwright Chromium 到', browsersPath);
- emitProgress({ phase: 'start', message: '开始下载 Chromium 浏览器...' });
- const child = spawn(process.execPath, [cliEntry, 'install', 'chromium'], {
- cwd: serverDir,
- env: {
- ...(process.env as Record<string, string>),
- ELECTRON_RUN_AS_NODE: '1',
- PLAYWRIGHT_BROWSERS_PATH: browsersPath,
- // 让 playwright 输出更详细的进度
- DEBUG: process.env.DEBUG || '',
- },
- stdio: ['ignore', 'pipe', 'pipe'],
- windowsHide: true,
- });
- let lastPercent = -1;
- const handleLine = (raw: string): void => {
- const line = raw.trim();
- if (!line) return;
- log('PW', line);
- const percent = parsePercent(line);
- if (percent !== null && percent !== lastPercent) {
- lastPercent = percent;
- emitProgress({ phase: 'progress', percent, message: line });
- }
- };
- child.stdout?.on('data', (chunk: Buffer) => chunk.toString().split(/\r?\n/).forEach(handleLine));
- child.stderr?.on('data', (chunk: Buffer) => {
- // playwright 通常把进度写到 stderr
- chunk.toString().split(/\r?\n/).forEach((l: string) => {
- const line = l.trim();
- if (!line) return;
- log('PW-ERR', line);
- const percent = parsePercent(line);
- if (percent !== null && percent !== lastPercent) {
- lastPercent = percent;
- emitProgress({ phase: 'progress', percent, message: line });
- }
- });
- });
- child.on('exit', (code: number | null) => {
- const ok = code === 0 && isChromiumInstalled();
- log(ok ? 'INFO' : 'ERROR', `Playwright Chromium install exit code=${code}`);
- emitProgress({ phase: 'done', ok, message: ok ? '浏览器下载完成' : `下载失败 (exit ${code})` });
- resolve(ok);
- });
- child.on('error', (err: Error) => {
- log('ERROR', 'Playwright install spawn error', err.message);
- emitProgress({ phase: 'done', ok: false, message: err.message });
- resolve(false);
- });
- });
- }
- 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 checkHealth(port: number): Promise<boolean> {
- return new Promise((resolve) => {
- const req = http.get(`http://127.0.0.1:${port}/api/health`, { timeout: 2500 }, (res: any) => {
- res.resume();
- resolve(res.statusCode >= 200 && res.statusCode < 500);
- });
- req.on('error', () => resolve(false));
- req.on('timeout', () => {
- req.destroy();
- resolve(false);
- });
- });
- }
- async function waitForService(port: number, timeoutMs = 45000): Promise<boolean> {
- const startedAt = Date.now();
- while (Date.now() - startedAt < timeoutMs) {
- if (await checkHealth(port)) {
- return true;
- }
- await new Promise((resolve) => setTimeout(resolve, 1000));
- }
- return false;
- }
- function shouldRestart(service: ServiceProcess): boolean {
- const now = Date.now();
- if (now - service.lastRestartTime > RESTART_WINDOW_MS) {
- service.restartCount = 0;
- }
- service.lastRestartTime = now;
- service.restartCount += 1;
- return service.restartCount <= MAX_RESTARTS;
- }
- function startNodeServer(): void {
- const service = services.node;
- const serverDir = getServerDir();
- const { cmd, args, useElectronAsNode } = findNodeRunner();
- const env: Record<string, string> = {
- ...(process.env as Record<string, string>),
- PORT: String(NODE_PORT),
- HOST: '127.0.0.1',
- USE_REDIS_QUEUE: 'false',
- LOCAL_NODE_ONLY_AUTOMATION: 'true',
- NODE_OPTIONS: '--max-old-space-size=512',
- };
- if (useElectronAsNode) {
- env.ELECTRON_RUN_AS_NODE = '1';
- }
- if (isPackaged()) {
- const uploadsDir = path.join(app.getPath('userData'), 'uploads');
- fs.mkdirSync(uploadsDir, { recursive: true });
- env.UPLOAD_PATH = uploadsDir;
- }
- if (isPackaged()) {
- env.PLAYWRIGHT_BROWSERS_PATH = getPlaywrightBrowsersPath();
- }
- env.CLOAKBROWSER_CACHE_DIR = getCloakBrowserCacheDir();
- log('INFO', 'Starting node service', cmd, args.join(' '));
- const child = spawn(cmd, args, {
- cwd: serverDir,
- env,
- stdio: ['ignore', 'pipe', 'pipe'],
- shell: false,
- windowsHide: true,
- });
- service.process = child;
- service.ready = false;
- child.stdout?.on('data', (chunk: Buffer) => {
- const message = chunk.toString().trim();
- if (!message) return;
- log('NODE', message);
- if (message.includes('Server running') || message.includes('listening')) {
- service.ready = true;
- }
- });
- child.stderr?.on('data', (chunk: Buffer) => {
- const message = chunk.toString().trim();
- if (message) log('NODE-ERR', message);
- });
- child.on('error', (error: Error) => {
- log('ERROR', 'Node spawn error', error.message);
- });
- child.on('exit', (code: number | null, signal: string | null) => {
- log('WARN', `Node service exited code=${code} signal=${signal}`);
- service.process = null;
- service.ready = false;
- if (signal === 'SIGTERM' || signal === 'SIGINT') {
- return;
- }
- if (!shouldRestart(service)) {
- log('ERROR', 'Node service restart budget exhausted');
- return;
- }
- const backoffMs = Math.min(service.restartCount * 4000, 15000);
- log('INFO', `Restarting node service in ${backoffMs}ms`);
- setTimeout(() => startNodeServer(), backoffMs);
- });
- }
- async function startLocalServices(): Promise<{ nodeOk: boolean }> {
- // 在 packaged 模式下,首次启动时按需下载 Chromium
- if (isPackaged()) {
- try {
- await installChromiumOnFirstRun();
- } catch (err) {
- log('ERROR', 'installChromiumOnFirstRun failed', err);
- }
- }
- if (!services.node.process) {
- startNodeServer();
- }
- const nodeOk = await waitForService(NODE_PORT);
- services.node.ready = nodeOk;
- return { nodeOk };
- }
- function stopLocalServices(): void {
- const service = services.node;
- service.ready = false;
- if (!service.process) return;
- try {
- service.process.kill('SIGTERM');
- } catch (error) {
- log('ERROR', 'Failed to stop node service', error);
- }
- service.process = null;
- }
- function getServiceStatus() {
- return {
- node: {
- ready: services.node.ready,
- port: services.node.port,
- restartCount: services.node.restartCount,
- },
- };
- }
- export {
- NODE_PORT,
- LOCAL_NODE_URL,
- startLocalServices,
- stopLocalServices,
- getServiceStatus,
- getLogPath,
- setPlaywrightProgressHandler,
- };
|