| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635 |
- /**
- * 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();
- }
- });
- }
|