/** * VLCode Lite — Electron main process * Runs one backend child process per window so each workspace stays isolated. */ const fs = require('fs'); const http = require('http'); const path = require('path'); const net = require('net'); const { fork } = require('child_process'); const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron'); const DEFAULT_PORT = 4000; const START_TIMEOUT_MS = 30000; const FORCE_KILL_DELAY_MS = 3000; const UNEXPECTED_EXIT_RETRY_WINDOW_MS = 60000; const windowSessions = new Map(); const workspaceWindows = new Map(); const launchPromises = new Map(); let lastFocusedWindowId = null; function normalizeWorkDir(dirPath) { return dirPath ? path.resolve(dirPath) : ''; } function isExistingDirectory(dirPath) { if (!dirPath || !fs.existsSync(dirPath)) return false; try { return fs.statSync(dirPath).isDirectory(); } catch { return false; } } function getAppRoot() { return app.getAppPath(); } function getServerEntry() { if (app.isPackaged) { const packagedEntry = path.join(getAppRoot(), 'bin', 'vlcode-lite.js'); if (fs.existsSync(packagedEntry)) { return packagedEntry; } const unpackedEntry = path.join(process.resourcesPath, 'app.asar.unpacked', 'bin', 'vlcode-lite.js'); if (fs.existsSync(unpackedEntry)) { return unpackedEntry; } } return path.join(getAppRoot(), 'bin', 'vlcode-lite.js'); } function getPreloadPath() { return path.join(__dirname, 'preload.cjs'); } function getChildCwd() { return app.isPackaged ? process.resourcesPath : getAppRoot(); } function getWritableSourceRoot() { return app.isPackaged ? path.join(process.resourcesPath, 'app.asar.unpacked') : getAppRoot(); } function getLogDir() { const candidates = []; try { candidates.push(path.join(app.getPath('userData'), 'logs')); } catch {} candidates.push(path.join(process.cwd(), '.vlcode-electron-logs')); for (const candidate of candidates) { try { fs.mkdirSync(candidate, { recursive: true }); return candidate; } catch {} } return ''; } function appendLog(fileName, message) { const logDir = getLogDir(); if (!logDir) return; try { fs.appendFileSync(path.join(logDir, fileName), `[${new Date().toISOString()}] ${message}\n`); } catch {} } function safeWriteToStream(stream, text) { if (!text) return; try { if (stream?.writable) { stream.write(text); } } catch (err) { appendLog('electron-main.log', `stream write failed: ${err.stack || err.message}`); } } function buildWindowUrl(port) { return `http://127.0.0.1:${port}`; } function buildChildEnv() { const childEnv = { ...process.env, ELECTRON_RUN: '1', ELECTRON_RUN_AS_NODE: '1', NODE_ENV: app.isPackaged ? 'production' : 'development', VLCODE_IS_PACKAGED: app.isPackaged ? '1' : '0', VLCODE_APP_ROOT: getAppRoot(), VLCODE_APP_VERSION: app.getVersion(), VLCODE_UNPACKED_DIR: getWritableSourceRoot(), VLCODE_LOG_DIR: getLogDir(), }; if (app.isPackaged) { const bundledBrowsers = path.join(process.resourcesPath, 'ms-playwright'); if (fs.existsSync(bundledBrowsers)) { childEnv.PLAYWRIGHT_BROWSERS_PATH = bundledBrowsers; } } return childEnv; } function extractRequestedWorkDir(argv) { for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === '--dir' || arg === '-d') { const next = argv[i + 1]; return next ? normalizeWorkDir(next) : ''; } if (arg === '--empty-window') { return ''; } } return null; } function isPortFree(port) { return new Promise((resolve) => { const server = net.createServer(); server.once('error', () => resolve(false)); server.once('listening', () => { server.close(() => resolve(true)); }); server.listen({ port, exclusive: true }); }); } async function findFreePort(startPort = DEFAULT_PORT, maxPort = 4099) { for (let port = startPort; port <= maxPort; port++) { if (await isPortFree(port)) return port; } throw new Error(`No free port found in range ${startPort}-${maxPort}`); } function probeHealth(port) { return new Promise((resolve) => { const req = http.get({ host: '127.0.0.1', port, path: '/api/health', timeout: 1500, }, (res) => { res.resume(); resolve(res.statusCode === 200); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } function waitForServerReady(port, child, timeoutMs = START_TIMEOUT_MS) { return new Promise((resolve, reject) => { let settled = false; const finish = (err) => { if (settled) return; settled = true; clearTimeout(timeout); clearInterval(poller); child.off('exit', onExit); if (err) reject(err); else resolve(); }; const onExit = (code, signal) => { finish(new Error(`Backend exited before ready (code: ${code ?? 'null'}, signal: ${signal ?? 'none'})`)); }; const poller = setInterval(async () => { try { if (await probeHealth(port)) finish(); } catch {} }, 250); const timeout = setTimeout(() => { finish(new Error(`Backend start timeout (${timeoutMs}ms)`)); }, timeoutMs); child.once('exit', onExit); }); } function pipeChildLogs(child, port) { child.stdout?.on('data', (chunk) => { const text = chunk.toString(); appendLog(`backend-${port}.log`, text.trimEnd()); safeWriteToStream(process.stdout, `[vlcode:${port}] ${text}`); }); child.stderr?.on('data', (chunk) => { const text = chunk.toString(); appendLog(`backend-${port}.log`, text.trimEnd()); safeWriteToStream(process.stderr, `[vlcode:${port}] ${text}`); }); } async function startServer(workDir = '') { const port = await findFreePort(DEFAULT_PORT, 4099); const args = ['--web', '--port', String(port)]; if (workDir) args.push('--dir', workDir); else args.push('--empty-window'); const child = fork(getServerEntry(), args, { cwd: getChildCwd(), env: buildChildEnv(), stdio: ['ignore', 'pipe', 'pipe', 'ipc'], }); appendLog('electron-main.log', `starting backend on port ${port} for ${workDir || '[empty window]'}`); pipeChildLogs(child, port); await waitForServerReady(port, child); return { child, port }; } function getSessionByWindow(win) { return win ? windowSessions.get(win.id) || null : null; } function focusWindow(win) { if (!win || win.isDestroyed()) return; if (win.isMinimized()) win.restore(); win.show(); win.focus(); } function cleanupSession(session) { if (!session) return; windowSessions.delete(session.window.id); if (session.workDir && workspaceWindows.get(session.workDir) === session.window.id) { workspaceWindows.delete(session.workDir); } if (lastFocusedWindowId === session.window.id) { lastFocusedWindowId = null; } } function stopServer(session) { const child = session?.serverProcess; if (!child) return; session.serverProcess = null; try { child.kill('SIGTERM'); } catch {} setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch {} } }, FORCE_KILL_DELAY_MS); } async function restartSession(session, reason = 'restart') { if (!session || session.closing || session.restarting) return; session.restarting = true; try { stopServer(session); const started = await startServer(session.workDir); session.serverProcess = started.child; session.port = started.port; session.lastStartAt = Date.now(); bindSessionLifecycle(session); if (session.window && !session.window.isDestroyed()) { await session.window.loadURL(buildWindowUrl(session.port)); } } catch (err) { dialog.showErrorBox( 'VLCode Lite — Backend Restart Failed', `Reason: ${reason}\n\n${err.message}` ); session.window?.close(); } finally { session.restarting = false; } } function bindSessionLifecycle(session) { const child = session.serverProcess; if (!child) return; child.on('message', (msg) => { if (msg?.type === 'restart') { restartSession(session, 'server requested restart'); } }); child.on('exit', (code, signal) => { appendLog( 'electron-main.log', `backend exited for ${session.workDir || '[empty window]'} (code=${code ?? 'null'}, signal=${signal ?? 'none'})` ); if (session.closing || session.restarting) return; if (code === 0 && session.window && !session.window.isDestroyed()) { restartSession(session, 'backend exited cleanly'); return; } const ranLongEnough = (Date.now() - (session.lastStartAt || 0)) > UNEXPECTED_EXIT_RETRY_WINDOW_MS; if (ranLongEnough) { session.unexpectedExitCount = 0; } if ((session.unexpectedExitCount || 0) < 1) { session.unexpectedExitCount = (session.unexpectedExitCount || 0) + 1; restartSession(session, `unexpected backend exit (${code ?? 'null'})`); return; } dialog.showErrorBox( 'VLCode Lite — Backend Stopped', `Workspace: ${session.workDir || '[empty window]'}\nCode: ${code ?? 'null'}\nSignal: ${signal ?? 'none'}\nLogs: ${getLogDir() || '[unavailable]'}` ); session.window?.close(); }); } function createAppWindow(port) { const windowOptions = { width: 1440, height: 900, minWidth: 1024, minHeight: 680, title: 'VLCode Lite', show: false, titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', backgroundColor: '#1e1e2e', webPreferences: { preload: getPreloadPath(), nodeIntegration: false, contextIsolation: true, spellcheck: false, }, }; if (process.platform === 'darwin') { windowOptions.trafficLightPosition = { x: 12, y: 12 }; } const win = new BrowserWindow(windowOptions); win.once('ready-to-show', () => win.show()); win.on('focus', () => { lastFocusedWindowId = win.id; }); win.on('closed', () => { const session = getSessionByWindow(win); if (!session) return; session.closing = true; stopServer(session); cleanupSession(session); }); win.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http') && !url.startsWith(buildWindowUrl(port))) { shell.openExternal(url); return { action: 'deny' }; } return { action: 'allow' }; }); if (!app.isPackaged) { win.webContents.on('before-input-event', (_event, input) => { if (input.meta && input.alt && input.key === 'i') { win.webContents.toggleDevTools(); } }); } win.loadURL(buildWindowUrl(port)); return win; } async function openWorkspacePicker(targetWindow) { const session = getSessionByWindow(targetWindow); const result = await dialog.showOpenDialog(targetWindow || undefined, { title: 'Open Folder', defaultPath: resolvePickerDefaultPath('', session?.workDir || ''), properties: ['openDirectory', 'createDirectory'], }); return result.canceled ? '' : normalizeWorkDir(result.filePaths?.[0] || ''); } async function openWorkspaceWindow(dirPath = '', options = {}) { const workDir = normalizeWorkDir(dirPath); const forceNew = !!options.forceNew; if (workDir && !isExistingDirectory(workDir)) { throw new Error(`Directory not found: ${workDir}`); } const existingId = !forceNew ? workspaceWindows.get(workDir) : null; if (existingId) { const existing = windowSessions.get(existingId); if (existing?.window && !existing.window.isDestroyed()) { focusWindow(existing.window); return { ok: true, reused: true, port: existing.port, windowId: existing.window.id }; } workspaceWindows.delete(workDir); } const launchKey = forceNew && !workDir ? `empty:${Date.now()}:${Math.random().toString(36).slice(2, 8)}` : workDir; if (launchPromises.has(launchKey)) { return launchPromises.get(launchKey); } const launch = (async () => { const started = await startServer(workDir); const win = createAppWindow(started.port); const session = { window: win, serverProcess: started.child, port: started.port, workDir, closing: false, restarting: false, lastStartAt: Date.now(), unexpectedExitCount: 0, }; windowSessions.set(win.id, session); if (workDir) { workspaceWindows.set(workDir, win.id); } lastFocusedWindowId = win.id; bindSessionLifecycle(session); return { ok: true, reused: false, port: session.port, windowId: win.id }; })().finally(() => { launchPromises.delete(launchKey); }); launchPromises.set(launchKey, launch); return launch; } function buildAppMenu() { const template = [ ...(process.platform === 'darwin' ? [{ label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }] : []), { label: 'File', submenu: [ { label: 'New Window', accelerator: 'CmdOrCtrl+Shift+N', click: () => { openWorkspaceWindow('', { forceNew: true }).catch((err) => { dialog.showErrorBox('VLCode Lite — New Window Failed', err.message); }); }, }, { label: 'Open Folder...', accelerator: 'CmdOrCtrl+O', click: async (_item, browserWindow) => { try { const dirPath = await openWorkspacePicker(browserWindow || BrowserWindow.getFocusedWindow()); if (dirPath) await openWorkspaceWindow(dirPath); } catch (err) { dialog.showErrorBox('VLCode Lite — Open Failed', err.message); } }, }, { type: 'separator' }, { role: 'close' }, ], }, { label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }, ], }, { label: 'View', submenu: [ { role: 'reload' }, { role: 'forceReload' }, { role: 'togglefullscreen' }, ...(app.isPackaged ? [] : [{ role: 'toggleDevTools' }]), ], }, { label: 'Window', submenu: [ { role: 'minimize' }, { role: 'zoom' }, ...(process.platform === 'darwin' ? [{ type: 'separator' }, { role: 'front' }, { role: 'window' }] : [{ role: 'close' }]), ], }, { label: 'Help', submenu: [ { label: 'VLCode Lite Docs', click: () => shell.openExternal('https://editor.visuallogic.ai/'), }, ], }, ]; return Menu.buildFromTemplate(template); } function focusLastWindow() { const preferred = lastFocusedWindowId ? windowSessions.get(lastFocusedWindowId)?.window : null; if (preferred && !preferred.isDestroyed()) { focusWindow(preferred); return; } const fallback = BrowserWindow.getAllWindows()[0]; if (fallback) focusWindow(fallback); } function resolvePickerDefaultPath(requestedPath, currentWorkDir = '') { const candidates = [requestedPath, currentWorkDir, currentWorkDir ? path.dirname(currentWorkDir) : '', app.getPath('documents'), app.getPath('home')]; for (const candidate of candidates) { if (!candidate) continue; const resolved = normalizeWorkDir(candidate); if (!resolved || !fs.existsSync(resolved)) continue; try { const stat = fs.statSync(resolved); return stat.isDirectory() ? resolved : path.dirname(resolved); } catch {} } return app.getPath('home'); } function registerIpcHandlers() { ipcMain.handle('vlcode:pick-directory', async (event, payload = {}) => { const win = BrowserWindow.fromWebContents(event.sender); const session = getSessionByWindow(win); const result = await dialog.showOpenDialog(win || undefined, { title: 'Open Folder', defaultPath: resolvePickerDefaultPath(payload.defaultPath, session?.workDir || ''), properties: ['openDirectory', 'createDirectory'], }); return { canceled: result.canceled, path: result.filePaths?.[0] || '', }; }); ipcMain.handle('vlcode:open-workspace-window', async (_event, payload = {}) => { const dirPath = normalizeWorkDir(payload.dirPath); if (!dirPath) throw new Error('dirPath is required'); return openWorkspaceWindow(dirPath, payload); }); } const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); } else { app.on('second-instance', (_event, commandLine) => { const requestedDir = extractRequestedWorkDir(commandLine); if (requestedDir !== null) { openWorkspaceWindow(requestedDir).catch((err) => { dialog.showErrorBox('VLCode Lite — Open Failed', err.message); }); return; } focusLastWindow(); }); app.whenReady().then(async () => { registerIpcHandlers(); Menu.setApplicationMenu(buildAppMenu()); try { const requestedDir = extractRequestedWorkDir(process.argv); await openWorkspaceWindow(requestedDir ?? ''); } catch (err) { dialog.showErrorBox( 'VLCode Lite — Launch Failed', `Failed to start the backend server.\n\n${err.message}` ); app.quit(); return; } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { openWorkspaceWindow('').catch((err) => { dialog.showErrorBox('VLCode Lite — Open Failed', err.message); }); } }); }); app.on('before-quit', () => { for (const session of windowSessions.values()) { session.closing = true; stopServer(session); } }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); }