Make IPC fully type-safe — contract, typed wrappers, frame validation
Summary
focusd's IPC has partial type-safety today: a typed renderer surface (FocusdAPI) and a global Window augmentation, but the actual IPC boundary is stringly-typed and the main side has no contract enforcement. This proposal adds a single source of truth for channels, typed wrapper helpers on both sides of the bridge, and a best-effort sender-frame validation hook — without changing any runtime behavior or removing existing code.
Current state
What's already good
FocusdAPI interface in src/shared/types.ts:203-248 — encodes args + return + nested namespaces.
- Preload binds against the contract:
const api: FocusdAPI = { ... } in src/preload/index.ts.
- Renderer
Window is augmented in src/renderer/src/env.d.ts:
declare global { interface Window { api: FocusdAPI } }
strict: true in tsconfig.node.json and tsconfig.web.json.
What's missing
- Channel names are stringly-typed at the boundary.
ipcMain.handle('app:info', ...) and ipcRenderer.invoke('app:info') are decoupled string literals. A typo or rename touches three places without TS catching the drift.
- Main-side handlers don't link to
FocusdAPI types. ipcMain.handle('app:info', () => 'wrong return') would compile.
sendToRenderer is fully untyped. src/main/ipc-handlers.ts:38 is (channel: string, ...args: unknown[]) => void — channel and payload unchecked.
- No contract for push channels.
recording-state, idle-state, tray-action, new-summary have no central type definition.
- No sender-frame validation. No origin or
senderFrame.url check at any IPC entry point.
- Audit notes (typed in this proposal but not feature-wired here):
| Channel |
Where |
Coverage |
app:logDir |
registered in main, no preload entry |
invokable from main only |
idle-state |
pushed from main, no preload listener |
reaches no renderer |
tray-action |
pushed from main, no preload listener |
reaches no renderer |
new-summary |
preload listener exists, no main emission |
listener never fires |
Proposal
1. New file — src/shared/ipc-contract.ts
Single source of truth for both directions: an IpcInvokeChannels map (channel → { args, return }) and an IpcSendChannels map (channel → payload). Includes a small TrayAction = 'start' | 'stop' union for the tray push payload.
2. New file — src/main/ipc-utils.ts
Three exports:
validateEventFrame(frame) — best-effort sender-frame validation. In dev, allows the electron-vite renderer URL and any localhost frame; in production, allows file:// URLs. Currently logs unexpected origins via warn(...) instead of throwing, so flipping the hook on is non-breaking. Future hardening can convert the warning into a thrown error once expected origins are confirmed in the field.
ipcMainHandle<K extends keyof IpcInvokeChannels>(channel, handler) — generic wrapper that enforces the channel name, the handler's args tuple, and its return type via the contract. Calls validateEventFrame on every invoke.
ipcWebContentsSend<K extends keyof IpcSendChannels>(channel, webContents, payload) — typed wrapper for webContents.send.
3. New file — src/preload/ipc-utils.ts
Two exports:
ipcInvoke<K extends keyof IpcInvokeChannels>(channel, ...args) — typed wrapper around ipcRenderer.invoke. Args spread from the contract; return type is Promise<IpcInvokeChannels[K]['return']>.
ipcOn<K extends keyof IpcSendChannels>(channel, cb) — typed wrapper around ipcRenderer.on that returns an unsubscribe function. Callback parameter is typed automatically.
4. Migrate src/main/ipc-handlers.ts
- Drop the
ipcMain import; add import { ipcMainHandle, ipcWebContentsSend } from './ipc-utils'; and import type { IpcSendChannels } from '../shared/ipc-contract';.
- Replace every
ipcMain.handle('foo', (_e, ...) => ...) with ipcMainHandle('foo', (...) => ...). The _e parameter is dropped because the wrapper does not pass the event through; handler bodies do not currently use it.
- Make the local
sendToRenderer generic over IpcSendChannels so payload + channel are statically checked. Existing call sites (sendToRenderer('recording-state', 'starting'), sendToRenderer('idle-state', idle)) keep working unchanged.
5. Migrate src/preload/index.ts
- Drop the raw
ipcRenderer import in favor of import { ipcInvoke, ipcOn } from './ipc-utils';.
- Replace every
ipcRenderer.invoke('foo', ...) with ipcInvoke('foo', ...).
- Replace each manual
ipcRenderer.on(...) listener with ipcOn(...). Each listener becomes one line.
- Drop imports that were only used to type the local listener callbacks (
RecordingState, MicroSummary, Settings) — types now flow from the contract.
6. Migrate src/main/index.ts
- Add
import { ipcWebContentsSend } from './ipc-utils';.
- Replace the two
mainWindow?.webContents.send('tray-action', ...) calls with ipcWebContentsSend('tray-action', mainWindow.webContents, ...) guarded by a mainWindow null-check.
Net file impact
| File |
Status |
src/shared/ipc-contract.ts |
new |
src/main/ipc-utils.ts |
new |
src/preload/ipc-utils.ts |
new |
src/main/ipc-handlers.ts |
modified — handler signatures + sendToRenderer |
src/preload/index.ts |
rewritten in same shape, shorter |
src/main/index.ts |
modified — tray-action sends |
No existing files are deleted. Runtime behavior is unchanged.
Verification
- Type check —
npx tsc --noEmit -p tsconfig.node.json and -p tsconfig.web.json produce no new errors. The two pre-existing TS errors (src/main/services/capture.ts:308, src/main/services/config.ts:54) are unrelated and predate this change.
- Smoke test (
npm run dev) —
- Onboarding: API key validate + save, permission cards, "Open Settings" buttons.
- Capture: list screens, start, stop. Verify
recording-state push reaches the renderer.
- Settings: each toggle and numeric input round-trips through
settings:get / settings:update.
- Summaries: today view loads, daily refresh works.
- Build (
npm run build / npm run package:mac) — succeeds without changes to electron-vite or electron-builder config.
- Frame validation telemetry — open logs during dev/smoke testing; confirm no
[IPC-UTIL] Unexpected frame URL: ... warnings during normal use. Any warning indicates a frame the validator should be taught to recognize before a future PR flips the hook to throw.
Out of scope (explicitly deferred)
- Auto-deriving the contract from
FocusdAPI via mapped/recursive types. The two are kept parallel and consistent 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; no exposure or feature change is in scope here.
- Renderer-side ergonomic helpers (e.g., a React hook around
ipcOn).
- Flipping
validateEventFrame to throw on unexpected origins. The hook ships in warn-only mode for non-breaking adoption; hardening is a separate follow-up after the field shows no false positives.
Make IPC fully type-safe — contract, typed wrappers, frame validation
Summary
focusd's IPC has partial type-safety today: a typed renderer surface (
FocusdAPI) and a globalWindowaugmentation, but the actual IPC boundary is stringly-typed and the main side has no contract enforcement. This proposal adds a single source of truth for channels, typed wrapper helpers on both sides of the bridge, and a best-effort sender-frame validation hook — without changing any runtime behavior or removing existing code.Current state
What's already good
FocusdAPIinterface insrc/shared/types.ts:203-248— encodes args + return + nested namespaces.const api: FocusdAPI = { ... }insrc/preload/index.ts.Windowis augmented insrc/renderer/src/env.d.ts:strict: trueintsconfig.node.jsonandtsconfig.web.json.What's missing
ipcMain.handle('app:info', ...)andipcRenderer.invoke('app:info')are decoupled string literals. A typo or rename touches three places without TS catching the drift.FocusdAPItypes.ipcMain.handle('app:info', () => 'wrong return')would compile.sendToRendereris fully untyped.src/main/ipc-handlers.ts:38is(channel: string, ...args: unknown[]) => void— channel and payload unchecked.recording-state,idle-state,tray-action,new-summaryhave no central type definition.senderFrame.urlcheck at any IPC entry point.app:logDiridle-statetray-actionnew-summaryProposal
1. New file —
src/shared/ipc-contract.tsSingle source of truth for both directions: an
IpcInvokeChannelsmap (channel →{ args, return }) and anIpcSendChannelsmap (channel → payload). Includes a smallTrayAction = 'start' | 'stop'union for the tray push payload.2. New file —
src/main/ipc-utils.tsThree exports:
validateEventFrame(frame)— best-effort sender-frame validation. In dev, allows the electron-vite renderer URL and any localhost frame; in production, allowsfile://URLs. Currently logs unexpected origins viawarn(...)instead of throwing, so flipping the hook on is non-breaking. Future hardening can convert the warning into a thrown error once expected origins are confirmed in the field.ipcMainHandle<K extends keyof IpcInvokeChannels>(channel, handler)— generic wrapper that enforces the channel name, the handler's args tuple, and its return type via the contract. CallsvalidateEventFrameon every invoke.ipcWebContentsSend<K extends keyof IpcSendChannels>(channel, webContents, payload)— typed wrapper forwebContents.send.3. New file —
src/preload/ipc-utils.tsTwo exports:
ipcInvoke<K extends keyof IpcInvokeChannels>(channel, ...args)— typed wrapper aroundipcRenderer.invoke. Args spread from the contract; return type isPromise<IpcInvokeChannels[K]['return']>.ipcOn<K extends keyof IpcSendChannels>(channel, cb)— typed wrapper aroundipcRenderer.onthat returns an unsubscribe function. Callback parameter is typed automatically.4. Migrate
src/main/ipc-handlers.tsipcMainimport; addimport { ipcMainHandle, ipcWebContentsSend } from './ipc-utils';andimport type { IpcSendChannels } from '../shared/ipc-contract';.ipcMain.handle('foo', (_e, ...) => ...)withipcMainHandle('foo', (...) => ...). The_eparameter is dropped because the wrapper does not pass the event through; handler bodies do not currently use it.sendToRenderergeneric overIpcSendChannelsso payload + channel are statically checked. Existing call sites (sendToRenderer('recording-state', 'starting'),sendToRenderer('idle-state', idle)) keep working unchanged.5. Migrate
src/preload/index.tsipcRendererimport in favor ofimport { ipcInvoke, ipcOn } from './ipc-utils';.ipcRenderer.invoke('foo', ...)withipcInvoke('foo', ...).ipcRenderer.on(...)listener withipcOn(...). Each listener becomes one line.RecordingState,MicroSummary,Settings) — types now flow from the contract.6. Migrate
src/main/index.tsimport { ipcWebContentsSend } from './ipc-utils';.mainWindow?.webContents.send('tray-action', ...)calls withipcWebContentsSend('tray-action', mainWindow.webContents, ...)guarded by amainWindownull-check.Net file impact
src/shared/ipc-contract.tssrc/main/ipc-utils.tssrc/preload/ipc-utils.tssrc/main/ipc-handlers.tssrc/preload/index.tssrc/main/index.tsNo existing files are deleted. Runtime behavior is unchanged.
Verification
npx tsc --noEmit -p tsconfig.node.jsonand-p tsconfig.web.jsonproduce no new errors. The two pre-existing TS errors (src/main/services/capture.ts:308,src/main/services/config.ts:54) are unrelated and predate this change.npm run dev) —recording-statepush reaches the renderer.settings:get/settings:update.npm run build/npm run package:mac) — succeeds without changes to electron-vite or electron-builder config.[IPC-UTIL] Unexpected frame URL: ...warnings during normal use. Any warning indicates a frame the validator should be taught to recognize before a future PR flips the hook tothrow.Out of scope (explicitly deferred)
FocusdAPIvia mapped/recursive types. The two are kept parallel and consistent by hand for now.app:logDir,idle-state,tray-action,new-summary). They're typed in the contract so call sites compile; no exposure or feature change is in scope here.ipcOn).validateEventFrameto throw on unexpected origins. The hook ships in warn-only mode for non-breaking adoption; hardening is a separate follow-up after the field shows no false positives.