agent-ecosystem/src/preload/index.ts
matt ff0c8cc978 Implement Claude root path configuration and management
- Introduced functionality to select and manage the local Claude root folder, allowing users to specify a custom path.
- Added IPC handlers for selecting the Claude root folder and retrieving its information.
- Enhanced configuration validation to ensure the specified Claude root path is an absolute path.
- Updated the ServiceContext to reconfigure based on changes to the Claude root path, improving context management.
- Refactored related components to support the new Claude root path features, including updates to the UI and state management.
2026-02-14 13:43:56 +09:00

443 lines
17 KiB
TypeScript

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<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),
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<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);
},
selectClaudeRootFolder: async (): Promise<ClaudeRootFolderSelection | null> => {
return invokeIpcWithResult<ClaudeRootFolderSelection | null>(
CONFIG_SELECT_CLAUDE_ROOT_FOLDER
);
},
getClaudeRootInfo: async (): Promise<ClaudeRootInfo> => {
return invokeIpcWithResult<ClaudeRootInfo>(CONFIG_GET_CLAUDE_ROOT_INFO);
},
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),
// 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<boolean>,
},
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 invokeIpcWithResult<SshConnectionStatus>(SSH_GET_STATE);
},
test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => {
return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config);
},
getConfigHosts: async (): Promise<SshConfigHostEntry[]> => {
return invokeIpcWithResult<SshConfigHostEntry[]>(SSH_GET_CONFIG_HOSTS);
},
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
return invokeIpcWithResult<SshConfigHostEntry | null>(SSH_RESOLVE_HOST, alias);
},
saveLastConnection: async (config: SshLastConnection): Promise<void> => {
return invokeIpcWithResult<void>(SSH_SAVE_LAST_CONNECTION, config);
},
getLastConnection: async (): Promise<SshLastConnection | null> => {
return invokeIpcWithResult<SshLastConnection | null>(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<ContextInfo[]> => {
return invokeIpcWithResult<ContextInfo[]>(CONTEXT_LIST);
},
getActive: async (): Promise<string> => {
return invokeIpcWithResult<string>(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<HttpServerStatus> => {
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_START);
},
stop: async (): Promise<HttpServerStatus> => {
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_STOP);
},
getStatus: async (): Promise<HttpServerStatus> => {
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_GET_STATUS);
},
},
};
// Use contextBridge to securely expose the API to the renderer process
contextBridge.exposeInMainWorld('electronAPI', electronAPI);