IPC channels
All inter-process communication between the renderer and the main process
goes through a typed, centrally-registered set of channels declared in
shared/ipc.ts. There are two kinds:
| Kind | Direction | Constant | Usage |
|---|---|---|---|
| Invoke | renderer → main → renderer | IPC_CHANNELS.X | one-shot request / response, awaited Promise |
| Event | main → renderer (one-way) | IPC_EVENTS.X | broadcast push (streaming, lifecycle notifications) |
The renderer talks to both via the preload bridge:
import { IPC_CHANNELS, IPC_EVENTS } from '@shared/ipc'
// invoke (request / response)const result = await window.electronAPI.invoke(IPC_CHANNELS.DB_QUERY, id, sql)
// subscribe (one-way push)const off = window.electronAPI.on(IPC_EVENTS.AI_CHAT_EVENT, (event) => { … })off() // unsubscribeNever use a string literal at a call site. The CI test
tests/unit/ipc-channels-coverage.test.ts scans the source tree for
string-literal invoke() / on() calls and fails the build if it finds one
that isn’t a known channel — and a forgotten constant is a clear regression
signal.
Adding a new invoke channel
Section titled “Adding a new invoke channel”Each channel is described in two complementary halves, both in shared/ipc.ts:
IpcChannelShapes— an interface keyed by the channel’s constant name (DB_EXPLAIN_QUERY) carrying itsargstuple andreturntype.IPC_CHANNELS— the const object mapping that same constant name to its wire string ('db:explain-query').
The wire string is written exactly once (in IPC_CHANNELS). The
renderer-/main-facing IpcChannelMap (keyed by wire string, the shape
invoke/handle/the preload bridge consume) is derived by joining the two
halves — so the channel name can never be duplicated or drift out of sync.
It’s a two-step edit, all in shared/ipc.ts:
-
Add the channel’s contract to
IpcChannelShapes, keyed by its constant name. Be precise about theargstuple and thereturntype — these are what the renderer actually sees throughwindow.electronAPI.invoke().export interface IpcChannelShapes {// …DB_EXPLAIN_QUERY: {args: [profileId: string, sql: string]return: { plan: string; cost: number }}} -
Add the matching constant + wire string to
IPC_CHANNELS. Follow the existingSCREAMING_SNAKE_CASEconvention; the section comment groups it under the right domain (DB,PLUGINS,AI, …).export const IPC_CHANNELS = {// …DB_EXPLAIN_QUERY: 'db:explain-query',} as const satisfies Record<keyof IpcChannelShapes, string>The
satisfiesclause makes TypeScript reject the build unless everyIpcChannelShapeskey has exactly one constant here (and vice versa) — so a forgotten or mistyped name is a compile-time error, not a runtime mystery. -
Implement the handler. Pick the right file under
src/main/ipc/based on the domain prefix:connections:*→ipc/connections.tsdb:*→ipc/db.tsexport:*/import:*→ipc/export-import.tsplugins:*→ipc/plugins.tssettings:*→ipc/settings.tsdialog:*→ipc/dialog.tskeyring:*→ipc/keyring.tsmcp:*→ipc/mcp.tsmigration:*→ipc/migration.tsapp:*→ipc/app.tsappdata:*→ipc/appdata.tsthemes:*→ipc/themes.tsupdater:*→ipc/updater.tswindow:*→ipc/window.tsactivity:*→ registered inline inipc-handlers.ts(no dedicated domain file)
ai:*is the exception to this list: the AI assistant is a bundled plugin (src/main/plugins/bundled/ai/), so it registers its own channels throughctx.ipc.handle()(thePluginIpc.handlemethod) from inside the plugin, not from a file undersrc/main/ipc/. It takes the same(channel, handler)pair as the corehandlewrapper, but the two are not interchangeable:PluginIpc.handlereturns aDisposable(dispose it to unregister — e.g. on plugin deactivation) and throws a permission error unless the plugin was granted theipccapability, whereas the corehandlereturnsvoidand also traces every call into the activity stream. If the domain you’re adding belongs to a plugin rather than the core app, register it throughctx.ipc.handle(), nothandle.The handler signature is inferred from
IpcChannelMap:src/main/ipc/db.ts import { IPC_CHANNELS } from '@shared/ipc'handle(IPC_CHANNELS.DB_EXPLAIN_QUERY, async (profileId, sql) => {const adapter = requireAdapter(profileId)const result = await adapter.query(`EXPLAIN ${sql}`)return { plan: formatPlan(result.rows), cost: 0 }})handleis the wrapper defined inipc/context.ts. It’s typed byIpcChannelMapso the handler’sargsandreturnmust match — if you forget a field or get a type wrong, the build fails. -
Call it from the renderer:
import { IPC_CHANNELS } from '@shared/ipc'const { plan } = await window.electronAPI.invoke(IPC_CHANNELS.DB_EXPLAIN_QUERY,profileId,sql)
No preload/index.ts change is needed: the generic invoke<K>(channel, …args)
signature already passes through any channel that’s in the map.
Adding a new broadcast event
Section titled “Adding a new broadcast event”Broadcasts go the other way: main → renderer. They don’t return a value.
They follow the same single-source model as channels — payload tuple in
IpcEventShapes (keyed by constant name), wire string once in IPC_EVENTS,
and IpcEventMap derived from the two.
-
Add the event’s payload tuple to
IpcEventShapesinshared/ipc.ts, keyed by its constant name:export interface IpcEventShapes {// …DB_LONG_QUERY_PROGRESS: [payload: { profileId: string; pct: number }]} -
Add the constant + wire string to
IPC_EVENTS:export const IPC_EVENTS = {// …DB_LONG_QUERY_PROGRESS: 'db:long-query-progress'} as const satisfies Record<keyof IpcEventShapes, string> -
Emit it from main. Inside a plugin use
ctx.broadcast(...); in orchestrator code use the typedbroadcast(IPC_EVENTS.X, payload)helper fromsrc/main/ipc/broadcast.ts— never hand-roll aBrowserWindow.getAllWindows()loop (the helper is typed byIpcEventMap, so a wrong payload is a compile error). -
Subscribe in the renderer:
const off = window.electronAPI.on(IPC_EVENTS.DB_LONG_QUERY_PROGRESS, ({ profileId, pct }) => {// …})// off() to unsubscribe
Guard rails
Section titled “Guard rails”| Check | Where | What breaks if you skip a step |
|---|---|---|
| Single-source key coverage | IPC_CHANNELS / IPC_EVENTS use satisfies Record<keyof IpcChannelShapes, string> | Constant without a shape (or shape without a constant) → build fail |
| Compile-time map coverage | tests/unit/ipc-channels-coverage.test.ts re-asserts the shape↔constant key sets match | Drift between the two halves → build fail |
| Call-site single-sourcing | tests/unit/audit/ipc-channels-single-sourced.test.ts scans all processes for a raw 'domain:action' literal passed to invoke/on/send/handle/h/broadcast/emit | Hand-rolled wire string at any call site (renderer or main handle/broadcast) → test fail. Always pass IPC_CHANNELS.X / IPC_EVENTS.X, never the literal |
| Renderer typing | window.electronAPI.invoke<K>() | Wrong args / wrong return → build fail |
| Handler typing | handle: Handle in ipc/context.ts | Wrong args / wrong return → build fail |
Picking a channel name
Section titled “Picking a channel name”- Use
domain:verb-noun(kebab-case after the colon). Multi-level domains use additional colons:plugins:ui:get-contributions. - Pick the domain prefix that already exists rather than inventing a new one — it determines which file the handler goes in.
- Avoid abbreviations that hide intent.
db:explain-queryis better thandb:eq.