Skip to content

feat: make IPC fully type-safe with contract + typed wrappers#7

Open
skalkii wants to merge 1 commit into
video-db:mainfrom
skalkii:feat/ipc-typesafe
Open

feat: make IPC fully type-safe with contract + typed wrappers#7
skalkii wants to merge 1 commit into
video-db:mainfrom
skalkii:feat/ipc-typesafe

Conversation

@skalkii
Copy link
Copy Markdown

@skalkii skalkii commented Apr 25, 2026

Summary

  • Adds a single source of truth for every IPC channel (src/shared/ipc-contract.ts).
  • Routes every ipcMain.handle, ipcRenderer.invoke, and webContents.send through generic wrappers that enforce channel name + args + return/payload at the type level.
  • Adds a best-effort validateEventFrame hook (warn-only, non-breaking) that future PRs can flip to enforcing.
  • No runtime behavior change. No files deleted.

Why

Pre-change IPC had partial type-safety:

  • The renderer-facing FocusdAPI was strongly typed.
  • The IPC boundary itself was stringly-typed: ipcMain.handle('foo:bar', ...) and ipcRenderer.invoke('foo:bar', ...) were decoupled string literals; main-side handlers had no link to FocusdAPI's return types; sendToRenderer was (string, ...unknown[]); push-channel payloads had no central type.
  • A typo in any channel name or a wrong return type from a handler would compile silently.

Changes

New

File Role
src/shared/ipc-contract.ts IpcInvokeChannels + IpcSendChannels maps; TrayAction union
src/main/ipc-utils.ts ipcMainHandle<K>, ipcWebContentsSend<K>, validateEventFrame
src/preload/ipc-utils.ts ipcInvoke<K>, ipcOn<K> (returns unsubscribe)

Migrated

  • src/main/ipc-handlers.tsipcMain.handleipcMainHandle; _e dropped from handler signatures; sendToRenderer is now generic over IpcSendChannels.
  • src/preload/index.tsipcRenderer.invoke/onipcInvoke/ipcOn. Listener boilerplate collapsed to one line per channel.
  • src/main/index.tswebContents.send('tray-action', …)ipcWebContentsSend(...).

Frame validation

validateEventFrame is wired into every ipcMainHandle. In dev it allows the electron-vite renderer URL and localhost; in production it allows file:// URLs. Currently logs unexpected origins via warn(...) rather than throwing, so the hook ships non-breaking. Future hardening can flip the warn into a throw once field telemetry shows no false positives.

Out of scope

  • Auto-deriving the contract from FocusdAPI via mapped types — kept parallel by hand for now.
  • Wiring up or removing the four audit findings (app:logDir, idle-state, tray-action, new-summary). They're typed in the contract so call sites compile; feature changes are separate.
  • Renderer-side ergonomic helpers (e.g., a React hook around ipcOn).
  • Flipping validateEventFrame to throw — separate follow-up.

Test plan

  • npx tsc --noEmit -p tsconfig.node.json and -p tsconfig.web.json produce no new errors. (Two pre-existing errors in src/main/services/capture.ts:308 and src/main/services/config.ts:54 are unrelated and predate this change.)
  • npm run build succeeds.
  • npm run preview — exercise onboarding (validate/save key, permission cards), capture (list screens, start, stop), settings (each toggle and numeric input), summaries (today + daily refresh). Verify recording-state push reaches the renderer.
  • npm run package:mac — confirms the deploy script unchanged.
  • Tail logs during smoke test for [IPC-UTIL] Unexpected frame URL: ... warnings — none expected during normal use.

Fixes #6

Add a single source of truth for every IPC channel and route every
ipcMain.handle / ipcRenderer.invoke / webContents.send call through
generic wrappers that enforce channel name + args + return / payload
against the contract.

New files:
- src/shared/ipc-contract.ts — IpcInvokeChannels (channel → {args, return})
  and IpcSendChannels (channel → payload) maps, with a TrayAction union
  for the tray push payload.
- src/main/ipc-utils.ts — ipcMainHandle, ipcWebContentsSend, and a
  best-effort validateEventFrame hook. Frame validation logs unexpected
  origins (warn-only) so adoption is non-breaking; future hardening can
  flip the warn into a throw once expected origins are confirmed.
- src/preload/ipc-utils.ts — ipcInvoke, ipcOn (returns unsubscribe).

Migrated:
- src/main/ipc-handlers.ts — every ipcMain.handle replaced with
  ipcMainHandle; the unused _e parameter is dropped from each handler;
  sendToRenderer is now generic over IpcSendChannels.
- src/preload/index.ts — every ipcRenderer.invoke / on replaced with
  ipcInvoke / ipcOn. Listener boilerplate collapses to one line per
  channel.
- src/main/index.ts — webContents.send('tray-action', ...) replaced with
  ipcWebContentsSend.

No runtime behavior change. No files deleted. The four orphan/dead-listener
channels (app:logDir, idle-state, tray-action, new-summary) are typed in
the contract so call sites compile, but their feature wiring is left as-is
and tracked separately.

Fixes video-db#6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make IPC fully type-safe: contract + typed wrappers + frame validation

1 participant