Make the IpcApi docs explicit about three things surfaced in review: - overview: Why Narrow the Surface (before/after + the type/cheat-sheet/audit wins), Direction Cheat Sheet (IpcRoute vs IpcEventName + out-of-scope), and No One-Way R->M Primitive (every R->M is invoke/handle; void still round-trips). - migration guide: Escape Hatch criteria (direction gate then frequency) with Tab_MoveWindow as the sole qualifying carve-out, its two hard conditions (still gated, still documented), and scope discipline for the sibling one-off channels; add Tab_MoveWindow to the Not In Scope table. - README + usage: navigation pointers and the R->M / M->R high-frequency asymmetry (M->R stays via class B, R->M may escape).
10 KiB
IpcApi Migration Guide
Stage 0 (the framework) ships alongside legacy IPC. Migration is later work — multiple independent PRs, one domain at a time, until everything is collected and the old machinery is retired.
Per-Domain Migration (request side)
For each domain, in one atomic PR (the four actions must land together, or the build breaks mid-way):
- Add the domain's
*RequestSchemas+*EventSchemastosrc/shared/ipc/schemas/. - Move the handler logic into
src/main/ipc/handlers/<domain>.ts(pure function if stateless; otherwise delegate to the existing service viaapplication.get). The service keeps its business logic and resource lifecycle; it just stops registering IPC. - Delete the old hand-written
preload/index.tsmethod(s) for that domain. - Switch renderer call sites to
ipcApi.request(...)/useIpcOn(...), then delete the oldIpcChannelenum entries.
Each PR is independently revertible.
Test the handler, not the schema. handlers/__tests__/<domain>.test.ts covers the real behavior (senderId routing, null fallback, delegation). Per-domain schemas are thin contracts locked by compile-time checks plus the one framework type test (src/shared/ipc/__tests__/schema.types.test.ts) — do not copy a schemas/__tests__ template. See ipc-usage.md.
Schema Authoring: Mirroring an Existing Type
When a request input reuses a TS type defined elsewhere (a preference type, a shared model), bind the validating zod schema to that type at the definition with z.ZodType<X>, so a drift is a compile error there — not in a far-away test:
import type { SelectionActionItem } from '@shared/data/preference/preferenceTypes'
// repo convention — see uiParts.ts, legacyFileMetadata.ts
const selectionActionItemSchema: z.ZodType<SelectionActionItem> = z.object({ id: z.string() /* …all fields… */ })
Two enforcement layers, only the second costs anything:
| Layer | Guarantees |
|---|---|
Handler contract (handler → svc.method(x: X)) |
schema covers every required field of X — free, the handler already passes it |
z.ZodType<X> annotation |
exact equality (optionals present, no extras) |
Anti-pattern to avoid: a JSDoc {@link X} plus a separate expectTypeOf test — the import reads as unused and the check drifts away from the definition.
Lighter alternative. If the value is opaque pass-through (main forwards it as initData and never reads its fields) and the renderer already type-locks the shape, z.custom<X>() drops the field mirror at the cost of no runtime field validation. Pick per ROI.
Return Values: void When Meaningless
A legacy handler often returns an internal status the caller never reads — e.g. WindowManager's close/minimize return a "was the window found" boolean, but the preload already typed it Promise<void> and every call site ignores it. Declare the route output: z.void() in that case. Give a non-void output only when a caller actually consumes the value (a query like window.is_maximized → boolean, window.get_init_data → unknown). The handler may still compute the internal value; the thin adapter just discards it. This keeps the typed surface honest about what callers can rely on.
Two Service Shapes
| Service kind | Migration form |
|---|---|
| Stateless (app info, fonts) | pure function in handlers/, no lifecycle service |
| Stateful (MCP / Knowledge / Window) | handler in handlers/ delegating to application.get('XxxService'); logic + lifecycle stay in the service |
BaseService.ipcHandle / ipcOn Removal
These sugar methods are just ipcMain.handle/on + registerDisposable(removeHandler/removeListener) — no unique capability. After all services are migrated, remove them in a dedicated terminal PR. IPC registration then collapses to two kinds: (1) business → the single IpcApi channel; (2) infrastructure data subsystems (DataApi/Preference/Cache) → their own native ipcMain.handle + registerDisposable, like DataApi's IpcAdapter.
IpcChannel Collapse
As domains migrate, their channel enum entries are deleted. At the end, src/shared/IpcChannel.ts is reduced to the IpcApi pair + the infrastructure DataApi_*/Preference_*/Cache_* channels, and moved to src/shared/ipc/channels.ts.
Exposure-Surface Audit
After migration, every main capability the renderer can reach is enumerated in src/main/ipc/handlers/ — one auditable list. Compare against the deleted scattered this.ipcHandle sites to confirm nothing was widened or dropped.
M→R send Work-List
~47 push call sites across ~30 channels, classified by destination:
| Class | Destination | Notes |
|---|---|---|
| A typed event (~35, the bulk) | IpcApi broadcast/send + useIpcOn |
window lifecycle/state, theme, selection, MCP/adapter notifications, update progress, etc. |
| B topic stream (5) | service-held listener + directed send |
Ai_StreamChunk/_Done/_Error, File_TreeMutation; keep 16ms/2048 batching + multi-window attach |
| C infrastructure (2) | not collected | Preference_Changed, Cache_Sync — stay in their subsystems |
| D special addressing (5) | ctx.senderId-based directed send |
CherryIN_OAuthResult ×4 (reply to the initiator window), migration progress |
~40 sites (A+B) move onto the IpcApi event link; only the 2 class-C sites stay out.
Class examples (before → after)
// A — typed event (WindowManager_MaximizedChanged): IpcChannel enum + win.webContents.send + preload onXxx + manual removeListener
export type WindowEventSchemas = { 'window.maximized_changed': { maximized: boolean } }
application.get('IpcApiService').send(windowId, 'window.maximized_changed', { maximized: isMax })
useIpcOn('window.maximized_changed', ({ maximized }) => setMax(maximized))
// B — topic stream (Ai_StreamChunk): the service's listener/batching/multi-window attach are unchanged; only "how to send" + ctx.senderId replaces event.sender
export type AiEventSchemas = { 'ai.stream_chunk': { topicId: string; chunk: AiChunk } }
'ai.stream_open': (req, { senderId }) => aiStream.attach(senderId, req.topicId)
// service: for (const id of windowsOf(topicId)) application.get('IpcApiService').send(id, 'ai.stream_chunk', { topicId, chunk })
useIpcOn('ai.stream_chunk', ({ topicId, chunk }) => { if (topicId === current) append(chunk) })
// C — not collected (Preference_Changed / Cache_Sync): keep using the subsystem hooks
const [theme] = usePreference('app.theme')
const [pos] = useSharedCache('scroll.position.x')
// D — special addressing (CherryIN_OAuthResult): reply only to the initiator window
export type CherryinEventSchemas = { 'cherryin.oauth_result': { ok: boolean; apiKeys?: ApiKey[]; error?: string } }
'cherryin.oauth_start': (req, { senderId }) => oauth.begin(req, senderId) // remember initiator WindowId
application.get('IpcApiService').send(savedSenderId, 'cherryin.oauth_result', { ok: true, apiKeys }) // no-op if the window is gone
useIpcOn('cherryin.oauth_result', (r) => (r.ok ? saveKeys(r.apiKeys) : showError(r.error)))
Known inconsistency to fix during collection
IpcChannel.Notification_OnClick = 'notification:on-click' (IpcChannel.ts) is unused; the actual push hardcodes 'notification-click' (MainWindowService.ts / NotificationService.ts) and the renderer listens for the hardcoded string. Unify into a typed event when collecting the notification domain.
Escape Hatch — When a Channel May Stay Out
Default: every R→M channel goes through IpcApi. The escape hatch is a rare, last-resort exception — today exactly one channel in the whole codebase clears the bar (Tab_MoveWindow). It is not a "high-frequency optimization" to reach for; it is opting out of the typed, gated, audited surface, and must be earned.
Two-step test — direction, then frequency:
Does this R→M channel go through IpcApi?
├─ M→R? → never escapes (already one-way send); hot → class B, still in IpcApi
└─ R→M?
├─ per-action → IN IpcApi (request, even void)
└─ per-frame → escape candidate → must meet BOTH conditions below
Why M→R never escapes. Its IpcApi transport is already one-way webContents.send (IpcApiService.send, WindowManager.broadcast) — no reply leg, nothing to escape. A hot M→R stream stays in IpcApi via the class-B pattern (service-held registry + directed send(windowId) + batching).
Why per-frame R→M may escape. R→M is invoke/handle (round-trip), so a per-frame channel pays the reply leg every frame, and await couples the drag loop to main's tail latency. Tab_MoveWindow (rAF-throttled, ~60–120/s, fire-and-forget native window move) is the only per-frame R→M in the repo — the only qualifier.
Two hard conditions for a carve-out (or it is a hole, not an exception):
- Still gated — register with native
ipcMain.on+registerDisposable+ an explicitvalidateSendercall (mirroring DataApi/Cache native registration). Do not use thethis.ipcOnsugar (slated for removal, see above). - Still documented — list it in Not In Scope below. A documented carve-out (like
Cache_Sync) keeps the one-list exposure audit honest; an undocumented omission breaks it.
Scope discipline — most of the same feature still migrates in:
| Channel | Disposition |
|---|---|
Tab_MoveWindow |
Out — escape hatch (gated + documented) |
Tab_Detach / Tab_DragEnd / Ai_AbortImage |
In — one-off → void request |
Python_ExecutionResponse |
Separate — renderer-as-server reverse RPC (request-id correlated, carries error); IpcApi's main-as-server request model doesn't fit, handle on its own |
Cache_Sync |
Stays in the Cache subsystem |
Not In Scope For IpcApi
| Item | Stays in |
|---|---|
Tab_MoveWindow (per-frame R→M drag; native ipcMain.on + own validateSender) |
SubWindowService (escape hatch) |
shell.openExternal, webUtils.getPathForFile (preload calls Electron directly, not IPC) |
window.electron |
preference.onChanged, dataApi.subscribe |
their own subsystems |
Cache_Sync "exclude self" (uses numeric BrowserWindow.id) |
Cache subsystem |