Skip to content

Commit 8c3d13b

Browse files
committed
Split IPC registration out of main.ts into app/src/ipc/*.ts
Moves all 95 ipcMain.handle registrations into 13 per-domain modules (ollama/backends, chat, system/benchmark/energy, downloads, settings, sessions/projects/scheduled-tasks, files/secrets/accounts/audio, app/ data, rag/huggingface, ocr/figma/screen, agent, terminal, mcp), each exporting a registerXIpc() function. main.ts's registerIpcHandlers() is now a thin orchestrator; the file shrinks from ~1100 to 225 lines. Pure reorganization: every channel name, handler body, and the overall registration order is unchanged (verified — diffed the channel-name set before/after, no drops/duplicates). createWindow/app lifecycle untouched aside from routing mainWindow/isBusy/forceClose through the new app-state accessors.
1 parent 6e84828 commit 8c3d13b

14 files changed

Lines changed: 796 additions & 905 deletions

‎app/src/ipc/agent-handlers.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ipcMain, IpcMainInvokeEvent, dialog } from "electron";
2+
import { logger } from "../logger";
3+
import * as agentTools from "../agent-tools";
4+
import { detectSandboxCapabilities } from "../command-sandbox";
5+
import * as terminalManager from "../terminal-manager";
6+
import * as mcpClient from "../mcp-client";
7+
import { agentToolArgsSchemas, mcpToolArgsSchema, parseOrThrow } from "../schemas";
8+
import { getMainWindow, requireString } from "../app-state";
9+
10+
export function registerAgentIpc(): void {
11+
ipcMain.handle("agent:pickWorkspace", async () => {
12+
const mainWindow = getMainWindow();
13+
const result = mainWindow
14+
? await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory"] })
15+
: await dialog.showOpenDialog({ properties: ["openDirectory"] });
16+
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
17+
});
18+
19+
ipcMain.handle(
20+
"tools:execute",
21+
async (
22+
_event: IpcMainInvokeEvent,
23+
{ workspaceRoot, name, args }: { workspaceRoot: string; name: string; args: unknown }
24+
) => {
25+
requireString(workspaceRoot, "workspace root");
26+
requireString(name, "tool name");
27+
try {
28+
const isMcpTool = mcpClient.isMcpTool(name);
29+
// MCP tool schemas are server-defined and unknown to us ahead of
30+
// time — just check for a plain object. Built-in tools get a
31+
// real per-tool schema; a tool name with no entry here (a typo,
32+
// or a tool the model invented) falls through with args
33+
// unvalidated and is rejected by executeTool()'s own "Unknown
34+
// tool" check instead.
35+
const schema = isMcpTool ? mcpToolArgsSchema : agentToolArgsSchemas[name];
36+
const validatedArgs = (schema
37+
? parseOrThrow(schema, args ?? {}, `arguments for tool "${name}"`)
38+
: (args ?? {})) as Record<string, unknown>;
39+
const result = isMcpTool
40+
? await mcpClient.callMcpTool(name, validatedArgs)
41+
: await agentTools.executeTool(workspaceRoot, name, validatedArgs);
42+
return { result };
43+
} catch (err) {
44+
const error = err as Error;
45+
logger.error(`Tool execution failed (tool=${name}): ${error.message}`);
46+
return { error: error.message };
47+
}
48+
}
49+
);
50+
51+
ipcMain.handle("agent:rollbackLastWrite", (_event: IpcMainInvokeEvent, workspaceRoot: string) => {
52+
requireString(workspaceRoot, "workspace root");
53+
return agentTools.rollbackLastWrite(workspaceRoot);
54+
});
55+
56+
ipcMain.handle("agent:detectScripts", (_event: IpcMainInvokeEvent, workspaceRoot: string) => {
57+
requireString(workspaceRoot, "workspace root");
58+
return agentTools.detectProjectScripts(workspaceRoot);
59+
});
60+
61+
// Called when the renderer is about to stop using a workspace (switching
62+
// to a different folder, or loading a session that points elsewhere) —
63+
// without this, background tasks started against the old workspace kept
64+
// running indefinitely, since killAllBackgroundCommands() only ever ran
65+
// on app quit.
66+
ipcMain.handle("agent:closeWorkspace", (_event: IpcMainInvokeEvent, workspaceRoot: string) => {
67+
requireString(workspaceRoot, "workspace root");
68+
const killedBackgroundTasks = agentTools.killBackgroundCommandsForWorkspace(workspaceRoot);
69+
const killedTerminals = terminalManager.closeAllForWorkspace(workspaceRoot);
70+
return { killedBackgroundTasks, killedTerminals };
71+
});
72+
73+
ipcMain.handle("agent:getSandboxCapabilities", () => detectSandboxCapabilities());
74+
}

‎app/src/ipc/app-handlers.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { app, ipcMain, IpcMainInvokeEvent, shell } from "electron";
2+
import { getLogPath, getLogTail } from "../logger";
3+
import * as ollama from "../ollama-manager";
4+
import * as sessionsStore from "../sessions-store";
5+
import * as dataTransfer from "../data-transfer";
6+
import type { PromptPreset } from "../settings-store";
7+
import { checkForUpdatesManually } from "../updater";
8+
import { getMainWindow, requireString, setIsBusy } from "../app-state";
9+
10+
export function registerAppIpc(): void {
11+
ipcMain.handle("app:setBusy", (_event: IpcMainInvokeEvent, busy: boolean) => {
12+
setIsBusy(busy);
13+
});
14+
ipcMain.handle("app:getVersion", () => app.getVersion());
15+
ipcMain.handle("app:checkForUpdates", () => checkForUpdatesManually(() => getMainWindow()));
16+
ipcMain.handle("app:getDiagnostics", async () => ({
17+
appVersion: app.getVersion(),
18+
electron: process.versions.electron,
19+
chrome: process.versions.chrome,
20+
node: process.versions.node,
21+
platform: process.platform,
22+
arch: process.arch,
23+
ollamaHost: ollama.getHost(),
24+
ollamaRunning: await ollama.isRunning(),
25+
logTail: getLogTail(),
26+
}));
27+
ipcMain.handle("app:openLogsFolder", () => shell.showItemInFolder(getLogPath()));
28+
29+
ipcMain.handle("data:exportSession", (_event: IpcMainInvokeEvent, id: string) =>
30+
dataTransfer.exportSession(getMainWindow(), requireString(id, "session id"))
31+
);
32+
ipcMain.handle("data:exportSessionMarkdown", (_event: IpcMainInvokeEvent, id: string) =>
33+
dataTransfer.exportSessionMarkdown(getMainWindow(), requireString(id, "session id"))
34+
);
35+
ipcMain.handle("data:getSessionMarkdown", (_event: IpcMainInvokeEvent, id: string) => {
36+
const session = sessionsStore.getSession(requireString(id, "session id"));
37+
return session ? dataTransfer.sessionToMarkdown(session) : null;
38+
});
39+
ipcMain.handle("data:exportAll", () => dataTransfer.exportAllSessions(getMainWindow()));
40+
ipcMain.handle("data:import", () => dataTransfer.importSessions(getMainWindow()));
41+
ipcMain.handle("data:getUserDataPath", () => dataTransfer.getUserDataPath());
42+
ipcMain.handle("data:openUserDataFolder", () => dataTransfer.openUserDataFolder());
43+
44+
ipcMain.handle("data:exportPromptPresets", (_event: IpcMainInvokeEvent, presets: PromptPreset[]) =>
45+
dataTransfer.exportPromptPresets(getMainWindow(), presets ?? [])
46+
);
47+
ipcMain.handle("data:importPromptPresets", () => dataTransfer.importPromptPresets(getMainWindow()));
48+
}

‎app/src/ipc/chat-handlers.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { ipcMain, IpcMainInvokeEvent } from "electron";
2+
import { logger } from "../logger";
3+
import * as agentTools from "../agent-tools";
4+
import * as mcpClient from "../mcp-client";
5+
import type { ChatMessage, ChatChunk, ChatOptions, ProviderId } from "../providers/types";
6+
import { activeChatRequests } from "../app-state";
7+
import { dispatchChat } from "../chat-dispatch";
8+
9+
export function registerChatIpc(): void {
10+
ipcMain.handle(
11+
"chat:send",
12+
async (
13+
event: IpcMainInvokeEvent,
14+
{
15+
requestId,
16+
provider,
17+
model,
18+
messages,
19+
options,
20+
agentMode,
21+
}: {
22+
requestId: string;
23+
provider: ProviderId;
24+
model: string;
25+
messages: ChatMessage[];
26+
options?: ChatOptions;
27+
agentMode?: boolean;
28+
}
29+
) => {
30+
const channel = `chat:chunk:${requestId}`;
31+
const onToken = (chunk: ChatChunk) => event.sender.send(channel, chunk);
32+
const controller = new AbortController();
33+
activeChatRequests.set(requestId, controller);
34+
const tools = agentMode ? [...agentTools.AGENT_TOOLS, ...mcpClient.getConnectedTools()] : undefined;
35+
try {
36+
await dispatchChat(provider, model, messages, options, onToken, controller.signal, tools);
37+
return { done: true };
38+
} catch (err) {
39+
const error = err as Error;
40+
if (error.name === "AbortError") {
41+
return { done: true, aborted: true };
42+
}
43+
logger.error(`Chat request failed (provider=${provider}, model=${model}): ${error.message}`);
44+
return { done: true, error: error.message };
45+
} finally {
46+
activeChatRequests.delete(requestId);
47+
}
48+
}
49+
);
50+
51+
ipcMain.handle("chat:cancel", (_event: IpcMainInvokeEvent, requestId: string) => {
52+
activeChatRequests.get(requestId)?.abort();
53+
});
54+
}

‎app/src/ipc/downloads-handlers.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ipcMain } from "electron";
2+
import * as settingsStore from "../settings-store";
3+
import * as downloadQueue from "../download-queue";
4+
import { requireString, getLlamaCppModelsDir } from "../app-state";
5+
6+
export function registerDownloadsIpc(): void {
7+
ipcMain.handle("downloads:list", () => downloadQueue.getJobs());
8+
ipcMain.handle("downloads:create", (_event, input: { modelId: string; filename: string; expectedBytes: number; backend?: Parameters<typeof downloadQueue.createHuggingFaceJob>[0]["backend"] }) =>
9+
downloadQueue.createHuggingFaceJob({
10+
...input,
11+
backend: input.backend ?? settingsStore.getSettings().preferredRuntime ?? "automatic",
12+
destinationDir: getLlamaCppModelsDir(),
13+
})
14+
);
15+
ipcMain.handle("downloads:pause", (_event, id: string) => downloadQueue.pauseJob(requireString(id, "download id")));
16+
ipcMain.handle("downloads:resume", (_event, id: string) => downloadQueue.resumeJob(requireString(id, "download id")));
17+
ipcMain.handle("downloads:retry", (_event, id: string) => downloadQueue.retryJob(requireString(id, "download id")));
18+
ipcMain.handle("downloads:cancel", (_event, id: string) => downloadQueue.cancelJob(requireString(id, "download id")));
19+
ipcMain.handle("downloads:delete", (_event, id: string) => downloadQueue.removeJob(requireString(id, "download id")));
20+
ipcMain.handle("downloads:forecast", (_event, id: string) => downloadQueue.diskForecast(requireString(id, "download id")));
21+
ipcMain.handle("downloads:recoveryStatus", () => downloadQueue.getRecoveryStatus());
22+
ipcMain.handle("downloads:getControls", () => {
23+
const settings = settingsStore.getSettings();
24+
return { concurrency: settings.downloadGlobalConcurrency ?? 2, bandwidthMbps: settings.downloadBandwidthMbps ?? 0 };
25+
});
26+
ipcMain.handle("downloads:setControls", (_event, controls: downloadQueue.DownloadControls) => {
27+
const normalized = downloadQueue.configure(controls);
28+
settingsStore.saveSettings({ downloadGlobalConcurrency: normalized.concurrency, downloadBandwidthMbps: normalized.bandwidthMbps });
29+
return normalized;
30+
});
31+
}

‎app/src/ipc/files-handlers.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { ipcMain, IpcMainInvokeEvent } from "electron";
2+
import { logger } from "../logger";
3+
import * as fileReader from "../file-reader";
4+
import * as secretsStore from "../secrets-store";
5+
import * as accounts from "../accounts";
6+
import * as openaiProvider from "../providers/openai";
7+
import { secretsSetInputSchema, parseOrThrow } from "../schemas";
8+
import { getMainWindow, requireString } from "../app-state";
9+
10+
export function registerFilesIpc(): void {
11+
ipcMain.handle("files:openAndRead", () => fileReader.openAndReadFiles(getMainWindow()));
12+
ipcMain.handle("files:openFolderAndRead", () => fileReader.openFolderAndRead(getMainWindow()));
13+
ipcMain.handle("files:openMedia", () => fileReader.openAndReadMedia(getMainWindow()));
14+
15+
ipcMain.handle("secrets:has", (_event: IpcMainInvokeEvent, key: string) =>
16+
secretsStore.hasSecret(requireString(key, "secret key"))
17+
);
18+
ipcMain.handle("secrets:set", (_event: IpcMainInvokeEvent, input: unknown) => {
19+
const { key, value } = parseOrThrow(secretsSetInputSchema, input, "secrets:set arguments");
20+
secretsStore.setSecret(key, value ?? "");
21+
});
22+
ipcMain.handle("secrets:isEncryptionAvailable", () => secretsStore.isEncryptionAvailable());
23+
24+
ipcMain.handle("accounts:status", (_event: IpcMainInvokeEvent, provider: accounts.AccountProvider) =>
25+
accounts.getLinkedAccount(provider)
26+
);
27+
ipcMain.handle("accounts:connect", async (_event: IpcMainInvokeEvent, { provider, token }: { provider: accounts.AccountProvider; token: string }) =>
28+
accounts.connectAccount(provider, requireString(token, "access token"))
29+
);
30+
ipcMain.handle("accounts:disconnect", (_event: IpcMainInvokeEvent, provider: accounts.AccountProvider) =>
31+
accounts.disconnectAccount(provider)
32+
);
33+
34+
ipcMain.handle(
35+
"audio:transcribe",
36+
async (_event: IpcMainInvokeEvent, { audioBase64, mimeType }: { audioBase64: string; mimeType: string }) => {
37+
requireString(audioBase64, "audio data");
38+
const apiKey = secretsStore.getSecret("openai_api_key");
39+
if (!apiKey) {
40+
return { error: "Voice input needs an OpenAI API key — add one in Settings to use it." };
41+
}
42+
try {
43+
const buffer = Buffer.from(audioBase64, "base64");
44+
const ext = mimeType.includes("webm") ? "webm" : mimeType.includes("ogg") ? "ogg" : "wav";
45+
const text = await openaiProvider.transcribeAudio(apiKey, buffer, `audio.${ext}`);
46+
return { text };
47+
} catch (err) {
48+
const error = err as Error;
49+
logger.error(`Audio transcription failed: ${error.message}`);
50+
return { error: error.message };
51+
}
52+
}
53+
);
54+
}

‎app/src/ipc/mcp-handlers.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ipcMain, IpcMainInvokeEvent } from "electron";
2+
import { logger } from "../logger";
3+
import * as mcpClient from "../mcp-client";
4+
import { mcpServerConfigSchema, parseOrThrow } from "../schemas";
5+
import { requireString } from "../app-state";
6+
7+
export function registerMcpIpc(): void {
8+
ipcMain.handle("mcp:connect", async (_event: IpcMainInvokeEvent, input: unknown) => {
9+
try {
10+
const config = parseOrThrow(mcpServerConfigSchema, input, "MCP server config");
11+
const { tools } = await mcpClient.connectServer(config);
12+
return { tools };
13+
} catch (err) {
14+
const error = err as Error;
15+
const name = typeof (input as { name?: unknown })?.name === "string" ? (input as { name: string }).name : "?";
16+
logger.error(`MCP connect failed (server=${name}): ${error.message}`);
17+
return { error: error.message };
18+
}
19+
});
20+
21+
ipcMain.handle("mcp:disconnect", (_event: IpcMainInvokeEvent, id: string) => {
22+
requireString(id, "server id");
23+
mcpClient.disconnectServer(id);
24+
});
25+
26+
ipcMain.handle("mcp:status", () => mcpClient.getServerStatuses());
27+
}

‎app/src/ipc/media-handlers.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { ipcMain, IpcMainInvokeEvent, desktopCapturer } from "electron";
2+
import { logger } from "../logger";
3+
import * as figma from "../figma";
4+
import * as ocr from "../ocr";
5+
import * as secretsStore from "../secrets-store";
6+
import { requireString } from "../app-state";
7+
8+
export function registerMediaIpc(): void {
9+
ipcMain.handle("ocr:recognize", async (_event: IpcMainInvokeEvent, imageBase64: string) => {
10+
requireString(imageBase64, "image data");
11+
try {
12+
return { text: await ocr.recognizeText(imageBase64) };
13+
} catch (err) {
14+
const error = err as Error;
15+
logger.error(`OCR failed: ${error.message}`);
16+
return { error: `OCR failed: ${error.message}` };
17+
}
18+
});
19+
20+
ipcMain.handle("figma:fetchFrame", async (_event: IpcMainInvokeEvent, url: string) => {
21+
requireString(url, "Figma URL");
22+
const token = secretsStore.getSecret("figma_token");
23+
if (!token) return { error: "Add a Figma personal access token in Settings first." };
24+
try {
25+
return { result: await figma.fetchFigmaFrameImage(token, url) };
26+
} catch (err) {
27+
return { error: (err as Error).message };
28+
}
29+
});
30+
31+
ipcMain.handle("screen:listSources", async () => {
32+
try {
33+
const sources = await desktopCapturer.getSources({
34+
types: ["screen", "window"],
35+
thumbnailSize: { width: 400, height: 250 },
36+
});
37+
return sources.map((s) => ({ id: s.id, name: s.name, thumbnailDataUrl: s.thumbnail.toDataURL() }));
38+
} catch (err) {
39+
logger.error(`Failed to list screen capture sources: ${(err as Error).message}`);
40+
return [];
41+
}
42+
});
43+
44+
ipcMain.handle("screen:capture", async (_event: IpcMainInvokeEvent, sourceId: string) => {
45+
requireString(sourceId, "source id");
46+
try {
47+
// Re-query at full size rather than reusing the small picker
48+
// thumbnail — sources can also disappear between listing and
49+
// capture (a window closed, a display disconnected).
50+
const sources = await desktopCapturer.getSources({
51+
types: ["screen", "window"],
52+
thumbnailSize: { width: 2560, height: 1440 },
53+
});
54+
const match = sources.find((s) => s.id === sourceId);
55+
if (!match) return { error: "That screen/window is no longer available." };
56+
const dataUrl = match.thumbnail.toDataURL();
57+
const dataBase64 = dataUrl.split(",")[1] ?? "";
58+
if (!dataBase64) return { error: "Capture returned an empty image." };
59+
return { dataBase64, mimeType: "image/png" };
60+
} catch (err) {
61+
const error = err as Error;
62+
logger.error(`Screen capture failed: ${error.message}`);
63+
return { error: error.message };
64+
}
65+
});
66+
}

0 commit comments

Comments
 (0)