6.1 KiB
IPC + Runtime Contracts
SubMiner's Electron app runs two isolated processes — main and renderer — that can only communicate through IPC channels. This boundary is intentional: the renderer is an untrusted surface (it loads Yomitan, renders user-controlled subtitle text, and runs in a Chromium sandbox), so every message crossing the bridge passes through a validation layer before it can reach domain logic.
The contract system enforces this by making channel names, payload shapes, and validators co-located and co-evolved. A change to any IPC surface touches the contract, the validator, the preload bridge, and the handler in the same commit — drift between any of those layers is treated as a bug.
Message Flow
Renderer-initiated calls (invoke) pass through four boundaries before reaching a service. Fire-and-forget messages (send) follow the same path but skip the response leg. Malformed payloads are caught at the validator and never reach domain code.
flowchart LR
classDef rend fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef bridge fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef valid fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef handler fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef svc fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef err fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
R["Renderer"]:::rend
P(["preload.ts"]):::bridge
M["ipcMain handler"]:::handler
V{"Validator"}:::valid
S["Service"]:::svc
E["Structured error"]:::err
R -->|"invoke / send"| P
P -->|"ipcRenderer"| M
M --> V
V -->|"valid"| S
V -->|"malformed"| E
S -->|"result"| P
E -->|"{ ok: false }"| P
P -->|"return"| R
style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
Core Surfaces
| File | Role |
|---|---|
src/shared/ipc/contracts.ts |
Canonical channel names and payload type contracts. Single source of truth for both processes. |
src/shared/ipc/validators.ts |
Runtime payload parsers and type guards. Every invoke payload is validated here before the handler runs. |
src/preload.ts |
Renderer-side bridge. Exposes a typed API surface to the renderer — only approved channels are accessible. |
src/main/ipc-runtime.ts |
Main-process handler registration and routing. Wires validated channels to domain handlers. |
src/core/services/ipc.ts |
Service-level invoke handling. Applies guardrails (validation, error wrapping) before calling domain logic. |
src/core/services/anki-jimaku-ipc.ts |
Integration-specific IPC boundary for Anki and Jimaku operations. |
src/main/cli-runtime.ts |
CLI/runtime command boundary. Handles commands that originate from the launcher or mpv plugin rather than the renderer. |
Contract Rules
These rules exist to prevent a class of bugs where the renderer and main process silently disagree about message shapes — which surfaces as undefined fields, swallowed errors, or state corruption.
- Use shared constants. Channel names come from
contracts.ts, never ad-hoc literal strings. This makes channels greppable and refactor-safe. - Validate before handling. Every
invokepayload passes throughvalidators.tsbefore reaching domain logic. This catches shape drift at the boundary instead of deep inside a service. - Return structured failures. Handlers return
{ ok: false, error: string }on failure rather than throwing. The renderer can always distinguish success from failure without try/catch. - Keep payloads narrow. Send only what the handler needs. Avoid passing entire state objects across the bridge — it couples the renderer to internal main-process structure.
- Co-evolve all layers. When a payload shape changes, update
contracts.ts,validators.ts,preload.ts, and the handler in the same commit. Partial updates are treated as bugs.
Two Message Patterns
Invoke (request/response): The renderer calls a typed bridge method and awaits a result. The main process validates the payload, runs the handler, and returns a structured response. Used for operations where the renderer needs a result — lookups, config reads, mining actions.
Fire-and-forget (send): The renderer sends a message with no response. The main process validates and handles it silently. Malformed payloads are dropped. Used for notifications where the renderer doesn't need confirmation — UI state hints, focus events, position updates.
Add a New IPC Action
- Add the channel constant in
src/shared/ipc/contracts.ts. - Add or extend the payload validator in
src/shared/ipc/validators.ts. - Expose a typed bridge method in
src/preload.ts. - Register the handler in
src/main/ipc-runtime.ts(or the relevant domain runtime module). - Add tests for both valid and malformed payload cases in
src/core/services/*. - Update renderer tests when behavior or state transitions change.
Runtime State Notes
- Prefer runtime/domain composition via
src/main/runtime/composers/*andsrc/main/runtime/domains/*. IPC handlers should delegate to composers rather than containing orchestration logic. - Route shared mutable state updates through transition helpers in
src/main/state.tsfor migrated domains. Direct mutation from IPC handlers bypasses invariant checks. - Keep IPC handlers thin — they validate, delegate, and return. Business logic belongs in services.
Troubleshooting
- Unknown payload in handler: The validator is not being applied before the handler runs. Check that the channel is routed through
ipc-runtime.tswith validation, not registered directly. - Renderer invoke fails: Verify the preload bridge method exists and matches the channel constant. Check that the handler is registered and returning (not throwing).
- Contract drift: When invoke calls return unexpected shapes, compare the shared contract, validator, preload bridge, and main handler signatures side by side. One of them was updated without the others.