local-services.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. const { spawn } = require('child_process');
  2. const path = require('path');
  3. const fs = require('fs');
  4. const http = require('http');
  5. const { app } = require('electron');
  6. interface ServiceProcess {
  7. process: ReturnType<typeof spawn> | null;
  8. name: string;
  9. port: number;
  10. ready: boolean;
  11. restartCount: number;
  12. lastRestartTime: number;
  13. }
  14. const NODE_PORT = 14890;
  15. const LOCAL_NODE_URL = `http://127.0.0.1:${NODE_PORT}`;
  16. const MAX_RESTARTS = 4;
  17. const RESTART_WINDOW_MS = 2 * 60 * 1000;
  18. const services: { node: ServiceProcess } = {
  19. node: {
  20. process: null,
  21. name: 'Node Server',
  22. port: NODE_PORT,
  23. ready: false,
  24. restartCount: 0,
  25. lastRestartTime: 0,
  26. },
  27. };
  28. let logFilePath = '';
  29. function getLogPath(): string {
  30. if (!logFilePath) {
  31. try {
  32. logFilePath = path.join(app.getPath('userData'), '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 line = `[${new Date().toISOString()}] [${level}] ${args.map((item) => typeof item === 'string' ? item : JSON.stringify(item)).join(' ')}`;
  41. if (level === 'ERROR') {
  42. console.error(line);
  43. } else {
  44. console.log(line);
  45. }
  46. try {
  47. fs.appendFileSync(getLogPath(), line + '\n', 'utf8');
  48. } catch {
  49. // ignore logging failures
  50. }
  51. }
  52. function isPackaged(): boolean {
  53. return app.isPackaged;
  54. }
  55. function getServerDir(): string {
  56. if (isPackaged()) {
  57. return path.join((process as any).resourcesPath, 'server');
  58. }
  59. return path.resolve(__dirname, '..', '..', 'server');
  60. }
  61. // 用户目录下的 Playwright 浏览器存放位置
  62. function getUserPlaywrightBrowsersPath(): string {
  63. return path.join(app.getPath('userData'), 'playwright-browsers');
  64. }
  65. function getPlaywrightBrowsersPath(): string {
  66. // 1) 已打包:固定使用用户目录,避免污染系统缓存;
  67. // 2) 开发环境:返回用户目录或系统默认(Playwright 会自动 fallback)。
  68. return getUserPlaywrightBrowsersPath();
  69. }
  70. function getCloakBrowserCacheDir(): string {
  71. return path.join(app.getPath('userData'), 'cloakbrowser');
  72. }
  73. // 检查 Chromium 是否已下载到用户目录
  74. function isChromiumInstalled(): boolean {
  75. const dir = getUserPlaywrightBrowsersPath();
  76. if (!fs.existsSync(dir)) return false;
  77. try {
  78. const entries = fs.readdirSync(dir);
  79. return entries.some((name: string) => name.startsWith('chromium'));
  80. } catch {
  81. return false;
  82. }
  83. }
  84. // 进度回调:phase 取值:
  85. // 'start' 开始下载
  86. // 'progress' 下载进度(percent 0-100)
  87. // 'done' 完成(ok 表示成功与否)
  88. export type PlaywrightInstallProgress =
  89. | { phase: 'start'; message?: string }
  90. | { phase: 'progress'; percent: number; message?: string }
  91. | { phase: 'done'; ok: boolean; message?: string };
  92. let onPlaywrightProgress: ((p: PlaywrightInstallProgress) => void) | null = null;
  93. function setPlaywrightProgressHandler(handler: (p: PlaywrightInstallProgress) => void): void {
  94. onPlaywrightProgress = handler;
  95. }
  96. function emitProgress(p: PlaywrightInstallProgress): void {
  97. try {
  98. onPlaywrightProgress?.(p);
  99. } catch {
  100. // ignore handler errors
  101. }
  102. }
  103. // 解析 playwright CLI 输出中的下载百分比,例如:
  104. // "|████████████████ | 65% of 130.5 MiB"
  105. function parsePercent(line: string): number | null {
  106. const m = line.match(/(\d{1,3})%/);
  107. if (!m) return null;
  108. const v = Number(m[1]);
  109. if (Number.isNaN(v) || v < 0 || v > 100) return null;
  110. return v;
  111. }
  112. // 触发 Node 子进程下载 Chromium
  113. function installChromiumOnFirstRun(): Promise<boolean> {
  114. return new Promise((resolve) => {
  115. if (isChromiumInstalled()) {
  116. log('INFO', 'Playwright Chromium 已存在,跳过下载');
  117. resolve(true);
  118. return;
  119. }
  120. const serverDir = getServerDir();
  121. const browsersPath = getUserPlaywrightBrowsersPath();
  122. fs.mkdirSync(browsersPath, { recursive: true });
  123. const cliEntry = path.join(serverDir, 'node_modules', 'playwright', 'cli.js');
  124. if (!fs.existsSync(cliEntry)) {
  125. log('ERROR', 'playwright/cli.js not found, cannot install chromium');
  126. emitProgress({ phase: 'done', ok: false, message: '未找到 playwright CLI' });
  127. resolve(false);
  128. return;
  129. }
  130. log('INFO', '首次启动:开始下载 Playwright Chromium 到', browsersPath);
  131. emitProgress({ phase: 'start', message: '开始下载 Chromium 浏览器...' });
  132. const child = spawn(process.execPath, [cliEntry, 'install', 'chromium'], {
  133. cwd: serverDir,
  134. env: {
  135. ...(process.env as Record<string, string>),
  136. ELECTRON_RUN_AS_NODE: '1',
  137. PLAYWRIGHT_BROWSERS_PATH: browsersPath,
  138. // 让 playwright 输出更详细的进度
  139. DEBUG: process.env.DEBUG || '',
  140. },
  141. stdio: ['ignore', 'pipe', 'pipe'],
  142. windowsHide: true,
  143. });
  144. let lastPercent = -1;
  145. const handleLine = (raw: string): void => {
  146. const line = raw.trim();
  147. if (!line) return;
  148. log('PW', line);
  149. const percent = parsePercent(line);
  150. if (percent !== null && percent !== lastPercent) {
  151. lastPercent = percent;
  152. emitProgress({ phase: 'progress', percent, message: line });
  153. }
  154. };
  155. child.stdout?.on('data', (chunk: Buffer) => chunk.toString().split(/\r?\n/).forEach(handleLine));
  156. child.stderr?.on('data', (chunk: Buffer) => {
  157. // playwright 通常把进度写到 stderr
  158. chunk.toString().split(/\r?\n/).forEach((l: string) => {
  159. const line = l.trim();
  160. if (!line) return;
  161. log('PW-ERR', line);
  162. const percent = parsePercent(line);
  163. if (percent !== null && percent !== lastPercent) {
  164. lastPercent = percent;
  165. emitProgress({ phase: 'progress', percent, message: line });
  166. }
  167. });
  168. });
  169. child.on('exit', (code: number | null) => {
  170. const ok = code === 0 && isChromiumInstalled();
  171. log(ok ? 'INFO' : 'ERROR', `Playwright Chromium install exit code=${code}`);
  172. emitProgress({ phase: 'done', ok, message: ok ? '浏览器下载完成' : `下载失败 (exit ${code})` });
  173. resolve(ok);
  174. });
  175. child.on('error', (err: Error) => {
  176. log('ERROR', 'Playwright install spawn error', err.message);
  177. emitProgress({ phase: 'done', ok: false, message: err.message });
  178. resolve(false);
  179. });
  180. });
  181. }
  182. function findNodeRunner(): { cmd: string; args: string[]; useElectronAsNode?: boolean } {
  183. const serverDir = getServerDir();
  184. const distEntry = path.join(serverDir, 'dist', 'app.js');
  185. const srcEntry = path.join(serverDir, 'src', 'app.ts');
  186. const isWin = process.platform === 'win32';
  187. if (isPackaged()) {
  188. return { cmd: process.execPath, args: [distEntry], useElectronAsNode: true };
  189. }
  190. if (fs.existsSync(distEntry)) {
  191. return { cmd: 'node', args: [distEntry] };
  192. }
  193. const tsxBin = path.join(serverDir, 'node_modules', '.bin', isWin ? 'tsx.cmd' : 'tsx');
  194. if (fs.existsSync(tsxBin)) {
  195. return { cmd: tsxBin, args: [srcEntry] };
  196. }
  197. return { cmd: isWin ? 'npx.cmd' : 'npx', args: ['tsx', srcEntry] };
  198. }
  199. function checkHealth(port: number): Promise<boolean> {
  200. return new Promise((resolve) => {
  201. const req = http.get(`http://127.0.0.1:${port}/api/health`, { timeout: 2500 }, (res: any) => {
  202. res.resume();
  203. resolve(res.statusCode >= 200 && res.statusCode < 500);
  204. });
  205. req.on('error', () => resolve(false));
  206. req.on('timeout', () => {
  207. req.destroy();
  208. resolve(false);
  209. });
  210. });
  211. }
  212. async function waitForService(port: number, timeoutMs = 45000): Promise<boolean> {
  213. const startedAt = Date.now();
  214. while (Date.now() - startedAt < timeoutMs) {
  215. if (await checkHealth(port)) {
  216. return true;
  217. }
  218. await new Promise((resolve) => setTimeout(resolve, 1000));
  219. }
  220. return false;
  221. }
  222. function shouldRestart(service: ServiceProcess): boolean {
  223. const now = Date.now();
  224. if (now - service.lastRestartTime > RESTART_WINDOW_MS) {
  225. service.restartCount = 0;
  226. }
  227. service.lastRestartTime = now;
  228. service.restartCount += 1;
  229. return service.restartCount <= MAX_RESTARTS;
  230. }
  231. function startNodeServer(): void {
  232. const service = services.node;
  233. const serverDir = getServerDir();
  234. const { cmd, args, useElectronAsNode } = findNodeRunner();
  235. const env: Record<string, string> = {
  236. ...(process.env as Record<string, string>),
  237. PORT: String(NODE_PORT),
  238. HOST: '127.0.0.1',
  239. USE_REDIS_QUEUE: 'false',
  240. LOCAL_NODE_ONLY_AUTOMATION: 'true',
  241. NODE_OPTIONS: '--max-old-space-size=512',
  242. };
  243. if (useElectronAsNode) {
  244. env.ELECTRON_RUN_AS_NODE = '1';
  245. }
  246. if (isPackaged()) {
  247. const uploadsDir = path.join(app.getPath('userData'), 'uploads');
  248. fs.mkdirSync(uploadsDir, { recursive: true });
  249. env.UPLOAD_PATH = uploadsDir;
  250. }
  251. if (isPackaged()) {
  252. env.PLAYWRIGHT_BROWSERS_PATH = getPlaywrightBrowsersPath();
  253. }
  254. env.CLOAKBROWSER_CACHE_DIR = getCloakBrowserCacheDir();
  255. log('INFO', 'Starting node service', cmd, args.join(' '));
  256. const child = spawn(cmd, args, {
  257. cwd: serverDir,
  258. env,
  259. stdio: ['ignore', 'pipe', 'pipe'],
  260. shell: false,
  261. windowsHide: true,
  262. });
  263. service.process = child;
  264. service.ready = false;
  265. child.stdout?.on('data', (chunk: Buffer) => {
  266. const message = chunk.toString().trim();
  267. if (!message) return;
  268. log('NODE', message);
  269. if (message.includes('Server running') || message.includes('listening')) {
  270. service.ready = true;
  271. }
  272. });
  273. child.stderr?.on('data', (chunk: Buffer) => {
  274. const message = chunk.toString().trim();
  275. if (message) log('NODE-ERR', message);
  276. });
  277. child.on('error', (error: Error) => {
  278. log('ERROR', 'Node spawn error', error.message);
  279. });
  280. child.on('exit', (code: number | null, signal: string | null) => {
  281. log('WARN', `Node service exited code=${code} signal=${signal}`);
  282. service.process = null;
  283. service.ready = false;
  284. if (signal === 'SIGTERM' || signal === 'SIGINT') {
  285. return;
  286. }
  287. if (!shouldRestart(service)) {
  288. log('ERROR', 'Node service restart budget exhausted');
  289. return;
  290. }
  291. const backoffMs = Math.min(service.restartCount * 4000, 15000);
  292. log('INFO', `Restarting node service in ${backoffMs}ms`);
  293. setTimeout(() => startNodeServer(), backoffMs);
  294. });
  295. }
  296. async function startLocalServices(): Promise<{ nodeOk: boolean }> {
  297. // 在 packaged 模式下,首次启动时按需下载 Chromium
  298. if (isPackaged()) {
  299. try {
  300. await installChromiumOnFirstRun();
  301. } catch (err) {
  302. log('ERROR', 'installChromiumOnFirstRun failed', err);
  303. }
  304. }
  305. if (!services.node.process) {
  306. startNodeServer();
  307. }
  308. const nodeOk = await waitForService(NODE_PORT);
  309. services.node.ready = nodeOk;
  310. return { nodeOk };
  311. }
  312. function stopLocalServices(): void {
  313. const service = services.node;
  314. service.ready = false;
  315. if (!service.process) return;
  316. try {
  317. service.process.kill('SIGTERM');
  318. } catch (error) {
  319. log('ERROR', 'Failed to stop node service', error);
  320. }
  321. service.process = null;
  322. }
  323. function getServiceStatus() {
  324. return {
  325. node: {
  326. ready: services.node.ready,
  327. port: services.node.port,
  328. restartCount: services.node.restartCount,
  329. },
  330. };
  331. }
  332. export {
  333. NODE_PORT,
  334. LOCAL_NODE_URL,
  335. startLocalServices,
  336. stopLocalServices,
  337. getServiceStatus,
  338. getLogPath,
  339. setPlaywrightProgressHandler,
  340. };