import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer } from 'electron'; import { CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, CONTEXT_SWITCH, HTTP_SERVER_GET_STATUS, HTTP_SERVER_START, HTTP_SERVER_STOP, SSH_CONNECT, SSH_DISCONNECT, SSH_GET_CONFIG_HOSTS, SSH_GET_LAST_CONNECTION, SSH_GET_STATE, SSH_RESOLVE_HOST, SSH_SAVE_LAST_CONNECTION, SSH_STATUS, SSH_TEST, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, UPDATER_STATUS, WINDOW_CLOSE, WINDOW_IS_MAXIMIZED, WINDOW_MAXIMIZE, WINDOW_MINIMIZE, } from './constants/ipcChannels'; import { CONFIG_ADD_IGNORE_REGEX, CONFIG_ADD_IGNORE_REPOSITORY, CONFIG_ADD_TRIGGER, CONFIG_CLEAR_SNOOZE, CONFIG_GET, CONFIG_GET_CLAUDE_ROOT_INFO, CONFIG_GET_TRIGGERS, CONFIG_OPEN_IN_EDITOR, CONFIG_PIN_SESSION, CONFIG_REMOVE_IGNORE_REGEX, CONFIG_REMOVE_IGNORE_REPOSITORY, CONFIG_REMOVE_TRIGGER, CONFIG_SELECT_CLAUDE_ROOT_FOLDER, CONFIG_SELECT_FOLDERS, CONFIG_SNOOZE, CONFIG_TEST_TRIGGER, CONFIG_UNPIN_SESSION, CONFIG_UPDATE, CONFIG_UPDATE_TRIGGER, } from './constants/ipcChannels'; import type { AppConfig, ClaudeRootFolderSelection, ClaudeRootInfo, ContextInfo, ElectronAPI, HttpServerStatus, NotificationTrigger, SessionsByIdsOptions, SessionsPaginationOptions, SshConfigHostEntry, SshConnectionConfig, SshConnectionStatus, SshLastConnection, TriggerTestResult, } from '@shared/types'; // ============================================================================= // IPC Result Types and Helpers // ============================================================================= /** * Standard IPC result structure returned by main process handlers. * All config-related IPC calls return this shape. */ interface IpcResult { success: boolean; data?: T; error?: string; } interface IpcFileChangePayload { type: 'add' | 'change' | 'unlink'; path: string; projectId?: string; sessionId?: string; isSubagent: boolean; } /** * Type-safe IPC invoker for operations that return IpcResult. * Throws an Error if the IPC call fails, otherwise returns the typed data. */ async function invokeIpcWithResult(channel: string, ...args: unknown[]): Promise { const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult; if (!result.success) { throw new Error(result.error ?? 'Unknown error'); } return result.data as T; } // Keep latest zoom factor cached even before renderer UI subscribes. let currentZoomFactor = 1; ipcRenderer.on( WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, (_event: Electron.IpcRendererEvent, zoomFactor: unknown) => { if (typeof zoomFactor === 'number' && Number.isFinite(zoomFactor)) { currentZoomFactor = zoomFactor; } } ); // ============================================================================= // Electron API Implementation // ============================================================================= // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object const electronAPI: ElectronAPI = { getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), getSessionsPaginated: ( projectId: string, cursor: string | null, limit?: number, options?: SessionsPaginationOptions ) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options), searchSessions: (projectId: string, query: string, maxResults?: number) => ipcRenderer.invoke('search-sessions', projectId, query, maxResults), getSessionDetail: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-detail', projectId, sessionId), getSessionMetrics: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-metrics', projectId, sessionId), getWaterfallData: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-waterfall-data', projectId, sessionId), getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) => ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId), getSessionGroups: (projectId: string, sessionId: string) => ipcRenderer.invoke('get-session-groups', projectId, sessionId), getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) => ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds, options), // Repository grouping (worktree support) getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'), getWorktreeSessions: (worktreeId: string) => ipcRenderer.invoke('get-worktree-sessions', worktreeId), // Validation methods validatePath: (relativePath: string, projectPath: string) => ipcRenderer.invoke('validate-path', relativePath, projectPath), validateMentions: (mentions: { type: 'path'; value: string }[], projectPath: string) => ipcRenderer.invoke('validate-mentions', mentions, projectPath), // CLAUDE.md reading methods readClaudeMdFiles: (projectRoot: string) => ipcRenderer.invoke('read-claude-md-files', projectRoot), readDirectoryClaudeMd: (dirPath: string) => ipcRenderer.invoke('read-directory-claude-md', dirPath), readMentionedFile: (absolutePath: string, projectRoot: string, maxTokens?: number) => ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens), // Notifications API notifications: { get: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('notifications:get', options), markRead: (id: string) => ipcRenderer.invoke('notifications:markRead', id), markAllRead: () => ipcRenderer.invoke('notifications:markAllRead'), delete: (id: string) => ipcRenderer.invoke('notifications:delete', id), clear: () => ipcRenderer.invoke('notifications:clear'), getUnreadCount: () => ipcRenderer.invoke('notifications:getUnreadCount'), onNew: (callback: (event: unknown, error: unknown) => void): (() => void) => { ipcRenderer.on( 'notification:new', callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); return (): void => { ipcRenderer.removeListener( 'notification:new', callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); }; }, onUpdated: ( callback: (event: unknown, payload: { total: number; unreadCount: number }) => void ): (() => void) => { ipcRenderer.on( 'notification:updated', callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); return (): void => { ipcRenderer.removeListener( 'notification:updated', callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); }; }, onClicked: (callback: (event: unknown, data: unknown) => void): (() => void) => { ipcRenderer.on( 'notification:clicked', callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); return (): void => { ipcRenderer.removeListener( 'notification:clicked', callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); }; }, }, // Config API - uses typed helper to unwrap { success, data, error } responses config: { get: async (): Promise => { return invokeIpcWithResult(CONFIG_GET); }, update: async (section: string, data: object): Promise => { return invokeIpcWithResult(CONFIG_UPDATE, section, data); }, addIgnoreRegex: async (pattern: string): Promise => { await invokeIpcWithResult(CONFIG_ADD_IGNORE_REGEX, pattern); // Re-fetch config after mutation return invokeIpcWithResult(CONFIG_GET); }, removeIgnoreRegex: async (pattern: string): Promise => { await invokeIpcWithResult(CONFIG_REMOVE_IGNORE_REGEX, pattern); return invokeIpcWithResult(CONFIG_GET); }, addIgnoreRepository: async (repositoryId: string): Promise => { await invokeIpcWithResult(CONFIG_ADD_IGNORE_REPOSITORY, repositoryId); return invokeIpcWithResult(CONFIG_GET); }, removeIgnoreRepository: async (repositoryId: string): Promise => { await invokeIpcWithResult(CONFIG_REMOVE_IGNORE_REPOSITORY, repositoryId); return invokeIpcWithResult(CONFIG_GET); }, snooze: async (minutes: number): Promise => { await invokeIpcWithResult(CONFIG_SNOOZE, minutes); return invokeIpcWithResult(CONFIG_GET); }, clearSnooze: async (): Promise => { await invokeIpcWithResult(CONFIG_CLEAR_SNOOZE); return invokeIpcWithResult(CONFIG_GET); }, addTrigger: async (trigger: Omit): Promise => { await invokeIpcWithResult(CONFIG_ADD_TRIGGER, trigger); // Return updated config return invokeIpcWithResult(CONFIG_GET); }, updateTrigger: async ( triggerId: string, updates: Partial ): Promise => { await invokeIpcWithResult(CONFIG_UPDATE_TRIGGER, triggerId, updates); // Return updated config return invokeIpcWithResult(CONFIG_GET); }, removeTrigger: async (triggerId: string): Promise => { await invokeIpcWithResult(CONFIG_REMOVE_TRIGGER, triggerId); // Return updated config return invokeIpcWithResult(CONFIG_GET); }, getTriggers: async (): Promise => { return invokeIpcWithResult(CONFIG_GET_TRIGGERS); }, testTrigger: async (trigger: NotificationTrigger): Promise => { return invokeIpcWithResult(CONFIG_TEST_TRIGGER, trigger); }, selectFolders: async (): Promise => { return invokeIpcWithResult(CONFIG_SELECT_FOLDERS); }, selectClaudeRootFolder: async (): Promise => { return invokeIpcWithResult( CONFIG_SELECT_CLAUDE_ROOT_FOLDER ); }, getClaudeRootInfo: async (): Promise => { return invokeIpcWithResult(CONFIG_GET_CLAUDE_ROOT_INFO); }, openInEditor: async (): Promise => { return invokeIpcWithResult(CONFIG_OPEN_IN_EDITOR); }, pinSession: async (projectId: string, sessionId: string): Promise => { return invokeIpcWithResult(CONFIG_PIN_SESSION, projectId, sessionId); }, unpinSession: async (projectId: string, sessionId: string): Promise => { return invokeIpcWithResult(CONFIG_UNPIN_SESSION, projectId, sessionId); }, }, // Deep link navigation session: { scrollToLine: (sessionId: string, lineNumber: number) => ipcRenderer.invoke('session:scrollToLine', sessionId, lineNumber), }, // Zoom factor sync (used for traffic-light-safe layout) getZoomFactor: async (): Promise => currentZoomFactor, onZoomFactorChanged: (callback: (zoomFactor: number) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, zoomFactor: unknown): void => { if (typeof zoomFactor !== 'number' || !Number.isFinite(zoomFactor)) return; currentZoomFactor = zoomFactor; callback(zoomFactor); }; ipcRenderer.on(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, listener); return (): void => { ipcRenderer.removeListener(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, listener); }; }, // File change events (real-time updates) onFileChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void => callback(data); ipcRenderer.on('file-change', listener); return (): void => { ipcRenderer.removeListener('file-change', listener); }; }, // Shell operations openPath: (targetPath: string, projectRoot?: string) => ipcRenderer.invoke('shell:openPath', targetPath, projectRoot), openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), // Window controls (when title bar is hidden, e.g. Windows / Linux) windowControls: { minimize: () => ipcRenderer.invoke(WINDOW_MINIMIZE), maximize: () => ipcRenderer.invoke(WINDOW_MAXIMIZE), close: () => ipcRenderer.invoke(WINDOW_CLOSE), isMaximized: () => ipcRenderer.invoke(WINDOW_IS_MAXIMIZED) as Promise, }, onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void => callback(data); ipcRenderer.on('todo-change', listener); return (): void => { ipcRenderer.removeListener('todo-change', listener); }; }, // Updater API updater: { check: () => ipcRenderer.invoke(UPDATER_CHECK), download: () => ipcRenderer.invoke(UPDATER_DOWNLOAD), install: () => ipcRenderer.invoke(UPDATER_INSTALL), onStatus: (callback: (event: unknown, status: unknown) => void): (() => void) => { ipcRenderer.on( UPDATER_STATUS, callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); return (): void => { ipcRenderer.removeListener( UPDATER_STATUS, callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); }; }, }, // SSH API ssh: { connect: async (config: SshConnectionConfig): Promise => { return invokeIpcWithResult(SSH_CONNECT, config); }, disconnect: async (): Promise => { return invokeIpcWithResult(SSH_DISCONNECT); }, getState: async (): Promise => { return invokeIpcWithResult(SSH_GET_STATE); }, test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => { return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config); }, getConfigHosts: async (): Promise => { return invokeIpcWithResult(SSH_GET_CONFIG_HOSTS); }, resolveHost: async (alias: string): Promise => { return invokeIpcWithResult(SSH_RESOLVE_HOST, alias); }, saveLastConnection: async (config: SshLastConnection): Promise => { return invokeIpcWithResult(SSH_SAVE_LAST_CONNECTION, config); }, getLastConnection: async (): Promise => { return invokeIpcWithResult(SSH_GET_LAST_CONNECTION); }, onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => { ipcRenderer.on( SSH_STATUS, callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); return (): void => { ipcRenderer.removeListener( SSH_STATUS, callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); }; }, }, // Context API context: { list: async (): Promise => { return invokeIpcWithResult(CONTEXT_LIST); }, getActive: async (): Promise => { return invokeIpcWithResult(CONTEXT_GET_ACTIVE); }, switch: async (contextId: string): Promise<{ contextId: string }> => { return invokeIpcWithResult<{ contextId: string }>(CONTEXT_SWITCH, contextId); }, onChanged: (callback: (event: unknown, data: ContextInfo) => void): (() => void) => { ipcRenderer.on( CONTEXT_CHANGED, callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); return (): void => { ipcRenderer.removeListener( CONTEXT_CHANGED, callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ); }; }, }, // HTTP Server API httpServer: { start: async (): Promise => { return invokeIpcWithResult(HTTP_SERVER_START); }, stop: async (): Promise => { return invokeIpcWithResult(HTTP_SERVER_STOP); }, getStatus: async (): Promise => { return invokeIpcWithResult(HTTP_SERVER_GET_STATUS); }, }, }; // Use contextBridge to securely expose the API to the renderer process contextBridge.exposeInMainWorld('electronAPI', electronAPI);