agent-ecosystem/src/main/index.ts
Jeremy Bornstein 50d5b922b6 feat(linux): add native window frame and pacman packaging
On Linux, `titleBarStyle: 'hidden'` removes the title bar entirely,
leaving no window controls (close/minimize/maximize). macOS has traffic
lights and Windows has a custom title bar component, but Linux gets
nothing. Fix this by omitting the hidden title bar style on Linux,
which gives native window decorations from the desktop environment
(GNOME, KDE, Hyprland, etc.).

Also adds Linux build support:
- electron-builder `linux` config targeting pacman (.pkg.tar.zst)
- `dist:linux` script for standalone Linux builds
- `release-linux` CI job on ubuntu-latest
- `dist` script updated to include --linux
2026-02-14 11:14:55 +11:00

543 lines
16 KiB
TypeScript

/**
* Main process entry point for claude-devtools.
*
* Responsibilities:
* - Initialize Electron app and main window
* - Set up IPC handlers for data access
* - Initialize ServiceContextRegistry with local context
* - Start file watcher for live updates
* - Manage application lifecycle
*/
import {
DEFAULT_WINDOW_HEIGHT,
DEFAULT_WINDOW_WIDTH,
DEV_SERVER_PORT,
getTrafficLightPositionForZoom,
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
} from '@shared/constants';
import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow, ipcMain } from 'electron';
import { existsSync } from 'fs';
import { join } from 'path';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
// Window icon path for non-mac platforms.
const getWindowIconPath = (): string | undefined => {
const isDev = process.env.NODE_ENV === 'development';
const candidates = isDev
? [join(process.cwd(), 'resources/icon.png')]
: [
join(process.resourcesPath, 'resources/icon.png'),
join(__dirname, '../../resources/icon.png'),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return undefined;
};
const logger = createLogger('App');
// IPC channel constants (duplicated from @preload to avoid boundary violation)
const SSH_STATUS = 'ssh:status';
const CONTEXT_CHANGED = 'context:changed';
const HTTP_SERVER_START = 'httpServer:start';
const HTTP_SERVER_STOP = 'httpServer:stop';
const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled promise rejection in main process:', reason);
});
process.on('uncaughtException', (error) => {
logger.error('Uncaught exception in main process:', error);
});
import { HttpServer } from './services/infrastructure/HttpServer';
import {
configManager,
LocalFileSystemProvider,
NotificationManager,
ServiceContext,
ServiceContextRegistry,
SshConnectionManager,
UpdaterService,
} from './services';
// =============================================================================
// Application State
// =============================================================================
let mainWindow: BrowserWindow | null = null;
// Service registry and global services
let contextRegistry: ServiceContextRegistry;
let notificationManager: NotificationManager;
let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager;
let httpServer: HttpServer;
// File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null;
let todoChangeCleanup: (() => void) | null = null;
/**
* Resolve production renderer index path.
* Main bundle lives in dist-electron/main, while renderer lives in out/renderer.
*/
function getRendererIndexPath(): string {
const candidates = [
join(__dirname, '../../out/renderer/index.html'),
join(__dirname, '../renderer/index.html'),
];
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
}
/**
* Wires file watcher events from a ServiceContext to the renderer and HTTP SSE clients.
* Cleans up previous listeners before adding new ones.
*/
function wireFileWatcherEvents(context: ServiceContext): void {
logger.info(`Wiring FileWatcher events for context: ${context.id}`);
// Clean up previous listeners
if (fileChangeCleanup) {
fileChangeCleanup();
fileChangeCleanup = null;
}
if (todoChangeCleanup) {
todoChangeCleanup();
todoChangeCleanup = null;
}
// Wire file-change events to renderer and HTTP SSE
const fileChangeHandler = (event: unknown) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('file-change', event);
}
httpServer?.broadcast('file-change', event);
};
context.fileWatcher.on('file-change', fileChangeHandler);
fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler);
// Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above)
const todoChangeHandler = (event: unknown) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('todo-change', event);
}
httpServer?.broadcast('todo-change', event);
};
context.fileWatcher.on('todo-change', todoChangeHandler);
todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler);
logger.info(`FileWatcher events wired for context: ${context.id}`);
}
/**
* Handles mode switch requests from the HTTP server.
* Switches the active context back to local when requested.
*/
async function handleModeSwitch(mode: 'local' | 'ssh'): Promise<void> {
if (mode === 'local' && contextRegistry.getActiveContextId() !== 'local') {
const { current } = contextRegistry.switch('local');
onContextSwitched(current);
}
}
/**
* Re-wires file watcher events only. No renderer notification.
* Used for renderer-initiated switches where the renderer already handles state.
*/
export function rewireContextEvents(context: ServiceContext): void {
wireFileWatcherEvents(context);
}
/**
* Full callback: re-wire + notify renderer.
* Used for external/unexpected switches (e.g., HTTP server mode switch).
*/
function onContextSwitched(context: ServiceContext): void {
rewireContextEvents(context);
// Notify renderer of context change
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(SSH_STATUS, sshConnectionManager.getStatus());
mainWindow.webContents.send(CONTEXT_CHANGED, {
id: context.id,
type: context.type,
});
}
}
/**
* Initializes all services.
*/
function initializeServices(): void {
logger.info('Initializing services...');
// Initialize SSH connection manager
sshConnectionManager = new SshConnectionManager();
// Create ServiceContextRegistry
contextRegistry = new ServiceContextRegistry();
// Create local context
const localContext = new ServiceContext({
id: 'local',
type: 'local',
fsProvider: new LocalFileSystemProvider(),
});
// Register and start local context
contextRegistry.registerContext(localContext);
localContext.start();
logger.info(`Projects directory: ${localContext.projectScanner.getProjectsDir()}`);
// Initialize notification manager (singleton, not context-scoped)
notificationManager = NotificationManager.getInstance();
// Set notification manager on local context's file watcher
localContext.fileWatcher.setNotificationManager(notificationManager);
// Wire file watcher events for local context
wireFileWatcherEvents(localContext);
// Initialize updater service
updaterService = new UpdaterService();
httpServer = new HttpServer();
// Initialize IPC handlers with registry
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, {
rewire: rewireContextEvents,
full: onContextSwitched,
});
// HTTP Server control IPC handlers
ipcMain.handle(HTTP_SERVER_START, async () => {
try {
if (httpServer.isRunning()) {
return { success: true, data: { running: true, port: httpServer.getPort() } };
}
await startHttpServer(handleModeSwitch);
// Persist the enabled state
configManager.updateConfig('httpServer', { enabled: true, port: httpServer.getPort() });
return { success: true, data: { running: true, port: httpServer.getPort() } };
} catch (error) {
logger.error('Failed to start HTTP server via IPC:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to start server',
};
}
});
ipcMain.handle(HTTP_SERVER_STOP, async () => {
try {
await httpServer.stop();
// Persist the disabled state
configManager.updateConfig('httpServer', { enabled: false });
return { success: true, data: { running: false, port: httpServer.getPort() } };
} catch (error) {
logger.error('Failed to stop HTTP server via IPC:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to stop server',
};
}
});
ipcMain.handle(HTTP_SERVER_GET_STATUS, () => {
return { running: httpServer.isRunning(), port: httpServer.getPort() };
});
// Forward SSH state changes to renderer and HTTP SSE clients
sshConnectionManager.on('state-change', (status: unknown) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(SSH_STATUS, status);
}
httpServer.broadcast('ssh:status', status);
});
// Forward notification events to HTTP SSE clients
notificationManager.on('notification-new', (notification: unknown) => {
httpServer.broadcast('notification:new', notification);
});
notificationManager.on('notification-updated', (data: unknown) => {
httpServer.broadcast('notification:updated', data);
});
notificationManager.on('notification-clicked', (data: unknown) => {
httpServer.broadcast('notification:clicked', data);
});
// Start HTTP server if enabled in config
const appConfig = configManager.getConfig();
if (appConfig.httpServer?.enabled) {
void startHttpServer(handleModeSwitch);
}
logger.info('Services initialized successfully');
}
/**
* Starts the HTTP sidecar server with services from the active context.
*/
async function startHttpServer(
modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void>
): Promise<void> {
try {
const config = configManager.getConfig();
const activeContext = contextRegistry.getActive();
const port = await httpServer.start(
{
projectScanner: activeContext.projectScanner,
sessionParser: activeContext.sessionParser,
subagentResolver: activeContext.subagentResolver,
chunkBuilder: activeContext.chunkBuilder,
dataCache: activeContext.dataCache,
updaterService,
sshConnectionManager,
},
modeSwitchHandler,
config.httpServer?.port ?? 3456
);
logger.info(`HTTP sidecar server running on port ${port}`);
} catch (error) {
logger.error('Failed to start HTTP server:', error);
}
}
/**
* Shuts down all services.
*/
function shutdownServices(): void {
logger.info('Shutting down services...');
// Stop HTTP server
if (httpServer?.isRunning()) {
void httpServer.stop();
}
// Clean up file watcher event listeners
if (fileChangeCleanup) {
fileChangeCleanup();
fileChangeCleanup = null;
}
if (todoChangeCleanup) {
todoChangeCleanup();
todoChangeCleanup = null;
}
// Dispose all contexts (including local)
if (contextRegistry) {
contextRegistry.dispose();
}
// Dispose SSH connection manager
if (sshConnectionManager) {
sshConnectionManager.dispose();
}
// Remove IPC handlers
removeIpcHandlers();
logger.info('Services shut down successfully');
}
/**
* Update native traffic-light position and notify renderer of the current zoom factor.
*/
function syncTrafficLightPosition(win: BrowserWindow): void {
const zoomFactor = win.webContents.getZoomFactor();
const position = getTrafficLightPositionForZoom(zoomFactor);
// setWindowButtonPosition is macOS-only (traffic light buttons)
if (process.platform === 'darwin') {
win.setWindowButtonPosition(position);
}
win.webContents.send(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor);
}
/**
* Creates the main application window.
*/
function createWindow(): void {
const isMac = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
const iconPath = isMac ? undefined : getWindowIconPath();
mainWindow = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
...(iconPath ? { icon: iconPath } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
},
backgroundColor: '#1a1a1a',
...(isLinux ? {} : { titleBarStyle: 'hidden' as const }),
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
title: 'claude-devtools',
});
// Load the renderer
if (process.env.NODE_ENV === 'development') {
void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`);
mainWindow.webContents.openDevTools();
} else {
void mainWindow.loadFile(getRendererIndexPath()).catch((error: unknown) => {
logger.error('Failed to load renderer entry HTML:', error);
});
}
// Set traffic light position + notify renderer on first load, and auto-check for updates
mainWindow.webContents.on('did-finish-load', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
syncTrafficLightPosition(mainWindow);
// Auto-check for updates 3 seconds after window loads
setTimeout(() => updaterService.checkForUpdates(), 3000);
}
});
// Log top-level renderer load failures (helps diagnose blank/black window issues in packaged apps)
mainWindow.webContents.on(
'did-fail-load',
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame) {
logger.error(
`Failed to load renderer (code=${errorCode}): ${errorDescription} - ${validatedURL}`
);
}
}
);
// Sync traffic light position when zoom changes (Cmd+/-, Cmd+0)
// zoom-changed event doesn't fire in Electron 40, so we detect zoom keys directly.
// Also keeps zoom bounds within a practical readability range.
const MIN_ZOOM_LEVEL = -3; // ~70%
const MAX_ZOOM_LEVEL = 5;
const ZOOM_IN_KEYS = new Set(['+', '=']);
const ZOOM_OUT_KEYS = new Set(['-', '_']);
mainWindow.webContents.on('before-input-event', (event, input) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!input.meta || input.type !== 'keyDown') return;
const currentLevel = mainWindow.webContents.getZoomLevel();
// Block zoom-out beyond minimum
if (ZOOM_OUT_KEYS.has(input.key) && currentLevel <= MIN_ZOOM_LEVEL) {
event.preventDefault();
return;
}
// Block zoom-in beyond maximum
if (ZOOM_IN_KEYS.has(input.key) && currentLevel >= MAX_ZOOM_LEVEL) {
event.preventDefault();
return;
}
// For zoom keys (including Cmd+0 reset), defer sync until zoom is applied
if (ZOOM_IN_KEYS.has(input.key) || ZOOM_OUT_KEYS.has(input.key) || input.key === '0') {
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
syncTrafficLightPosition(mainWindow);
}
}, 100);
}
});
mainWindow.on('closed', () => {
mainWindow = null;
// Clear main window references
if (notificationManager) {
notificationManager.setMainWindow(null);
}
if (updaterService) {
updaterService.setMainWindow(null);
}
});
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
mainWindow.webContents.on('render-process-gone', (_event, details) => {
logger.error('Renderer process gone:', details.reason, details.exitCode);
// Could show an error dialog or attempt to reload the window
});
// Set main window reference for notification manager and updater
if (notificationManager) {
notificationManager.setMainWindow(mainWindow);
}
if (updaterService) {
updaterService.setMainWindow(mainWindow);
}
logger.info('Main window created');
}
/**
* Application ready handler.
*/
void app.whenReady().then(() => {
logger.info('App ready, initializing...');
try {
// Initialize services first
initializeServices();
// Apply configuration settings
const config = configManager.getConfig();
// Apply launch at login setting
app.setLoginItemSettings({
openAtLogin: config.general.launchAtLogin,
});
// Apply dock visibility and icon (macOS)
if (process.platform === 'darwin') {
if (!config.general.showDockIcon) {
app.dock?.hide();
}
// macOS app icon is already provided by the signed bundle (.icns)
// so we avoid runtime setIcon calls that can fail and block startup.
}
// Then create window
createWindow();
// Listen for notification click events
notificationManager.on('notification-clicked', (_error) => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
} catch (error) {
logger.error('Startup initialization failed:', error);
if (!mainWindow) {
createWindow();
}
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
/**
* All windows closed handler.
*/
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
/**
* Before quit handler - cleanup.
*/
app.on('before-quit', () => {
shutdownServices();
});