- Clarify current integration, media review, and Anki behavior - Remove obsolete AI translation and configuration references - Standardize headings and prose across docs
7.9 KiB
IPC + runtime contracts
SubMiner's Electron app runs two isolated processes, main and renderer, and IPC channels are the only way they talk. That boundary is deliberate. The renderer is an untrusted surface: it loads Yomitan, renders subtitle text SubMiner did not write, and runs in a Chromium sandbox. Every message crossing the bridge goes through a validator before any domain code sees it.
Channel names, payload shapes, and validators all live together, so they change together. Touching an IPC surface means updating the contract, the validator, the preload bridge, and the handler in one commit. Drift between those four layers is a bug, not a style preference.
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 TB
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
Runtime sockets
The renderer↔main bridge above lives inside the Electron app. A separate set of OS sockets connects the app to the other runtimes - mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
- mpv IPC socket (
/tmp/subminer-socket, or\\.\pipe\subminer-socketon Windows): theMpvIpcClientin the main process connects here to send JSON commands and subscribe to playback/subtitle properties viaobserve_property. Created by mpv's--input-ipc-server. - App control socket (
/tmp/subminer-control-<uid>-<hash>.sock, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (--start,--show-visible-overlay,--texthooker) to a running app here. It also dedupes a secondsubminerinvocation into the existing instance instead of launching twice.
flowchart LR
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef app fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef ext fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
subgraph MpvProc["mpv process"]
direction TB
Mpv["mpv core"]:::ext
Plugin["SubMiner plugin (Lua)"]:::extrt
end
Launcher["Launcher CLI"]:::extrt
App["SubMiner app (Electron main)"]:::app
App <-->|"mpv IPC socket · /tmp/subminer-socket<br/>JSON commands + property observe"| Mpv
Launcher -->|"app control socket · /tmp/subminer-control-*<br/>--start, --show-visible-overlay, …"| App
Plugin -->|"app control socket<br/>spawn / attach"| App
style MpvProc fill:#363a4f,stroke:#494d64,color:#cad3f5
How these sockets are established during launch is covered in Playback Startup Flow.
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.