Files
CodePilot/electron/main.ts
T
7418andClaude Opus 4.6 21ca106720 fix: graceful server shutdown with SIGKILL fallback
Add killServer() that sends SIGTERM via UtilityProcess.kill(), waits
up to 3s, then escalates to SIGKILL via process.kill(pid). Fixes
window-all-closed to await shutdown before quitting, and uses a
persistent isQuitting flag in before-quit to prevent infinite loops.

Closes #51

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:55:04 +08:00

412 lines
12 KiB
TypeScript

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<string, string> = {};
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<void> {
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<string, string> {
// 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<string, string> = {};
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<number> {
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<void> {
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<void>((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<string, string> = {
...userShellEnv,
...(process.env as Record<string, string>),
// 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();
}
});