aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 12:08:26 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 12:08:26 +0200
commit662adfe717203fbe5ea8218a190bc39aff897d6e (patch)
treee38fc72800e6c6319eaf404985fb5fb3a5b684f2
parent1e238ba8c82e306f99e8bd97b05830c3a912fbd3 (diff)
downloadquickshell-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>
-rw-r--r--docs/superpowers/plans/2026-10-17-obs-pill.md528
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`.