aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 12:04:15 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 12:04:15 +0200
commit1e238ba8c82e306f99e8bd97b05830c3a912fbd3 (patch)
tree724d7adf2bd44011926a6a064015fb0274dcae2a /docs/superpowers/specs
parent3b333c9537f9c018fe44084572931c58d75bd4f8 (diff)
downloadquickshell-1e238ba8c82e306f99e8bd97b05830c3a912fbd3.tar.gz
quickshell-1e238ba8c82e306f99e8bd97b05830c3a912fbd3.zip
docs: OBS pill design
A pill on the island that exists while OBS runs: record, pause, scene, webcam visibility, and a pulsing sliver while recording. Talks to obs-websocket directly from QML (QtWebSockets is installed), reading the password from OBS's own config so no copy exists. Auth stays on because obs-websocket listens on every interface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-10-17-obs-pill-design.md125
1 files changed, 125 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-10-17-obs-pill-design.md b/docs/superpowers/specs/2026-10-17-obs-pill-design.md
new file mode 100644
index 0000000..3331bba
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-17-obs-pill-design.md
@@ -0,0 +1,125 @@
+# OBS pill
+
+A pill on the island for screen recording with OBS Studio. It exists only
+while OBS is running and controls the running instance: start and stop
+recording, pause and resume, switch scene, show or hide the webcam. The
+always-visible sliver shows recording state.
+
+## Out of scope
+
+- Mic mute: the user has a physical button for it.
+- Camera hardware settings (zoom, focus, brightness through `v4l2-ctl`).
+- Replay buffer, streaming, virtual camera.
+
+## Transport
+
+obs-websocket (protocol v5), built into OBS 28+, spoken directly from QML
+with `QtWebSockets`' `WebSocket`, which is installed. Events are pushed, so
+nothing polls OBS except the elapsed time while recording (below).
+
+Rejected: a Python bridge (the stdlib has no websocket client, so either a
+hand-rolled framing layer or a new dependency) and `obs-cmd` (a new
+dependency, polled, laggy state).
+
+### One-time setup
+
+Enable the server in OBS: Tools > WebSocket Server Settings. Authentication
+stays on: obs-websocket listens on every interface and has no localhost-only
+option, so turning it off would expose recording control to the LAN.
+
+### Password
+
+Read from OBS's own `~/.config/obs-studio/plugin_config/obs-websocket/config.json`
+(`server_password`, `server_port`) with a `FileView`. No copy is stored
+anywhere, and a password changed in OBS is picked up on the next connect.
+
+### Handshake
+
+1. Server sends `Hello` (op 0). With auth it carries `challenge` and `salt`.
+2. Auth string: `secret = base64(sha256(password + salt))`,
+ `auth = base64(sha256(secret + challenge))`. QML has no SHA256, so one
+ `Process` per connect runs `openssl dgst -sha256 -binary | base64` twice,
+ with the inputs on stdin, never on the command line.
+3. Client sends `Identify` (op 1) with `rpcVersion: 1`, `authentication`, and
+ `eventSubscriptions` = Scenes (4) | Outputs (64) | SceneItems (128) = 196.
+4. `Identified` (op 2): connected. Request the initial state.
+
+## `Obs.qml` singleton (`desktop/island/`)
+
+State:
+
+- `connected`: true after `Identified`. This doubles as "OBS is running":
+ no process check.
+- `recording`, `paused`
+- `duration`: milliseconds, from `GetRecordStatus.outputDuration`
+- `scenes`: names, in OBS order; `scene`: current program scene
+- `camId`, `camOn`: the scene item in the current scene whose `inputKind` is
+ `v4l2_input`, found automatically; `camId` is -1 when the scene has none
+
+Requests sent:
+
+| Action | Request |
+| --- | --- |
+| initial / on change | `GetRecordStatus`, `GetSceneList`, `GetSceneItemList {sceneName}` |
+| start/stop | `ToggleRecord` |
+| pause/resume | `ToggleRecordPause` |
+| scene | `SetCurrentProgramScene {sceneName}` |
+| webcam | `SetSceneItemEnabled {sceneName, sceneItemId, sceneItemEnabled}` |
+
+Events handled: `RecordStateChanged` (sets `recording`/`paused` from
+`outputState`), `CurrentProgramSceneChanged` and `SceneListChanged` (re-read
+scenes and the webcam item), `SceneItemEnableStateChanged` (updates `camOn`
+when the id matches).
+
+Responses are matched by `requestId`; the request type prefixes the id, so a
+plain switch on it routes the reply.
+
+Elapsed time: `GetRecordStatus` every 1s while `recording`. OBS owns the
+clock, so pause freezes it with no local bookkeeping. ponytail: one local
+request per second, only while recording.
+
+Reconnect: a `Timer` every 3s while not connected sets `active` on the
+`WebSocket`. A closed or errored socket clears all state, which hides the
+pill. An auth failure (close code 4009) logs once and keeps retrying, since a
+password change in OBS fixes it without a restart.
+
+## The pill (`ObsPill.qml`)
+
+Right of the status pill, slides with the others, exists only while
+`Obs.connected`.
+
+Left to right:
+
+- **Record**: idle icon, or a red dot and `mm:ss` (`h:mm:ss` past an hour)
+ while recording. Click toggles recording.
+- **Pause**: only while recording. Click toggles pause; the dot dims while
+ paused.
+- **Scene**: current scene name. Click steps to the next, wheel steps
+ previous (up) and next (down), wrapping. Hidden with fewer than two scenes.
+- **Webcam**: camera icon, struck while hidden. Click toggles. Absent when
+ `camId` is -1.
+
+The pill names its own panels (AGENTS.md): it opens none, so it never
+expands.
+
+## Sliver
+
+While recording, the sliver pulses red (opacity animation), drawn over the
+privacy segments; paused, it holds steady at reduced opacity. Red is
+already "screen capture" in the privacy colours, and OBS's screen source
+likely lights that segment whenever OBS is open, so the pulse, not the
+colour, is what says "recording". Not recording: the sliver is unchanged.
+
+## Pure logic and its check
+
+In `Logic.js`, covered by `test_logic.js`:
+
+- `obsElapsed(ms)`: `0` -> `00:00`, `61000` -> `01:01`, `3661000` -> `1:01:01`
+- `obsStep(list, cur, d)`: next/previous scene with wrap, `cur` missing -> first
+
+## Errors
+
+- Server disabled or OBS closed: connect fails, pill absent, retry loop.
+- Auth failure: logged, retry loop.
+- A failed request (`requestStatus.result` false): logged with its comment;
+ state is not changed optimistically, it follows OBS's events.