Files
CodePilot/electron/main.ts
T
315aac0f04 fix: sanitize __NEXT_PRIVATE_* env vars from child processes (#161)
* fix: preserve paragraph breaks between text blocks in bridge responses

When Claude's response includes tool calls (Read, Edit, Bash, etc.),
the response text is split into multiple content blocks — one before
each tool call and one after. These blocks were joined with an empty
string (.join('')), causing all paragraphs to concatenate without
any spacing in the final Telegram message.

Changed .join('') to .join('\n\n') so paragraph breaks between text
blocks are preserved, matching how they appear in the desktop UI.

* fix: sanitize __NEXT_PRIVATE_* env vars from child processes

The bundled Next.js standalone server sets __NEXT_PRIVATE_STANDALONE_CONFIG
and __NEXT_PRIVATE_ORIGIN in process.env at runtime. These leak into every
child process spawned by the Electron main process (Claude Code CLI,
node version checks, package installs, etc.), which in turn pollutes
tmux/terminal sessions.

When other Next.js projects detect __NEXT_PRIVATE_STANDALONE_CONFIG in their
environment, they skip normal next.config loading and use CodePilot's baked-in
config instead — silently breaking builds and dev servers system-wide.

Fix: strip all __NEXT_PRIVATE_* variables when constructing env objects for
child processes. The standalone server.js already sets these internally, so
they don't need to be passed from the parent.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
2026-03-05 10:59:39 +08:00

1030 lines
32 KiB
TypeScript

import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess, ipcMain, shell, Tray, Menu } from 'electron';
import path from 'path';
import { execFileSync, spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import net from 'net';
import os from 'os';
/**
* Return a copy of process.env without __NEXT_PRIVATE_* variables.
*
* The bundled Next.js standalone server sets these at runtime
* (e.g. __NEXT_PRIVATE_STANDALONE_CONFIG, __NEXT_PRIVATE_ORIGIN).
* If they leak into child-process environments they cause every
* other Next.js project on the machine to skip its own config
* loading, breaking builds and dev servers.
*/
function sanitizedProcessEnv(): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith('__NEXT_PRIVATE_') && value !== undefined) {
env[key] = value;
}
}
return env;
}
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;
let tray: Tray | null = null;
// --- Install orchestrator ---
interface InstallStep {
id: string;
label: string;
status: 'pending' | 'running' | 'success' | 'failed' | 'skipped';
error?: string;
}
interface InstallState {
status: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
currentStep: string | null;
steps: InstallStep[];
logs: string[];
}
let installState: InstallState = {
status: 'idle',
currentStep: null,
steps: [],
logs: [],
};
let installProcess: ChildProcess | null = null;
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 — on Windows use taskkill to kill the entire process tree
if (pid) {
try {
if (process.platform === 'win32') {
spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' });
} else {
process.kill(pid, 'SIGKILL');
}
} catch { /* already dead */ }
}
serverProcess = null;
resolve();
}, 3000);
serverProcess.on('exit', () => {
clearTimeout(timeout);
serverProcess = null;
resolve();
});
// On Windows, SIGTERM is not supported — use taskkill to kill the tree
if (process.platform === 'win32' && pid) {
spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' });
} else {
serverProcess.kill();
}
});
}
/**
* Check if the remote bridge is currently active by querying the local API.
*/
async function isBridgeActive(): Promise<boolean> {
if (!serverPort) return false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = require('http');
return await new Promise<boolean>((resolve) => {
const req = http.get(`http://127.0.0.1:${serverPort}/api/bridge`, (res: { statusCode?: number; on: (event: string, cb: (data?: Buffer) => void) => void }) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => {
try {
const data = JSON.parse(body);
resolve(data.running === true);
} catch {
resolve(false);
}
});
});
req.on('error', () => resolve(false));
req.setTimeout(2000, () => { req.destroy(); resolve(false); });
});
} catch {
return false;
}
}
/**
* Stop the remote bridge by posting to the local API.
*/
async function stopBridge(): Promise<void> {
if (!serverPort) return;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = require('http');
await new Promise<void>((resolve) => {
const postData = JSON.stringify({ action: 'stop' });
const req = http.request({
hostname: '127.0.0.1',
port: serverPort,
path: '/api/bridge',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
}, () => { resolve(); });
req.on('error', () => resolve());
req.setTimeout(3000, () => { req.destroy(); resolve(); });
req.write(postData);
req.end();
});
} catch {
// ignore — bridge may already be stopped
}
}
/**
* Create a system tray icon for background bridge mode.
* Called when all windows are closed but the bridge is still active.
*/
function createTray(): void {
if (tray) return;
const iconPath = getIconPath();
const trayIcon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
tray = new Tray(trayIcon);
tray.setToolTip('CodePilot — Bridge Active');
const contextMenu = Menu.buildFromTemplate([
{
label: 'Open CodePilot',
click: () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow(`http://127.0.0.1:${serverPort || 3000}`);
} else {
mainWindow?.focus();
}
},
},
{ type: 'separator' },
{
label: 'Bridge Status: Active',
enabled: false,
},
{ type: 'separator' },
{
label: 'Stop Bridge & Quit',
click: async () => {
await stopBridge();
destroyTray();
await killServer();
app.quit();
},
},
]);
tray.setContextMenu(contextMenu);
// Double-click on tray icon opens the window (macOS/Windows)
tray.on('double-click', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow(`http://127.0.0.1:${serverPort || 3000}`);
} else {
mainWindow?.focus();
}
});
}
function destroyTray(): void {
if (tray) {
tray.destroy();
tray = null;
}
}
/**
* 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 {};
}
}
/**
* Build an expanded PATH that includes common locations for node, npm globals,
* claude, nvm, homebrew, etc. Shared by the server launcher and install orchestrator.
*/
function getExpandedShellPath(): string {
const home = os.homedir();
const shellPath = userShellEnv.PATH || process.env.PATH || '';
const sep = path.delimiter;
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);
return [...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);
return [...new Set(allParts)].join(':');
}
}
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) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
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 constructedPath = getExpandedShellPath();
const env: Record<string, string> = {
...userShellEnv,
...sanitizedProcessEnv(),
// 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');
}
/** Inline loading HTML shown while the server starts up */
const LOADING_HTML = `data:text/html;charset=utf-8,${encodeURIComponent(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
height: 100vh; display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0a0a0a; color: #a0a0a0;
-webkit-app-region: drag;
}
.container { text-align: center; }
.spinner {
width: 28px; height: 28px; margin: 0 auto 14px;
border: 2.5px solid rgba(255,255,255,0.1);
border-top-color: rgba(255,255,255,0.5);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
p { font-size: 13px; opacity: 0.7; }
</style>
</head>
<body>
<div class="container">
<div class="spinner"></div>
<p>Starting CodePilot...</p>
</div>
</body>
</html>`)}`;
function createWindow(url?: string) {
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(url || LOADING_HTML);
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));
}
// --- Install wizard IPC handlers ---
ipcMain.handle('install:check-prerequisites', async () => {
const expandedPath = getExpandedShellPath();
const execEnv = { ...sanitizedProcessEnv(), ...userShellEnv, PATH: expandedPath };
const execOpts = { timeout: 5000, encoding: 'utf-8' as const, env: execEnv };
let hasNode = false;
let nodeVersion: string | undefined;
try {
const result = execFileSync('node', ['--version'], execOpts);
nodeVersion = result.trim();
hasNode = true;
} catch {
// node not found
}
let hasClaude = false;
let claudeVersion: string | undefined;
try {
const claudeOpts = process.platform === 'win32'
? { ...execOpts, shell: true }
: execOpts;
const result = execFileSync('claude', ['--version'], claudeOpts);
claudeVersion = result.trim();
hasClaude = true;
} catch {
// claude not found
}
// Check Homebrew on macOS
let hasHomebrew = false;
if (process.platform === 'darwin') {
const brewPaths = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
hasHomebrew = brewPaths.some(p => fs.existsSync(p));
}
return { hasNode, nodeVersion, hasClaude, claudeVersion, hasHomebrew, platform: process.platform };
});
ipcMain.handle('install:start', (_event: Electron.IpcMainInvokeEvent, options?: { includeNode?: boolean }) => {
if (installState.status === 'running') {
throw new Error('Installation is already running');
}
const needsNode = options?.includeNode === true;
// Reset state
const steps: InstallStep[] = [];
if (needsNode) {
steps.push({ id: 'install-node', label: 'Installing Node.js', status: 'pending' });
}
steps.push(
{ id: 'check-node', label: 'Checking Node.js', status: 'pending' },
{ id: 'install-claude', label: 'Installing Claude Code', status: 'pending' },
{ id: 'verify', label: 'Verifying installation', status: 'pending' },
);
installState = {
status: 'running',
currentStep: null,
steps,
logs: [],
};
const expandedPath = getExpandedShellPath();
const execEnv: Record<string, string> = {
...userShellEnv,
...sanitizedProcessEnv(),
...userShellEnv,
PATH: expandedPath,
};
function sendProgress() {
mainWindow?.webContents.send('install:progress', installState);
}
function setStep(id: string, status: InstallStep['status'], error?: string) {
const step = installState.steps.find(s => s.id === id);
if (step) {
step.status = status;
step.error = error;
}
installState.currentStep = id;
sendProgress();
}
function addLog(line: string) {
installState.logs.push(line);
sendProgress();
}
// Run the installation sequence asynchronously
(async () => {
try {
// Step 0 (optional): Install Node.js via package manager
if (needsNode) {
setStep('install-node', 'running');
const nodeInstalled = await new Promise<boolean>((resolve) => {
const isWin = process.platform === 'win32';
const isMac = process.platform === 'darwin';
let cmd: string;
let args: string[];
if (isMac) {
// macOS: Homebrew only — UI should guide the user to install Homebrew first
const brewPaths = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
const brewPath = brewPaths.find(p => fs.existsSync(p));
if (brewPath) {
cmd = brewPath;
args = ['install', 'node'];
addLog(`Running: ${brewPath} install node`);
} else {
addLog('Homebrew is required. Please install Homebrew first and retry.');
resolve(false);
return;
}
} else if (isWin) {
cmd = 'winget';
args = ['install', '-e', '--id', 'OpenJS.NodeJS.LTS', '--accept-source-agreements', '--accept-package-agreements'];
addLog('Running: winget install -e --id OpenJS.NodeJS.LTS');
} else {
// Linux — no universal package manager
addLog('Auto-install of Node.js is not supported on this platform.');
resolve(false);
return;
}
const child = spawn(cmd, args, {
env: execEnv,
shell: isWin,
stdio: ['ignore', 'pipe', 'pipe'],
});
installProcess = child;
child.stdout?.on('data', (data: Buffer) => {
for (const line of data.toString().split('\n').filter(Boolean)) {
addLog(line);
}
});
child.stderr?.on('data', (data: Buffer) => {
for (const line of data.toString().split('\n').filter(Boolean)) {
addLog(line);
}
});
child.on('error', (err) => {
addLog(`Error: ${err.message}`);
resolve(false);
});
child.on('close', (code) => {
installProcess = null;
resolve(code === 0);
});
});
if (installState.status === 'cancelled') {
setStep('install-node', 'failed', 'Cancelled');
return;
}
if (!nodeInstalled) {
setStep('install-node', 'failed', 'Could not auto-install Node.js.');
installState.status = 'failed';
sendProgress();
return;
}
setStep('install-node', 'success');
addLog('Node.js installation completed.');
}
// Step 1: Check node
setStep('check-node', 'running');
try {
const nodeResult = execFileSync('node', ['--version'], {
timeout: 5000,
encoding: 'utf-8',
env: execEnv,
});
addLog(`Node.js found: ${nodeResult.trim()}`);
setStep('check-node', 'success');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addLog(`Node.js not found: ${msg}`);
setStep('check-node', 'failed', 'Node.js is not installed. Please install Node.js first.');
installState.status = 'failed';
sendProgress();
return;
}
// Step 2: Install Claude Code via npm
setStep('install-claude', 'running');
addLog('Running: npm install -g @anthropic-ai/claude-code');
const npmInstallSuccess = await new Promise<boolean>((resolve) => {
const isWin = process.platform === 'win32';
const npmCmd = isWin ? 'npm.cmd' : 'npm';
const child = spawn(npmCmd, ['install', '-g', '@anthropic-ai/claude-code'], {
env: execEnv,
shell: isWin,
stdio: ['ignore', 'pipe', 'pipe'],
});
installProcess = child;
child.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean);
for (const line of lines) {
addLog(line);
}
});
child.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean);
for (const line of lines) {
addLog(line);
}
});
child.on('error', (err) => {
addLog(`npm error: ${err.message}`);
resolve(false);
});
child.on('close', (code) => {
installProcess = null;
if (code === 0) {
addLog('npm install completed successfully');
resolve(true);
} else if (installState.status === 'cancelled') {
addLog('Installation was cancelled');
resolve(false);
} else {
addLog(`npm install exited with code ${code}`);
resolve(false);
}
});
});
if (installState.status === 'cancelled') {
setStep('install-claude', 'failed', 'Cancelled');
return;
}
if (!npmInstallSuccess) {
setStep('install-claude', 'failed', 'npm install failed. Check logs for details.');
installState.status = 'failed';
sendProgress();
return;
}
setStep('install-claude', 'success');
// Step 3: Verify claude is available
setStep('verify', 'running');
try {
const verifyOpts = process.platform === 'win32'
? { timeout: 5000, encoding: 'utf-8' as const, env: execEnv, shell: true }
: { timeout: 5000, encoding: 'utf-8' as const, env: execEnv };
const claudeResult = execFileSync('claude', ['--version'], verifyOpts);
addLog(`Claude Code installed: ${claudeResult.trim()}`);
setStep('verify', 'success');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addLog(`Verification failed: ${msg}`);
setStep('verify', 'failed', 'Claude Code was installed but could not be verified.');
installState.status = 'failed';
sendProgress();
return;
}
installState.status = 'success';
installState.currentStep = null;
sendProgress();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addLog(`Unexpected error: ${msg}`);
installState.status = 'failed';
sendProgress();
}
})();
});
ipcMain.handle('install:cancel', () => {
if (installState.status !== 'running') {
return;
}
installState.status = 'cancelled';
installState.logs.push('Cancelling installation...');
if (installProcess) {
const pid = installProcess.pid;
try {
if (process.platform === 'win32' && pid) {
// Windows: kill entire process tree (shell: true spawns cmd.exe which
// spawns npm/winget — child.kill() only kills the shell, not the tree)
spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' });
} else {
installProcess.kill();
}
} catch {
// already dead
}
installProcess = null;
installState.logs.push('Installation process terminated.');
}
mainWindow?.webContents.send('install:progress', installState);
});
ipcMain.handle('install:get-logs', () => {
return installState.logs;
});
// --- End install wizard IPC handlers ---
// Open a folder in the system file manager (Finder / Explorer)
ipcMain.handle('shell:open-path', async (_event: Electron.IpcMainInvokeEvent, folderPath: string) => {
return shell.openPath(folderPath);
});
// Bridge status IPC
ipcMain.handle('bridge:is-active', async () => {
return isBridgeActive();
});
// Native folder picker dialog
ipcMain.handle('dialog:open-folder', async (_event, options?: { defaultPath?: string; title?: string }) => {
if (!mainWindow) return { canceled: true, filePaths: [] };
const result = await dialog.showOpenDialog(mainWindow, {
title: options?.title || 'Select a project folder',
defaultPath: options?.defaultPath || undefined,
properties: ['openDirectory', 'createDirectory'],
});
return { canceled: result.canceled, filePaths: result.filePaths };
});
try {
let port: number;
if (isDev) {
port = 3000;
console.log(`Dev mode: connecting to http://127.0.0.1:${port}`);
serverPort = port;
createWindow(`http://127.0.0.1:${port}`);
} else {
port = await getPort();
console.log(`Starting server on port ${port}...`);
serverProcess = startServer(port);
serverPort = port;
// Show window immediately with loading screen
createWindow();
// Wait for server in background, then navigate to real URL
await waitForServer(port);
console.log('Server is ready');
if (mainWindow) {
mainWindow.loadURL(`http://127.0.0.1:${port}`);
}
// Trigger bridge auto-start via explicit POST (only checks setting once)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = require('http');
const autoStartData = JSON.stringify({ action: 'auto-start' });
const autoStartReq = http.request({
hostname: '127.0.0.1',
port,
path: '/api/bridge',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(autoStartData),
},
}, () => {});
autoStartReq.on('error', () => {});
autoStartReq.write(autoStartData);
autoStartReq.end();
}
} 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 () => {
// If bridge is active, keep the server running and show a tray icon
const bridgeActive = await isBridgeActive();
if (bridgeActive) {
console.log('Bridge is active — keeping server alive in background with tray icon');
createTray();
return;
}
destroyTray();
await killServer();
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', async () => {
// If tray is active (bridge background mode), destroy it when user re-opens
destroyTray();
if (BrowserWindow.getAllWindows().length === 0) {
try {
if (!isDev && !serverProcess) {
const port = await getPort();
serverProcess = startServer(port);
// Show loading window immediately
createWindow();
await waitForServer(port);
serverPort = port;
if (mainWindow) {
mainWindow.loadURL(`http://127.0.0.1:${port}`);
}
} else {
createWindow(`http://127.0.0.1:${serverPort || 3000}`);
}
} catch (err) {
console.error('Failed to restart server:', err);
}
}
});
app.on('before-quit', async (e) => {
// Kill any running install process (tree-kill on Windows)
if (installProcess) {
const pid = installProcess.pid;
try {
if (process.platform === 'win32' && pid) {
spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' });
} else {
installProcess.kill();
}
} catch { /* already dead */ }
installProcess = null;
}
destroyTray();
if (serverProcess && !isQuitting) {
isQuitting = true;
e.preventDefault();
// Stop bridge gracefully before killing the server
await stopBridge();
await killServer();
app.quit();
}
});