/** * 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'; import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder'; // 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'; const TEAM_CHANGE = 'team:change'; 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, TeamDataService, TeamProvisioningService, 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 teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let httpServer: HttpServer; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; let todoChangeCleanup: (() => void) | null = null; let teamChangeCleanup: (() => 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; } if (teamChangeCleanup) { teamChangeCleanup(); teamChangeCleanup = null; } // Wire file-change events to renderer and HTTP SSE const fileChangeHandler = (event: unknown): void => { 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): void => { 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); // Forward team-change events to renderer and HTTP SSE const teamChangeHandler = (event: unknown): void => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(TEAM_CHANGE, event); } httpServer?.broadcast('team-change', event); }; context.fileWatcher.on('team-change', teamChangeHandler); teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler); 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 { 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, }); } } /** * Rebuilds the local ServiceContext using the current configured Claude root paths. * Called when general.claudeRootPath changes. */ function reconfigureLocalContextForClaudeRoot(): void { try { const currentLocal = contextRegistry.get('local'); if (!currentLocal) { logger.error('Cannot reconfigure local context: local context not found'); return; } const wasLocalActive = contextRegistry.getActiveContextId() === 'local'; const projectsDir = getProjectsBasePath(); const todosDir = getTodosBasePath(); logger.info(`Reconfiguring local context: projectsDir=${projectsDir}, todosDir=${todosDir}`); if (wasLocalActive) { currentLocal.stopFileWatcher(); } const replacementLocal = new ServiceContext({ id: 'local', type: 'local', fsProvider: new LocalFileSystemProvider(), projectsDir, todosDir, }); if (notificationManager) { replacementLocal.fileWatcher.setNotificationManager(notificationManager); } replacementLocal.start(); if (!wasLocalActive) { replacementLocal.stopFileWatcher(); } contextRegistry.replaceContext('local', replacementLocal); if (wasLocalActive) { wireFileWatcherEvents(replacementLocal); } } catch (error) { logger.error('Failed to reconfigure local context for Claude root change:', error); } } /** * Initializes all services. */ function initializeServices(): void { logger.info('Initializing services...'); // Initialize SSH connection manager sshConnectionManager = new SshConnectionManager(); // Create ServiceContextRegistry contextRegistry = new ServiceContextRegistry(); const localProjectsDir = getProjectsBasePath(); const localTodosDir = getTodosBasePath(); // Create local context const localContext = new ServiceContext({ id: 'local', type: 'local', fsProvider: new LocalFileSystemProvider(), projectsDir: localProjectsDir, todosDir: localTodosDir, }); // 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(); teamDataService = new TeamDataService(); teamProvisioningService = new TeamProvisioningService(); httpServer = new HttpServer(); // Initialize IPC handlers with registry initializeIpcHandlers( contextRegistry, updaterService, sshConnectionManager, teamDataService, teamProvisioningService, { rewire: rewireContextEvents, full: onContextSwitched, onClaudeRootPathUpdated: (_claudeRootPath: string | null) => { reconfigureLocalContextForClaudeRoot(); }, } ); // 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 { success: true, data: { 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 ): Promise { 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(); });