agent-ecosystem/src/preload/index.ts
2026-02-11 13:34:25 +00:00

351 lines
13 KiB
TypeScript

import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
import { contextBridge, ipcRenderer } from 'electron';
import {
SSH_CONNECT,
SSH_DISCONNECT,
SSH_GET_STATE,
SSH_STATUS,
SSH_TEST,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
UPDATER_STATUS,
} from './constants/ipcChannels';
import {
CONFIG_ADD_IGNORE_REGEX,
CONFIG_ADD_IGNORE_REPOSITORY,
CONFIG_ADD_TRIGGER,
CONFIG_CLEAR_SNOOZE,
CONFIG_GET,
CONFIG_GET_TRIGGERS,
CONFIG_OPEN_IN_EDITOR,
CONFIG_PIN_SESSION,
CONFIG_REMOVE_IGNORE_REGEX,
CONFIG_REMOVE_IGNORE_REPOSITORY,
CONFIG_REMOVE_TRIGGER,
CONFIG_SELECT_FOLDERS,
CONFIG_SNOOZE,
CONFIG_TEST_TRIGGER,
CONFIG_UNPIN_SESSION,
CONFIG_UPDATE,
CONFIG_UPDATE_TRIGGER,
} from './constants/ipcChannels';
import type {
AppConfig,
ElectronAPI,
NotificationTrigger,
SessionsPaginationOptions,
SshConnectionConfig,
SshConnectionStatus,
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<T> {
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<T>.
* Throws an Error if the IPC call fails, otherwise returns the typed data.
*/
async function invokeIpcWithResult<T>(channel: string, ...args: unknown[]): Promise<T> {
const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult<T>;
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),
// 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<AppConfig> => {
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
update: async (section: string, data: object): Promise<AppConfig> => {
return invokeIpcWithResult<AppConfig>(CONFIG_UPDATE, section, data);
},
addIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_ADD_IGNORE_REGEX, pattern);
// Re-fetch config after mutation
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
removeIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_REMOVE_IGNORE_REGEX, pattern);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
addIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_ADD_IGNORE_REPOSITORY, repositoryId);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
removeIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_REMOVE_IGNORE_REPOSITORY, repositoryId);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
snooze: async (minutes: number): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_SNOOZE, minutes);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
clearSnooze: async (): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_CLEAR_SNOOZE);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
addTrigger: async (trigger: Omit<NotificationTrigger, 'isBuiltin'>): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_ADD_TRIGGER, trigger);
// Return updated config
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
updateTrigger: async (
triggerId: string,
updates: Partial<NotificationTrigger>
): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_UPDATE_TRIGGER, triggerId, updates);
// Return updated config
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
removeTrigger: async (triggerId: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_REMOVE_TRIGGER, triggerId);
// Return updated config
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
getTriggers: async (): Promise<NotificationTrigger[]> => {
return invokeIpcWithResult<NotificationTrigger[]>(CONFIG_GET_TRIGGERS);
},
testTrigger: async (trigger: NotificationTrigger): Promise<TriggerTestResult> => {
return invokeIpcWithResult<TriggerTestResult>(CONFIG_TEST_TRIGGER, trigger);
},
selectFolders: async (): Promise<string[]> => {
return invokeIpcWithResult<string[]>(CONFIG_SELECT_FOLDERS);
},
openInEditor: async (): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_OPEN_IN_EDITOR);
},
pinSession: async (projectId: string, sessionId: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_PIN_SESSION, projectId, sessionId);
},
unpinSession: async (projectId: string, sessionId: string): Promise<void> => {
return invokeIpcWithResult<void>(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<number> => 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),
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<SshConnectionStatus> => {
return invokeIpcWithResult<SshConnectionStatus>(SSH_CONNECT, config);
},
disconnect: async (): Promise<SshConnectionStatus> => {
return invokeIpcWithResult<SshConnectionStatus>(SSH_DISCONNECT);
},
getState: async (): Promise<SshConnectionStatus> => {
return ipcRenderer.invoke(SSH_GET_STATE);
},
test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => {
return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config);
},
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
);
};
},
},
};
// Use contextBridge to securely expose the API to the renderer process
contextBridge.exposeInMainWorld('electronAPI', electronAPI);