import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess } from 'electron'; import path from 'path'; import { execFileSync } from 'child_process'; import fs from 'fs'; import net from 'net'; import os from 'os'; let mainWindow: BrowserWindow | null = null; let serverProcess: Electron.UtilityProcess | null = null; let serverPort: number | null = null; let serverErrors: string[] = []; let serverExited = false; let serverExitCode: number | null = null; let userShellEnv: Record = {}; let isQuitting = false; const isDev = !app.isPackaged; /** * Gracefully shut down the server process. * Sends kill() (SIGTERM) first, waits up to 3s for exit, * then force-kills via process.kill(pid, SIGKILL) as fallback. */ function killServer(): Promise { return new Promise((resolve) => { if (!serverProcess) { resolve(); return; } const pid = serverProcess.pid; const timeout = setTimeout(() => { // Force kill via Node's process.kill with SIGKILL if (pid) { try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ } } serverProcess = null; resolve(); }, 3000); serverProcess.on('exit', () => { clearTimeout(timeout); serverProcess = null; resolve(); }); // UtilityProcess.kill() sends SIGTERM serverProcess.kill(); }); } /** * Verify that better_sqlite3.node in standalone resources is compatible * with this Electron runtime's ABI. If it was built for a different * Node.js ABI (e.g. system Node v22 ABI 127 vs Electron's ABI 143), * show a clear error instead of a cryptic MODULE_NOT_FOUND crash. */ function checkNativeModuleABI(): void { if (isDev) return; // Skip in dev mode const standaloneDir = path.join(process.resourcesPath, 'standalone'); // Find better_sqlite3.node recursively function findNodeFile(dir: string): string | null { if (!fs.existsSync(dir)) return null; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const found = findNodeFile(fullPath); if (found) return found; } else if (entry.name === 'better_sqlite3.node') { return fullPath; } } return null; } const nodeFile = findNodeFile(path.join(standaloneDir, 'node_modules')); if (!nodeFile) { console.warn('[ABI check] better_sqlite3.node not found in standalone resources'); return; } try { // Attempt to load the native module to verify ABI compatibility process.dlopen({ exports: {} } as NodeModule, nodeFile); console.log(`[ABI check] better_sqlite3.node ABI is compatible (${nodeFile})`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('NODE_MODULE_VERSION')) { console.error(`[ABI check] ABI mismatch detected: ${msg}`); dialog.showErrorBox( 'CodePilot - Native Module ABI Mismatch', `The bundled better-sqlite3 native module was compiled for a different Node.js version.\n\n` + `${msg}\n\n` + `This usually means the build process did not correctly recompile native modules for Electron.\n` + `Please rebuild the application or report this issue.` ); app.quit(); } else { // Other load errors (missing dependencies, etc.) -- log but don't block console.warn(`[ABI check] Could not verify better_sqlite3.node: ${msg}`); } } } /** * Read the user's full shell environment by running a login shell. * When Electron is launched from Dock/Finder (macOS) or desktop launcher * (Linux), process.env is very limited and won't include vars from * .zshrc/.bashrc (e.g. API keys, nvm PATH). */ function loadUserShellEnv(): Record { // Windows GUI apps inherit the full user environment if (process.platform === 'win32') { return {}; } try { const shell = process.env.SHELL || '/bin/zsh'; const result = execFileSync(shell, ['-ilc', 'env'], { timeout: 5000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); const env: Record = {}; for (const line of result.split('\n')) { const idx = line.indexOf('='); if (idx > 0) { const key = line.slice(0, idx); const value = line.slice(idx + 1); env[key] = value; } } console.log(`Loaded ${Object.keys(env).length} env vars from user shell`); return env; } catch (err) { console.warn('Failed to load user shell env:', err); return {}; } } function getPort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const addr = server.address(); if (addr && typeof addr === 'object') { const port = addr.port; server.close(() => resolve(port)); } else { server.close(() => reject(new Error('Failed to get port'))); } }); }); } async function waitForServer(port: number, timeout = 30000): Promise { const start = Date.now(); while (Date.now() - start < timeout) { // If the server process already exited, fail fast if (serverExited) { throw new Error( `Server process exited with code ${serverExitCode}.\n\n${serverErrors.join('\n')}` ); } try { await new Promise((resolve, reject) => { const req = require('http').get(`http://127.0.0.1:${port}/api/health`, (res: { statusCode?: number }) => { if (res.statusCode === 200) resolve(); else reject(new Error(`Status ${res.statusCode}`)); }); req.on('error', reject); req.setTimeout(1000, () => { req.destroy(); reject(new Error('timeout')); }); }); return; } catch { await new Promise(r => setTimeout(r, 200)); } } throw new Error( `Server startup timeout after ${timeout / 1000}s.\n\n${serverErrors.length > 0 ? 'Server output:\n' + serverErrors.slice(-10).join('\n') : 'No server output captured.'}` ); } function startServer(port: number): Electron.UtilityProcess { const standaloneDir = path.join(process.resourcesPath, 'standalone'); const serverPath = path.join(standaloneDir, 'server.js'); console.log(`Server path: ${serverPath}`); console.log(`Standalone dir: ${standaloneDir}`); serverErrors = []; serverExited = false; serverExitCode = null; const home = os.homedir(); const shellPath = userShellEnv.PATH || process.env.PATH || ''; const sep = path.delimiter; // ';' on Windows, ':' on Unix let constructedPath: string; if (process.platform === 'win32') { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); const winExtra = [ path.join(appData, 'npm'), path.join(localAppData, 'npm'), path.join(home, '.npm-global', 'bin'), path.join(home, '.local', 'bin'), path.join(home, '.claude', 'bin'), ]; const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean); constructedPath = [...new Set(allParts)].join(sep); } else { const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`; const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`; const allParts = raw.split(':').filter(Boolean); constructedPath = [...new Set(allParts)].join(':'); } const env: Record = { ...userShellEnv, ...(process.env as Record), // Ensure user shell env vars override (especially API keys) ...userShellEnv, PORT: String(port), HOSTNAME: '127.0.0.1', CLAUDE_GUI_DATA_DIR: path.join(home, '.codepilot'), HOME: home, USERPROFILE: home, PATH: constructedPath, }; // Use Electron's utilityProcess to run the server in a child process // without spawning a separate Dock icon on macOS. const child = utilityProcess.fork(serverPath, [], { env, cwd: standaloneDir, stdio: 'pipe', serviceName: 'codepilot-server', }); child.stdout?.on('data', (data: Buffer) => { const msg = data.toString().trim(); console.log(`[server] ${msg}`); serverErrors.push(msg); }); child.stderr?.on('data', (data: Buffer) => { const msg = data.toString().trim(); console.error(`[server:err] ${msg}`); serverErrors.push(msg); }); child.on('exit', (code) => { console.log(`Server process exited with code ${code}`); serverExited = true; serverExitCode = code; serverProcess = null; }); return child; } function getIconPath(): string { if (isDev) { return path.join(process.cwd(), 'build', 'icon.png'); } if (process.platform === 'win32') { return path.join(process.resourcesPath, 'icon.ico'); } if (process.platform === 'linux') { return path.join(process.resourcesPath, 'icon.png'); } return path.join(process.resourcesPath, 'icon.icns'); } function createWindow(port: number) { const windowOptions: Electron.BrowserWindowConstructorOptions = { width: 1280, height: 860, minWidth: 800, minHeight: 600, icon: getIconPath(), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, }; if (process.platform === 'darwin') { windowOptions.titleBarStyle = 'hiddenInset'; } else if (process.platform === 'win32') { windowOptions.titleBarStyle = 'hidden'; windowOptions.titleBarOverlay = { color: '#00000000', symbolColor: '#888888', height: 44, }; } mainWindow = new BrowserWindow(windowOptions); mainWindow.loadURL(`http://127.0.0.1:${port}`); if (isDev) { mainWindow.webContents.openDevTools(); } mainWindow.on('closed', () => { mainWindow = null; }); } app.whenReady().then(async () => { // Load user's full shell environment (API keys, PATH, etc.) userShellEnv = loadUserShellEnv(); // Verify native module ABI compatibility before starting the server checkNativeModuleABI(); // Clear cache on version upgrade const currentVersion = app.getVersion(); const versionFilePath = path.join(app.getPath('userData'), 'last-version.txt'); try { const lastVersion = fs.existsSync(versionFilePath) ? fs.readFileSync(versionFilePath, 'utf-8').trim() : ''; if (lastVersion && lastVersion !== currentVersion) { console.log(`Version changed from ${lastVersion} to ${currentVersion}, clearing cache...`); await session.defaultSession.clearCache(); await session.defaultSession.clearStorageData({ storages: ['cachestorage', 'serviceworkers'], }); console.log('Cache cleared successfully'); } fs.writeFileSync(versionFilePath, currentVersion, 'utf-8'); } catch (err) { console.warn('Failed to check/clear version cache:', err); } // Set macOS Dock icon if (process.platform === 'darwin' && app.dock) { const iconPath = getIconPath(); app.dock.setIcon(nativeImage.createFromPath(iconPath)); } try { let port: number; if (isDev) { port = 3000; console.log(`Dev mode: connecting to http://127.0.0.1:${port}`); } else { port = await getPort(); console.log(`Starting server on port ${port}...`); serverProcess = startServer(port); await waitForServer(port); console.log('Server is ready'); } serverPort = port; createWindow(port); } catch (err) { console.error('Failed to start:', err); dialog.showErrorBox( 'CodePilot - Failed to Start', `The internal server could not start.\n\n${err instanceof Error ? err.message : String(err)}\n\nPlease try restarting the application.` ); app.quit(); } }); app.on('window-all-closed', async () => { await killServer(); if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', async () => { if (BrowserWindow.getAllWindows().length === 0) { try { if (!isDev && !serverProcess) { const port = await getPort(); serverProcess = startServer(port); await waitForServer(port); serverPort = port; } createWindow(serverPort || 3000); } catch (err) { console.error('Failed to restart server:', err); } } }); app.on('before-quit', async (e) => { if (serverProcess && !isQuitting) { isQuitting = true; e.preventDefault(); await killServer(); app.quit(); } });