601 lines · 18.2 KB · TypeScript
RawDownload
/** * 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'; 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 serviceslet contextRegistry: ServiceContextRegistry;let notificationManager: NotificationManager;let updaterService: UpdaterService;let sshConnectionManager: SshConnectionManager;let httpServer: HttpServer; // File watcher event cleanup functionslet 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): 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);   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,    });  }} /** * 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();  httpServer = new HttpServer();   // Initialize IPC handlers with registry  initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, {    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 { 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') {

Lines 1–500 of 601.