aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-10-17-obs-pill-design.md
blob: 3331bbac6edd926b60be15d8089c4d15711d0920 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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.