ipc-architecture
How to add or change an IPC channel in Struq Voice end to end. Use whenever a task involves IPC, a preload bridge, a channel, a payload type, window.struqVoice, invoke/send/on across main and renderer
SKILL.md.claude/skills/ipc-architecture/SKILL.md
GitHubIPC architecture in Struq Voice
Every channel is declared in src/shared/ipc.ts and nowhere else. There is
no second source. This is enforced by convention and is the first thing to
check when IPC "does not work": the name on the sender must equal the name on
the handler.
Why the preloads work the way they do
Every window runs sandboxed (sandbox: true), so preloads cannot import
shared modules: each preload bundle must be one self-contained file. To give
the preload the channel names, main serializes PRELOAD_CHANNELS (exported
from src/shared/ipc.ts) into the window's additionalArguments as
--struq-channels=..., and each preload reads them from process.argv
before exposing its API via contextBridge.
Adding a channel, end to end
src/shared/ipc.ts: declare the channel constant (export const fooChannel = "foo:bar" as const;), the request and result payload interfaces, and, if the preload needs it, add the channel under the matching group inPRELOAD_CHANNELS(top-level keys:appGetVersion,window,captureStateChanged,captureLevelsChanged,recorder,history,clipboard,settings,devices,openRouterKey,models,metrics).src/shared/api.ts: add the method to the relevant*WindowApiinterface (MainWindowApi,OverlayWindowApi,RecorderWindowApi).src/preload/<window>.ts: implement it withipcRenderer.invoke/ipcRenderer.send/ipcRenderer.on, using the argv-read channel names. For main-to-renderer pushes, return an unsubscribe function.src/main/ipc.ts: registeripcMain.handle/ipcMain.onfor the channel. This file is thin typed dispatch only; no business logic. If the handler needs a new dependency (a store, service or window), extend theregisterIpcHandlers(...)signature and update the call site insrc/main/index.ts.- Renderer: call
window.struqVoice(typed as the window's API). Never construct channel names in the renderer.
Testing pattern
- Pure logic behind IPC is unit tested directly (settings migration, engine router, text cleanup, paste decisions) with injected deps.
- The main-process test hook (
src/main/test-hook.ts) exposes drives and accessors for e2e. Add accessors there when a new surface needs e2e coverage, following the existinggetState/history.getRecentpattern. - Payloads cross IPC as plain JSON; use
readonlyon interfaces andexactOptionalPropertyTypes(never sendundefinedfields).
Gotchas
ipcMain.handlereturns a Promise;ipcMain.onis fire-and-forget.- Transferable
ArrayBuffer(PCM) travels viaipcRenderer.sendwith the transfer list, neverinvoke. - Do not pass API keys or secrets across IPC. The key stays in main; the renderer only ever asks for a masked status.