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
126
127
|
# 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`.
**At rest**: a record dot, grey when idle, red while recording, dimmed while
paused, followed by `mm:ss` (`h:mm:ss` past an hour) while recording. A
click opens the panel.
**Panel** (`win.panel === "obs"`), morphing the capsule as the status pill
does for sound; Escape and an outside click close it like the others:
- A status line: `Not recording`, `Recording 03:21` or `Paused 03:21`.
- Buttons (`desktop/Button.qml`): Record/Stop, Pause/Resume (while
recording), Show/Hide webcam (absent when `camId` is -1).
- The scene list in OBS's order, current one highlighted, click to switch.
A plain column; about ten scenes fit the island's 480px window.
The pill names its own panel, `obs` (AGENTS.md). OBS quitting with the panel
open clears `win.panel`, or the island would stay held open by a pill that
no longer exists.
## 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`
## 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.
|