main.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. /**
  2. * VLCode Lite — Electron main process
  3. * Runs one backend child process per window so each workspace stays isolated.
  4. */
  5. const fs = require('fs');
  6. const http = require('http');
  7. const path = require('path');
  8. const net = require('net');
  9. const { fork } = require('child_process');
  10. const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron');
  11. const DEFAULT_PORT = 4000;
  12. const START_TIMEOUT_MS = 30000;
  13. const FORCE_KILL_DELAY_MS = 3000;
  14. const UNEXPECTED_EXIT_RETRY_WINDOW_MS = 60000;
  15. const windowSessions = new Map();
  16. const workspaceWindows = new Map();
  17. const launchPromises = new Map();
  18. let lastFocusedWindowId = null;
  19. function normalizeWorkDir(dirPath) {
  20. return dirPath ? path.resolve(dirPath) : '';
  21. }
  22. function isExistingDirectory(dirPath) {
  23. if (!dirPath || !fs.existsSync(dirPath)) return false;
  24. try {
  25. return fs.statSync(dirPath).isDirectory();
  26. } catch {
  27. return false;
  28. }
  29. }
  30. function getAppRoot() {
  31. return app.getAppPath();
  32. }
  33. function getServerEntry() {
  34. if (app.isPackaged) {
  35. const packagedEntry = path.join(getAppRoot(), 'bin', 'vlcode-lite.js');
  36. if (fs.existsSync(packagedEntry)) {
  37. return packagedEntry;
  38. }
  39. const unpackedEntry = path.join(process.resourcesPath, 'app.asar.unpacked', 'bin', 'vlcode-lite.js');
  40. if (fs.existsSync(unpackedEntry)) {
  41. return unpackedEntry;
  42. }
  43. }
  44. return path.join(getAppRoot(), 'bin', 'vlcode-lite.js');
  45. }
  46. function getPreloadPath() {
  47. return path.join(__dirname, 'preload.cjs');
  48. }
  49. function getChildCwd() {
  50. return app.isPackaged ? process.resourcesPath : getAppRoot();
  51. }
  52. function getWritableSourceRoot() {
  53. return app.isPackaged
  54. ? path.join(process.resourcesPath, 'app.asar.unpacked')
  55. : getAppRoot();
  56. }
  57. function getLogDir() {
  58. const candidates = [];
  59. try {
  60. candidates.push(path.join(app.getPath('userData'), 'logs'));
  61. } catch {}
  62. candidates.push(path.join(process.cwd(), '.vlcode-electron-logs'));
  63. for (const candidate of candidates) {
  64. try {
  65. fs.mkdirSync(candidate, { recursive: true });
  66. return candidate;
  67. } catch {}
  68. }
  69. return '';
  70. }
  71. function appendLog(fileName, message) {
  72. const logDir = getLogDir();
  73. if (!logDir) return;
  74. try {
  75. fs.appendFileSync(path.join(logDir, fileName), `[${new Date().toISOString()}] ${message}\n`);
  76. } catch {}
  77. }
  78. function safeWriteToStream(stream, text) {
  79. if (!text) return;
  80. try {
  81. if (stream?.writable) {
  82. stream.write(text);
  83. }
  84. } catch (err) {
  85. appendLog('electron-main.log', `stream write failed: ${err.stack || err.message}`);
  86. }
  87. }
  88. function buildWindowUrl(port) {
  89. return `http://127.0.0.1:${port}`;
  90. }
  91. function buildChildEnv() {
  92. const childEnv = {
  93. ...process.env,
  94. ELECTRON_RUN: '1',
  95. ELECTRON_RUN_AS_NODE: '1',
  96. NODE_ENV: app.isPackaged ? 'production' : 'development',
  97. VLCODE_IS_PACKAGED: app.isPackaged ? '1' : '0',
  98. VLCODE_APP_ROOT: getAppRoot(),
  99. VLCODE_APP_VERSION: app.getVersion(),
  100. VLCODE_UNPACKED_DIR: getWritableSourceRoot(),
  101. VLCODE_LOG_DIR: getLogDir(),
  102. };
  103. if (app.isPackaged) {
  104. const bundledBrowsers = path.join(process.resourcesPath, 'ms-playwright');
  105. if (fs.existsSync(bundledBrowsers)) {
  106. childEnv.PLAYWRIGHT_BROWSERS_PATH = bundledBrowsers;
  107. }
  108. }
  109. return childEnv;
  110. }
  111. function extractRequestedWorkDir(argv) {
  112. for (let i = 0; i < argv.length; i++) {
  113. const arg = argv[i];
  114. if (arg === '--dir' || arg === '-d') {
  115. const next = argv[i + 1];
  116. return next ? normalizeWorkDir(next) : '';
  117. }
  118. if (arg === '--empty-window') {
  119. return '';
  120. }
  121. }
  122. return null;
  123. }
  124. function isPortFree(port) {
  125. return new Promise((resolve) => {
  126. const server = net.createServer();
  127. server.once('error', () => resolve(false));
  128. server.once('listening', () => {
  129. server.close(() => resolve(true));
  130. });
  131. server.listen({ port, exclusive: true });
  132. });
  133. }
  134. async function findFreePort(startPort = DEFAULT_PORT, maxPort = 4099) {
  135. for (let port = startPort; port <= maxPort; port++) {
  136. if (await isPortFree(port)) return port;
  137. }
  138. throw new Error(`No free port found in range ${startPort}-${maxPort}`);
  139. }
  140. function probeHealth(port) {
  141. return new Promise((resolve) => {
  142. const req = http.get({
  143. host: '127.0.0.1',
  144. port,
  145. path: '/api/health',
  146. timeout: 1500,
  147. }, (res) => {
  148. res.resume();
  149. resolve(res.statusCode === 200);
  150. });
  151. req.on('error', () => resolve(false));
  152. req.on('timeout', () => {
  153. req.destroy();
  154. resolve(false);
  155. });
  156. });
  157. }
  158. function waitForServerReady(port, child, timeoutMs = START_TIMEOUT_MS) {
  159. return new Promise((resolve, reject) => {
  160. let settled = false;
  161. const finish = (err) => {
  162. if (settled) return;
  163. settled = true;
  164. clearTimeout(timeout);
  165. clearInterval(poller);
  166. child.off('exit', onExit);
  167. if (err) reject(err);
  168. else resolve();
  169. };
  170. const onExit = (code, signal) => {
  171. finish(new Error(`Backend exited before ready (code: ${code ?? 'null'}, signal: ${signal ?? 'none'})`));
  172. };
  173. const poller = setInterval(async () => {
  174. try {
  175. if (await probeHealth(port)) finish();
  176. } catch {}
  177. }, 250);
  178. const timeout = setTimeout(() => {
  179. finish(new Error(`Backend start timeout (${timeoutMs}ms)`));
  180. }, timeoutMs);
  181. child.once('exit', onExit);
  182. });
  183. }
  184. function pipeChildLogs(child, port) {
  185. child.stdout?.on('data', (chunk) => {
  186. const text = chunk.toString();
  187. appendLog(`backend-${port}.log`, text.trimEnd());
  188. safeWriteToStream(process.stdout, `[vlcode:${port}] ${text}`);
  189. });
  190. child.stderr?.on('data', (chunk) => {
  191. const text = chunk.toString();
  192. appendLog(`backend-${port}.log`, text.trimEnd());
  193. safeWriteToStream(process.stderr, `[vlcode:${port}] ${text}`);
  194. });
  195. }
  196. async function startServer(workDir = '') {
  197. const port = await findFreePort(DEFAULT_PORT, 4099);
  198. const args = ['--web', '--port', String(port)];
  199. if (workDir) args.push('--dir', workDir);
  200. else args.push('--empty-window');
  201. const child = fork(getServerEntry(), args, {
  202. cwd: getChildCwd(),
  203. env: buildChildEnv(),
  204. stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
  205. });
  206. appendLog('electron-main.log', `starting backend on port ${port} for ${workDir || '[empty window]'}`);
  207. pipeChildLogs(child, port);
  208. await waitForServerReady(port, child);
  209. return { child, port };
  210. }
  211. function getSessionByWindow(win) {
  212. return win ? windowSessions.get(win.id) || null : null;
  213. }
  214. function focusWindow(win) {
  215. if (!win || win.isDestroyed()) return;
  216. if (win.isMinimized()) win.restore();
  217. win.show();
  218. win.focus();
  219. }
  220. function cleanupSession(session) {
  221. if (!session) return;
  222. windowSessions.delete(session.window.id);
  223. if (session.workDir && workspaceWindows.get(session.workDir) === session.window.id) {
  224. workspaceWindows.delete(session.workDir);
  225. }
  226. if (lastFocusedWindowId === session.window.id) {
  227. lastFocusedWindowId = null;
  228. }
  229. }
  230. function stopServer(session) {
  231. const child = session?.serverProcess;
  232. if (!child) return;
  233. session.serverProcess = null;
  234. try {
  235. child.kill('SIGTERM');
  236. } catch {}
  237. setTimeout(() => {
  238. if (child.exitCode === null && child.signalCode === null) {
  239. try { child.kill('SIGKILL'); } catch {}
  240. }
  241. }, FORCE_KILL_DELAY_MS);
  242. }
  243. async function restartSession(session, reason = 'restart') {
  244. if (!session || session.closing || session.restarting) return;
  245. session.restarting = true;
  246. try {
  247. stopServer(session);
  248. const started = await startServer(session.workDir);
  249. session.serverProcess = started.child;
  250. session.port = started.port;
  251. session.lastStartAt = Date.now();
  252. bindSessionLifecycle(session);
  253. if (session.window && !session.window.isDestroyed()) {
  254. await session.window.loadURL(buildWindowUrl(session.port));
  255. }
  256. } catch (err) {
  257. dialog.showErrorBox(
  258. 'VLCode Lite — Backend Restart Failed',
  259. `Reason: ${reason}\n\n${err.message}`
  260. );
  261. session.window?.close();
  262. } finally {
  263. session.restarting = false;
  264. }
  265. }
  266. function bindSessionLifecycle(session) {
  267. const child = session.serverProcess;
  268. if (!child) return;
  269. child.on('message', (msg) => {
  270. if (msg?.type === 'restart') {
  271. restartSession(session, 'server requested restart');
  272. }
  273. });
  274. child.on('exit', (code, signal) => {
  275. appendLog(
  276. 'electron-main.log',
  277. `backend exited for ${session.workDir || '[empty window]'} (code=${code ?? 'null'}, signal=${signal ?? 'none'})`
  278. );
  279. if (session.closing || session.restarting) return;
  280. if (code === 0 && session.window && !session.window.isDestroyed()) {
  281. restartSession(session, 'backend exited cleanly');
  282. return;
  283. }
  284. const ranLongEnough = (Date.now() - (session.lastStartAt || 0)) > UNEXPECTED_EXIT_RETRY_WINDOW_MS;
  285. if (ranLongEnough) {
  286. session.unexpectedExitCount = 0;
  287. }
  288. if ((session.unexpectedExitCount || 0) < 1) {
  289. session.unexpectedExitCount = (session.unexpectedExitCount || 0) + 1;
  290. restartSession(session, `unexpected backend exit (${code ?? 'null'})`);
  291. return;
  292. }
  293. dialog.showErrorBox(
  294. 'VLCode Lite — Backend Stopped',
  295. `Workspace: ${session.workDir || '[empty window]'}\nCode: ${code ?? 'null'}\nSignal: ${signal ?? 'none'}\nLogs: ${getLogDir() || '[unavailable]'}`
  296. );
  297. session.window?.close();
  298. });
  299. }
  300. function createAppWindow(port) {
  301. const windowOptions = {
  302. width: 1440,
  303. height: 900,
  304. minWidth: 1024,
  305. minHeight: 680,
  306. title: 'VLCode Lite',
  307. show: false,
  308. titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
  309. backgroundColor: '#1e1e2e',
  310. webPreferences: {
  311. preload: getPreloadPath(),
  312. nodeIntegration: false,
  313. contextIsolation: true,
  314. spellcheck: false,
  315. },
  316. };
  317. if (process.platform === 'darwin') {
  318. windowOptions.trafficLightPosition = { x: 12, y: 12 };
  319. }
  320. const win = new BrowserWindow(windowOptions);
  321. win.once('ready-to-show', () => win.show());
  322. win.on('focus', () => {
  323. lastFocusedWindowId = win.id;
  324. });
  325. win.on('closed', () => {
  326. const session = getSessionByWindow(win);
  327. if (!session) return;
  328. session.closing = true;
  329. stopServer(session);
  330. cleanupSession(session);
  331. });
  332. win.webContents.setWindowOpenHandler(({ url }) => {
  333. if (url.startsWith('http') && !url.startsWith(buildWindowUrl(port))) {
  334. shell.openExternal(url);
  335. return { action: 'deny' };
  336. }
  337. return { action: 'allow' };
  338. });
  339. if (!app.isPackaged) {
  340. win.webContents.on('before-input-event', (_event, input) => {
  341. if (input.meta && input.alt && input.key === 'i') {
  342. win.webContents.toggleDevTools();
  343. }
  344. });
  345. }
  346. win.loadURL(buildWindowUrl(port));
  347. return win;
  348. }
  349. async function openWorkspacePicker(targetWindow) {
  350. const session = getSessionByWindow(targetWindow);
  351. const result = await dialog.showOpenDialog(targetWindow || undefined, {
  352. title: 'Open Folder',
  353. defaultPath: resolvePickerDefaultPath('', session?.workDir || ''),
  354. properties: ['openDirectory', 'createDirectory'],
  355. });
  356. return result.canceled ? '' : normalizeWorkDir(result.filePaths?.[0] || '');
  357. }
  358. async function openWorkspaceWindow(dirPath = '', options = {}) {
  359. const workDir = normalizeWorkDir(dirPath);
  360. const forceNew = !!options.forceNew;
  361. if (workDir && !isExistingDirectory(workDir)) {
  362. throw new Error(`Directory not found: ${workDir}`);
  363. }
  364. const existingId = !forceNew ? workspaceWindows.get(workDir) : null;
  365. if (existingId) {
  366. const existing = windowSessions.get(existingId);
  367. if (existing?.window && !existing.window.isDestroyed()) {
  368. focusWindow(existing.window);
  369. return { ok: true, reused: true, port: existing.port, windowId: existing.window.id };
  370. }
  371. workspaceWindows.delete(workDir);
  372. }
  373. const launchKey = forceNew && !workDir
  374. ? `empty:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`
  375. : workDir;
  376. if (launchPromises.has(launchKey)) {
  377. return launchPromises.get(launchKey);
  378. }
  379. const launch = (async () => {
  380. const started = await startServer(workDir);
  381. const win = createAppWindow(started.port);
  382. const session = {
  383. window: win,
  384. serverProcess: started.child,
  385. port: started.port,
  386. workDir,
  387. closing: false,
  388. restarting: false,
  389. lastStartAt: Date.now(),
  390. unexpectedExitCount: 0,
  391. };
  392. windowSessions.set(win.id, session);
  393. if (workDir) {
  394. workspaceWindows.set(workDir, win.id);
  395. }
  396. lastFocusedWindowId = win.id;
  397. bindSessionLifecycle(session);
  398. return { ok: true, reused: false, port: session.port, windowId: win.id };
  399. })().finally(() => {
  400. launchPromises.delete(launchKey);
  401. });
  402. launchPromises.set(launchKey, launch);
  403. return launch;
  404. }
  405. function buildAppMenu() {
  406. const template = [
  407. ...(process.platform === 'darwin' ? [{
  408. label: app.name,
  409. submenu: [
  410. { role: 'about' },
  411. { type: 'separator' },
  412. { role: 'services' },
  413. { type: 'separator' },
  414. { role: 'hide' },
  415. { role: 'hideOthers' },
  416. { role: 'unhide' },
  417. { type: 'separator' },
  418. { role: 'quit' },
  419. ],
  420. }] : []),
  421. {
  422. label: 'File',
  423. submenu: [
  424. {
  425. label: 'New Window',
  426. accelerator: 'CmdOrCtrl+Shift+N',
  427. click: () => {
  428. openWorkspaceWindow('', { forceNew: true }).catch((err) => {
  429. dialog.showErrorBox('VLCode Lite — New Window Failed', err.message);
  430. });
  431. },
  432. },
  433. {
  434. label: 'Open Folder...',
  435. accelerator: 'CmdOrCtrl+O',
  436. click: async (_item, browserWindow) => {
  437. try {
  438. const dirPath = await openWorkspacePicker(browserWindow || BrowserWindow.getFocusedWindow());
  439. if (dirPath) await openWorkspaceWindow(dirPath);
  440. } catch (err) {
  441. dialog.showErrorBox('VLCode Lite — Open Failed', err.message);
  442. }
  443. },
  444. },
  445. { type: 'separator' },
  446. { role: 'close' },
  447. ],
  448. },
  449. {
  450. label: 'Edit',
  451. submenu: [
  452. { role: 'undo' },
  453. { role: 'redo' },
  454. { type: 'separator' },
  455. { role: 'cut' },
  456. { role: 'copy' },
  457. { role: 'paste' },
  458. { role: 'selectAll' },
  459. ],
  460. },
  461. {
  462. label: 'View',
  463. submenu: [
  464. { role: 'reload' },
  465. { role: 'forceReload' },
  466. { role: 'togglefullscreen' },
  467. ...(app.isPackaged ? [] : [{ role: 'toggleDevTools' }]),
  468. ],
  469. },
  470. {
  471. label: 'Window',
  472. submenu: [
  473. { role: 'minimize' },
  474. { role: 'zoom' },
  475. ...(process.platform === 'darwin'
  476. ? [{ type: 'separator' }, { role: 'front' }, { role: 'window' }]
  477. : [{ role: 'close' }]),
  478. ],
  479. },
  480. {
  481. label: 'Help',
  482. submenu: [
  483. {
  484. label: 'VLCode Lite Docs',
  485. click: () => shell.openExternal('https://editor.visuallogic.ai/'),
  486. },
  487. ],
  488. },
  489. ];
  490. return Menu.buildFromTemplate(template);
  491. }
  492. function focusLastWindow() {
  493. const preferred = lastFocusedWindowId ? windowSessions.get(lastFocusedWindowId)?.window : null;
  494. if (preferred && !preferred.isDestroyed()) {
  495. focusWindow(preferred);
  496. return;
  497. }
  498. const fallback = BrowserWindow.getAllWindows()[0];
  499. if (fallback) focusWindow(fallback);
  500. }
  501. function resolvePickerDefaultPath(requestedPath, currentWorkDir = '') {
  502. const candidates = [requestedPath, currentWorkDir, currentWorkDir ? path.dirname(currentWorkDir) : '', app.getPath('documents'), app.getPath('home')];
  503. for (const candidate of candidates) {
  504. if (!candidate) continue;
  505. const resolved = normalizeWorkDir(candidate);
  506. if (!resolved || !fs.existsSync(resolved)) continue;
  507. try {
  508. const stat = fs.statSync(resolved);
  509. return stat.isDirectory() ? resolved : path.dirname(resolved);
  510. } catch {}
  511. }
  512. return app.getPath('home');
  513. }
  514. function registerIpcHandlers() {
  515. ipcMain.handle('vlcode:pick-directory', async (event, payload = {}) => {
  516. const win = BrowserWindow.fromWebContents(event.sender);
  517. const session = getSessionByWindow(win);
  518. const result = await dialog.showOpenDialog(win || undefined, {
  519. title: 'Open Folder',
  520. defaultPath: resolvePickerDefaultPath(payload.defaultPath, session?.workDir || ''),
  521. properties: ['openDirectory', 'createDirectory'],
  522. });
  523. return {
  524. canceled: result.canceled,
  525. path: result.filePaths?.[0] || '',
  526. };
  527. });
  528. ipcMain.handle('vlcode:open-workspace-window', async (_event, payload = {}) => {
  529. const dirPath = normalizeWorkDir(payload.dirPath);
  530. if (!dirPath) throw new Error('dirPath is required');
  531. return openWorkspaceWindow(dirPath, payload);
  532. });
  533. }
  534. const gotLock = app.requestSingleInstanceLock();
  535. if (!gotLock) {
  536. app.quit();
  537. } else {
  538. app.on('second-instance', (_event, commandLine) => {
  539. const requestedDir = extractRequestedWorkDir(commandLine);
  540. if (requestedDir !== null) {
  541. openWorkspaceWindow(requestedDir).catch((err) => {
  542. dialog.showErrorBox('VLCode Lite — Open Failed', err.message);
  543. });
  544. return;
  545. }
  546. focusLastWindow();
  547. });
  548. app.whenReady().then(async () => {
  549. registerIpcHandlers();
  550. Menu.setApplicationMenu(buildAppMenu());
  551. try {
  552. const requestedDir = extractRequestedWorkDir(process.argv);
  553. await openWorkspaceWindow(requestedDir ?? '');
  554. } catch (err) {
  555. dialog.showErrorBox(
  556. 'VLCode Lite — Launch Failed',
  557. `Failed to start the backend server.\n\n${err.message}`
  558. );
  559. app.quit();
  560. return;
  561. }
  562. app.on('activate', () => {
  563. if (BrowserWindow.getAllWindows().length === 0) {
  564. openWorkspaceWindow('').catch((err) => {
  565. dialog.showErrorBox('VLCode Lite — Open Failed', err.message);
  566. });
  567. }
  568. });
  569. });
  570. app.on('before-quit', () => {
  571. for (const session of windowSessions.values()) {
  572. session.closing = true;
  573. stopServer(session);
  574. }
  575. });
  576. app.on('window-all-closed', () => {
  577. if (process.platform !== 'darwin') {
  578. app.quit();
  579. }
  580. });
  581. }