IPC Boundary
Renderer ↔ main process communication is type-checked end-to-end by shared contracts.
Shape
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ renderer │ ──IPC── │ preload │ ──IPC── │ main │
│ (window.api)│ │ (typed surf)│ │ (handlers) │
└──────────────┘ └─────────────┘ └──────────────┘
│ │
└─────── packages/contracts (Zod) ───────────────┘- Contracts live in
packages/contractsand are Zod-typed. - Preload exposes a typed
window.apisurface (no Node access in renderer). - Main registers handlers against the same contract types.
Files Worth Knowing
packages/contracts/
├─ ipc-channels.ts # channel name constants
├─ <domain>-api.ts # request/response Zod schemas
└─ telemetry-api.ts # telemetry surfaces
apps/desktop/src/preload/
└─ index.ts # window.api surface
apps/desktop/src/main/ipc/
└─ <domain>-handlers.ts # one file per domainInvoke Map
pnpm ipc:generate regenerates the typed invoke map from contract types. pnpm ipc:check runs the typecheck that validates renderer↔main alignment.
When to run:
- After adding or renaming a channel
- After changing a request / response Zod schema
- Before opening any PR that touches the boundary
Adding a New Channel
- Add a channel constant to
packages/contracts/ipc-channels.ts. - Define request and response Zod schemas in
packages/contracts/<domain>-api.ts. - Add a handler in
apps/desktop/src/main/ipc/<domain>-handlers.ts. - Expose the call on
window.apivia the preload script. - Run
pnpm ipc:generate && pnpm ipc:check. - Use it from the renderer.
Error Propagation
Renderer-side IPC errors carry Electron noise (stack frames, channel names). Always strip with:
ts
import { extractErrorMessage } from '@/lib/ipc-error'
try {
await window.api.notes.create(...)
} catch (err) {
toast.error(extractErrorMessage(err, 'Could not create note'))
}Logging
Both sides use createLogger(scope) from electron-log. Never console.*.
ts
import { createLogger } from '@/lib/logger'
const log = createLogger('NoteService')
log.info('created note', { id })Ownership Rules
- The main process owns SQLite, Yjs
Y.Docs, and the file system. - The renderer owns UI state, tabs, and the BlockNote editor.
- CRDT updates flow renderer → main via the Yjs IPC provider; updates are tagged with
sourceWindowIdand Y.Doc origin parameters to prevent loops.