Skip to content

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

@skalkii

Description

@skalkii

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

  1. 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.
  2. Main-side handlers don't link to FocusdAPI types. ipcMain.handle('app:info', () => 'wrong return') would compile.
  3. sendToRenderer is fully untyped. src/main/ipc-handlers.ts:38 is (channel: string, ...args: unknown[]) => void — channel and payload unchecked.
  4. No contract for push channels. recording-state, idle-state, tray-action, new-summary have no central type definition.
  5. No sender-frame validation. No origin or senderFrame.url check at any IPC entry point.
  6. 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

  1. Type checknpx 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.
  2. 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.
  3. Build (npm run build / npm run package:mac) — succeeds without changes to electron-vite or electron-builder config.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions