diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-17 12:08:26 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-17 12:08:26 +0200 |
| commit | 662adfe717203fbe5ea8218a190bc39aff897d6e (patch) | |
| tree | e38fc72800e6c6319eaf404985fb5fb3a5b684f2 /docs/superpowers | |
| parent | 1e238ba8c82e306f99e8bd97b05830c3a912fbd3 (diff) | |
| download | quickshell-662adfe717203fbe5ea8218a190bc39aff897d6e.tar.gz quickshell-662adfe717203fbe5ea8218a190bc39aff897d6e.zip | |
docs: OBS pill implementation plan
Six tasks: logic helpers with their check, the openssl auth helper, the
Obs singleton, the pill, island wiring with the sliver pulse, docs.
Records two deviations from the spec found while planning: QML's
WebSocket hides the close code, so a wrong password is logged as
'closed before Identified'; responses route on the echoed requestType.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-10-17-obs-pill.md | 528 |
1 files changed, 528 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-17-obs-pill.md b/docs/superpowers/plans/2026-10-17-obs-pill.md new file mode 100644 index 0000000..1c81d95 --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-obs-pill.md @@ -0,0 +1,528 @@ +# OBS Pill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** An island pill that exists while OBS runs and controls recording, pause, scene and webcam visibility, with a pulsing sliver while recording. + +**Architecture:** `Obs.qml` singleton speaks obs-websocket v5 over QML `WebSocket` (QtWebSockets), reading the password from OBS's own config; the SHA256 auth string comes from `obs-auth.sh` (openssl). `ObsPill.qml` renders state and calls the singleton's actions. `Island.qml` places the pill right of the status pill and draws the pulse over the sliver. + +**Tech Stack:** Quickshell 0.3.1 QML, QtWebSockets, openssl, node (for `test_logic.js`). + +Spec: `docs/superpowers/specs/2026-10-17-obs-pill-design.md`. + +**Spec deviations, found while planning:** +- QML `WebSocket` exposes no close code, so an auth failure (4009) cannot be told apart from OBS closing. The singleton logs "closed before Identified" once per run of failures instead. +- Responses route on `requestType`, which obs-websocket echoes in every response; no prefixed `requestId` is needed. + +## Files + +- Create `desktop/island/obs-auth.sh`: stdin `password+salt` line, `challenge` line; stdout the auth string. +- Create `desktop/island/Obs.qml`: singleton, protocol and state. +- Create `desktop/island/ObsPill.qml`: the pill. +- Modify `desktop/island/Logic.js`, `desktop/island/test_logic.js`: `obsElapsed`, `obsStep`. +- Modify `desktop/island/Island.qml`: hover, mask, placement, sliver pulse. +- Modify `desktop/island/README.md`, `AGENTS.md`: docs. + +## Constraints every task must respect + +- GPLv2 header on every new file (copy from `privacy.sh` / `Privacy.qml`). +- Glyphs render with `Theme.iconFamily`. Checked present in Inconsolata Nerd Font: `\uf111` fa-circle, `\uf04c` fa-pause, `\uf04b` fa-play, `\u{F0567}` md-video, `\u{F0568}` md-video_off. +- Never run `qs` detached or `reload.sh` from a tool call (AGENTS.md). New files in `island/` need `./reload.sh desktop` from the user's terminal to be indexed. +- Process name is `qs`; never `pkill -f`. + +--- + +### Task 1: Logic helpers + +**Files:** Modify `desktop/island/Logic.js`, `desktop/island/test_logic.js` + +- [ ] **Step 1: Failing tests.** In `test_logic.js`, extend the return list to `{ volumeIcon, vmRole, flagFor, privacyRoles, parseOpenWindow, obsElapsed, obsStep }` and add before `console.log("ok")`: + +```js +// OBS elapsed: mm:ss under an hour, h:mm:ss past it. +assert.strictEqual(L.obsElapsed(0), "00:00"); +assert.strictEqual(L.obsElapsed(61000), "01:01"); +assert.strictEqual(L.obsElapsed(3599999), "59:59"); +assert.strictEqual(L.obsElapsed(3661000), "1:01:01"); +assert.strictEqual(L.obsElapsed(undefined), "00:00"); + +// OBS scene step: wraps both ways; an unknown current scene starts at the first. +assert.strictEqual(L.obsStep(["a", "b", "c"], "a", 1), "b"); +assert.strictEqual(L.obsStep(["a", "b", "c"], "c", 1), "a"); +assert.strictEqual(L.obsStep(["a", "b", "c"], "a", -1), "c"); +assert.strictEqual(L.obsStep(["a", "b", "c"], "x", 1), "a"); +assert.strictEqual(L.obsStep([], "x", 1), ""); +``` + +- [ ] **Step 2:** `node desktop/island/test_logic.js` → FAIL, `obsElapsed is not defined`. + +- [ ] **Step 3: Implement** at the end of `Logic.js`: + +```js +// OBS's outputDuration (ms) as mm:ss, or h:mm:ss past an hour. +function obsElapsed(ms) { + const s = Math.floor((ms > 0 ? ms : 0) / 1000); + const two = n => String(n).padStart(2, "0"); + const h = Math.floor(s / 3600); + const mmss = `${two(Math.floor(s / 60) % 60)}:${two(s % 60)}`; + return h ? `${h}:${mmss}` : mmss; +} + +// The scene d steps (+1 next, -1 previous) from cur, wrapping. A current +// scene missing from the list starts at the first. +function obsStep(list, cur, d) { + if (!list.length) return ""; + const i = list.indexOf(cur); + if (i < 0) return list[0]; + return list[(i + d + list.length) % list.length]; +} +``` + +- [ ] **Step 4:** `node desktop/island/test_logic.js` → `ok`. + +- [ ] **Step 5: Commit** `island: elapsed and scene-step helpers for the OBS pill`. + +### Task 2: Auth helper + +**Files:** Create `desktop/island/obs-auth.sh` (mode 755) + +- [ ] **Step 1: Write it** (GPL header as in `privacy.sh`, then): + +```sh +# obs-websocket v5 authentication, which QML cannot compute: it has no +# SHA256. stdin is two lines, password+salt then challenge, so neither +# reaches a command line. stdout is +# base64(sha256(base64(sha256(password + salt)) + challenge)) + +read -r ps || exit 1 +read -r ch || exit 1 +secret=$(printf %s "$ps" | openssl dgst -sha256 -binary | base64) +printf %s "$secret$ch" | openssl dgst -sha256 -binary | base64 +``` + +- [ ] **Step 2: Check against Python** (one-off, not committed): + +```sh +printf 'pwSALT\nCHAL\n' | sh desktop/island/obs-auth.sh +/usr/bin/python3 -c "import hashlib,base64;s=base64.b64encode(hashlib.sha256(b'pwSALT').digest());print(base64.b64encode(hashlib.sha256(s+b'CHAL').digest()).decode())" +``` +Expected: identical lines. + +- [ ] **Step 3: Commit** `island: obs-websocket auth helper`. + +### Task 3: `Obs.qml` singleton + +**Files:** Create `desktop/island/Obs.qml` + +- [ ] **Step 1: Write it** (GPL header, then): + +```qml +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import QtWebSockets + +// OBS Studio over obs-websocket v5. Connected means OBS is running with +// its server enabled, so nothing checks for the process. State follows +// OBS's events; actions only send requests and never set state +// optimistically. +Singleton { + id: root + + property bool connected: false + property bool recording: false + property bool paused: false + property real duration: 0 + property var scenes: [] + property string scene: "" + // The webcam: the first v4l2_input item in the current scene, -1 for none. + // ponytail: a webcam inside a group or nested scene is not found; walk + // GetGroupSceneItemList if one ever is. + property int camId: -1 + property bool camOn: false + + function toggleRecord() { request("ToggleRecord"); } + function togglePause() { request("ToggleRecordPause"); } + function setScene(name) { if (name) request("SetCurrentProgramScene", { sceneName: name }); } + function toggleCam() { + if (camId >= 0) + request("SetSceneItemEnabled", { sceneName: scene, sceneItemId: camId, sceneItemEnabled: !camOn }); + } + + // --- config ----------------------------------------------------------- + + // OBS's own file, so no copy of the password exists anywhere else. + FileView { + id: cfg + path: `${Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config"}/obs-studio/plugin_config/obs-websocket/config.json` + watchChanges: true + onFileChanged: reload() + } + + function config() { + try { return JSON.parse(cfg.text()); } catch (e) { return null; } + } + + // --- socket ----------------------------------------------------------- + + property bool warned: false + property bool wasOpen: false + + WebSocket { + id: sock + onStatusChanged: { + if (status === WebSocket.Open) { root.wasOpen = true; return; } + if (status === WebSocket.Connecting) return; + // QML's WebSocket hides the close code, so a wrong password + // (4009) and OBS quitting look the same. Say it once per run. + if (root.wasOpen && !root.connected && !root.warned) { + console.warn("obs: closed before Identified, wrong password?"); + root.warned = true; + } + root.reset(); + } + onTextMessageReceived: message => { + try { root.receive(JSON.parse(message)); } + catch (e) { console.warn(`obs: bad message: ${e}`); } + } + } + + function reset() { + connected = false; wasOpen = false; + recording = false; paused = false; duration = 0; + scenes = []; scene = ""; camId = -1; camOn = false; + } + + // Retry while not connected; a closed or failed socket is reopened by + // toggling active. + Timer { + interval: 3000 + repeat: true + running: !root.connected + triggeredOnStart: true + onTriggered: { + if (sock.status === WebSocket.Open || sock.status === WebSocket.Connecting) return; + const c = root.config(); + if (!c || !c.server_enabled) return; + sock.active = false; + sock.url = `ws://127.0.0.1:${c.server_port}`; + sock.active = true; + } + } + + // --- protocol ----------------------------------------------------------- + + property int seq: 0 + + function send(op, d) { sock.sendTextMessage(JSON.stringify({ op: op, d: d })); } + + function request(type, data) { + if (!connected) return; + send(6, { requestType: type, requestId: String(++seq), requestData: data || {} }); + } + + function identify(token) { + const d = { rpcVersion: 1, eventSubscriptions: 4 | 64 | 128 }; // Scenes | Outputs | SceneItems + if (token) d.authentication = token; + send(1, d); + } + + Process { + id: auth + command: ["sh", `${Quickshell.shellDir}/island/obs-auth.sh`] + stdinEnabled: true + property string data: "" + onStarted: { auth.write(auth.data); auth.stdinEnabled = false; } + stdout: StdioCollector { onStreamFinished: root.identify(text.trim()) } + } + + function receive(m) { + switch (m.op) { + case 0: { // Hello + const a = m.d.authentication; + if (!a) { identify(""); break; } + const c = config(); + auth.data = `${c ? c.server_password : ""}${a.salt}\n${a.challenge}\n`; + auth.stdinEnabled = true; + auth.running = false; + auth.running = true; + break; + } + case 2: // Identified + connected = true; + warned = false; + request("GetRecordStatus"); + request("GetSceneList"); + break; + case 5: + event(m.d.eventType, m.d.eventData || {}); + break; + case 7: + response(m.d); + break; + } + } + + function event(type, d) { + switch (type) { + case "RecordStateChanged": + recording = d.outputActive; + paused = d.outputState === "OBS_WEBSOCKET_OUTPUT_PAUSED"; + if (!recording) duration = 0; + break; + case "CurrentProgramSceneChanged": + scene = d.sceneName; + request("GetSceneItemList", { sceneName: scene }); + break; + case "SceneListChanged": + case "SceneItemCreated": + case "SceneItemRemoved": + request("GetSceneList"); + break; + case "SceneItemEnableStateChanged": + if (d.sceneName === scene && d.sceneItemId === camId) camOn = d.sceneItemEnabled; + break; + } + } + + function response(d) { + if (!d.requestStatus.result) { + console.warn(`obs: ${d.requestType} failed: ${d.requestStatus.comment ?? d.requestStatus.code}`); + return; + } + const r = d.responseData || {}; + switch (d.requestType) { + case "GetRecordStatus": + recording = r.outputActive; + paused = r.outputPaused; + duration = r.outputDuration; + break; + case "GetSceneList": + // obs-websocket lists scenes bottom-up; reversed is OBS's own order. + scenes = r.scenes.slice().reverse().map(s => s.sceneName); + scene = r.currentProgramSceneName; + request("GetSceneItemList", { sceneName: scene }); + break; + case "GetSceneItemList": { + const it = r.sceneItems.find(i => i.inputKind === "v4l2_input"); + camId = it ? it.sceneItemId : -1; + camOn = !!it && it.sceneItemEnabled; + break; + } + } + } + + // OBS owns the clock, so pausing freezes it with no local bookkeeping. + // ponytail: one local request a second, only while recording. + Timer { + interval: 1000 + repeat: true + running: root.recording && !root.paused + onTriggered: root.request("GetRecordStatus") + } +} +``` + +- [ ] **Step 2: Commit** `island: Obs singleton, obs-websocket v5 client`. Verified together with the pill in Task 5. + +### Task 4: `ObsPill.qml` + +**Files:** Create `desktop/island/ObsPill.qml` + +- [ ] **Step 1: Write it** (GPL header, then): + +```qml +import QtQuick +import ".." +import "Logic.js" as Logic + +// Record and elapsed, pause, scene, webcam. Each part hides when it has +// nothing to do: pause while not recording, the scene with fewer than two, +// the webcam when the scene has none. +Capsule { + id: pill + + restWidth: row.implicitWidth + 24 + + Row { + id: row + anchors.centerIn: parent + spacing: 12 + + // Dot and time share one click target. + Item { + anchors.verticalCenter: parent.verticalCenter + implicitWidth: rec.implicitWidth + implicitHeight: rec.implicitHeight + Row { + id: rec + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + text: "\uf111" + color: Obs.recording ? Theme.red : Theme.subtext + opacity: Obs.paused ? 0.5 : 1 + font { family: Theme.iconFamily; pixelSize: 14 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: Obs.recording + text: Logic.obsElapsed(Obs.duration) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Obs.toggleRecord() + } + } + + Text { + visible: Obs.recording + anchors.verticalCenter: parent.verticalCenter + text: Obs.paused ? "\uf04b" : "\uf04c" + color: Theme.text + font { family: Theme.iconFamily; pixelSize: 14 } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Obs.togglePause() + } + } + + Text { + visible: Obs.scenes.length > 1 + anchors.verticalCenter: parent.verticalCenter + text: Obs.scene + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + // One step per wheel notch, as the status pill's layout flag. + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + property real acc: 0 + onClicked: Obs.setScene(Logic.obsStep(Obs.scenes, Obs.scene, 1)) + onWheel: wheel => { + acc += wheel.angleDelta.y; + if (Math.abs(acc) >= 120) { + Obs.setScene(Logic.obsStep(Obs.scenes, Obs.scene, acc > 0 ? -1 : 1)); + acc = 0; + } + wheel.accepted = true; + } + } + } + + Text { + visible: Obs.camId >= 0 + anchors.verticalCenter: parent.verticalCenter + text: Obs.camOn ? "\u{F0567}" : "\u{F0568}" + color: Obs.camOn ? Theme.text : Theme.overlay + font { family: Theme.iconFamily; pixelSize: 16 } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Obs.toggleCam() + } + } + } +} +``` + +- [ ] **Step 2: Commit** `island: OBS pill`. + +### Task 5: Wire into the island + +**Files:** Modify `desktop/island/Island.qml` + +- [ ] **Step 1: Hover.** Line `readonly property bool hovered: ...` gains `|| obs.hovered`. + +- [ ] **Step 2: Mask.** In `mask: Region { ... }` add + `Region { item: obs.visible ? obs : null; intersection: Intersection.Combine }`. + +- [ ] **Step 3: Pill**, after `MediaPill`, mirroring it on the right: + +```qml + // While OBS runs; right of the status pill, following it as the sound + // panel widens it. + ObsPill { + id: obs + visible: Obs.connected + x: win.cx - width / 2 + (win.gap * 1.5 + status.width + width / 2) * win.reveal + y: -height + (win.top + height) * win.reveal + opacity: Math.min(1, win.reveal * 2) + } +``` + +- [ ] **Step 4: Sliver pulse**, right after the `sliver` Row: + +```qml + // Recording: a red pulse over the sliver, steady and dim while paused. + // Red alone would not say it: OBS's screen source already lights the + // privacy sliver's red segment whenever OBS is open. + Rectangle { + x: sliver.x + y: 0 + width: sliver.width + height: sliver.height + radius: 2 + color: Theme.red + visible: Obs.recording + property real pulse: 1 + opacity: Obs.paused ? 0.4 : pulse + SequentialAnimation on pulse { + running: Obs.recording && !Obs.paused + loops: Animation.Infinite + NumberAnimation { to: 0.25; duration: 700; easing.type: Easing.InOutSine } + NumberAnimation { to: 1; duration: 700; easing.type: Easing.InOutSine } + } + } +``` + +- [ ] **Step 5: Smoke.** Ask the user to run `./reload.sh desktop` (new files in `island/` need the reindex). Then `qs log -p desktop --log-times | tail -40`: expect no `ReferenceError`, no `QtWebSockets` import error. + +- [ ] **Step 6: Live check with OBS** (user starts OBS; the server is enabled). Ask the user to confirm by eye: + 1. Pill appears within ~3s of OBS starting, disappears on quit. + 2. Click record: dot turns red, timer counts, sliver pulses. + 3. Pause: timer freezes, sliver steady dim. Resume continues. + 4. Scene: click steps, wheel steps both ways, OBS follows; changing scene in OBS updates the pill. + 5. Webcam icon toggles the source in OBS; toggling the eye in OBS updates the icon. + 6. Stop: pill back to idle, pulse gone. + + Agent side: `qs log -p desktop | grep obs:` shows no warnings during the above. + +- [ ] **Step 7: Commit** `island: place the OBS pill and pulse the sliver while recording`. + +### Task 6: Docs + +**Files:** `desktop/island/README.md`, `AGENTS.md` + +- [ ] **Step 1: README**, new section after "Media pill": + +```markdown +## OBS pill + +Right of the status pill, while OBS Studio runs with its WebSocket server +enabled (Tools > WebSocket Server Settings; auth stays on, the server +listens on every interface). `Obs.qml` speaks obs-websocket v5 directly +and reads the password from OBS's own `obs-websocket/config.json`, so no +copy of it exists; `obs-auth.sh` computes the SHA256 handshake QML cannot. + +Record dot and elapsed time (click starts or stops), pause (while +recording), the current scene (click or wheel steps, wrapping; only with +two or more scenes), and the webcam (the first `v4l2_input` item in the +current scene; click shows or hides it). While recording the sliver +pulses red, and holds dim while paused. + +QML's `WebSocket` does not expose the close code, so a wrong password +reads the same as OBS quitting; it is logged once as "closed before +Identified". +``` + +- [ ] **Step 2: AGENTS.md** line 19: `island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy, calendar` → append `, OBS`. + +- [ ] **Step 3: Commit** `docs: the OBS pill`. |
