Electron IPC
StreamooS uses two distinct communication channels between its three processes (main, preload, renderer) and the overlays. This document describes how each layer communicates.
Overview
Core Rules
- The renderer can never import Node.js / Electron modules
- The renderer can never call
ipcRendererdirectly - All communication goes through
window.streamoos(preload bridge) - Overlays communicate only via WebSocket (never IPC)
Preload Bridge (window.streamoos)
The preload script (app/src/preload/index.cts) exposes a typed API via contextBridge that the renderer can call safely:
// app/src/preload/index.cts
contextBridge.exposeInMainWorld("streamoos", {
sendCommand: (payload: FeatureCommandPayload) => ipcRenderer.invoke("feature/command", payload),
onFeatureUpdate: (callback: (event: FeatureUpdateEvent) => void) =>
ipcRenderer.on("feature/update", (_, event) => callback(event)),
// ... other methods
});In the renderer:
// app/src/renderer/
const state = await window.streamoos.sendCommand("timer", "get_state");
window.streamoos.onUpdate((update) => {
if (update.feature === "timer") {
/* ... */
}
});IPC Channels (app/src/shared/ipc.ts)
Channels are defined in IPC_CHANNELS in app/src/shared/ipc.ts and shared between main and preload:
Application
| Channel | Direction | Description |
|---|---|---|
app/ping | renderer → main | Health check |
app/status | renderer → main | Get app status (WS server, overlays, windows) |
Feature Commands
| Channel | Direction | Description |
|---|---|---|
feature/command | renderer → main | Send a command to a feature handler |
feature/update | main → renderer | State broadcast (mirrors WS updates) |
feature/error | main → renderer | Feature error notification |
ws/event | main → renderer | WebSocket runtime events (client connect/disconnect, commands) |
Twitch
| Channel | Direction | Description |
|---|---|---|
twitch/auth-start | renderer → main | Start Twitch OAuth PKCE flow |
twitch/auth-status | main → renderer | Auth result callback |
twitch/disconnect | renderer → main | Disconnect Twitch |
User Data
| Channel | Direction | Description |
|---|---|---|
favorites/get / favorites/set | renderer ↔ main | User favorite radio preference |
settings/get / settings/set | renderer ↔ main | App settings (theme, language, presets) |
profile/get / profile/set | renderer ↔ main | User profile (display name, avatar) |
profile/upload-avatar | renderer → main | Upload avatar image (file dialog) |
Remote Control
| Channel | Direction | Description |
|---|---|---|
remote/generate-pin | renderer → main | Generate a new PIN + remote URLs |
Overlays
| Channel | Direction | Description |
|---|---|---|
overlay/open | renderer → main | Open a built-in overlay in external window |
custom-overlay/list | renderer → main | List user-created overlays |
custom-overlay/add | renderer → main | Import a custom overlay |
custom-overlay/remove | renderer → main | Delete a custom overlay |
custom-overlay/open | renderer → main | Open a custom overlay |
Handler Registry (Main)
Each feature implements the FeatureHandler interface (app/src/main/handlers/types.ts):
interface FeatureHandler {
readonly feature: string;
handle(action: string, data: unknown): Promise<unknown>;
getState(): FeatureState;
}HandlerRegistry (app/src/main/handlers/registry.ts) routes commands to the correct handler:
ipcMain.handle('command', async (event, { feature, action, data }) => {
// 1. Sender origin check (event.senderFrame)
// 2. Zod payload validation
// 3. Dispatch to FeatureHandler
// 4. emitUpdate() → IPC + WS simultaneously
})emitUpdate - Simultaneous Sync
Overlays and the dashboard always receive the same state at the same time.
IPC Security
Sender Origin Check
Every ipcMain handler verifies the message comes from a known frame:
// ❌ Never do this
ipcMain.handle('command', async (event, data) => { ... })
// ✓ Always check
ipcMain.handle('command', async (event, data) => {
if (!isAllowedSender(event.senderFrame)) return;
// ...
})Defensive Payload Mapping
Never spread an unknown IPC payload:
// ❌
store.update({ ...payload });
// ✓ Explicitly map known fields
store.update({
volume: payload.volume,
track: payload.track,
});Zod Validation
All incoming payloads (IPC and WS) go through Zod safeParse before processing. An invalid payload returns an error without crashing the process.
Shared Types (app/src/shared/)
IPC types are defined in app/src/shared/ipc.ts and importable from main, preload, and renderer:
import type { FeatureUpdateEvent, FeatureCommandPayload } from "@shared/ipc";app/src/shared/ has no runtime dependencies - TypeScript only.
