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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions