diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-11-09-deck-it.md | 718 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-11-09-deck-it-design.md | 133 |
2 files changed, 851 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-11-09-deck-it.md b/docs/superpowers/plans/2026-11-09-deck-it.md new file mode 100644 index 0000000..5f70bae --- /dev/null +++ b/docs/superpowers/plans/2026-11-09-deck-it.md @@ -0,0 +1,718 @@ +# deck-it 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:** A modular tablet dashboard, stream-deck, and second monitor for a Slackware-current Hyprland desktop, UDT-themed, reusing tabdeck's plumbing. + +**Architecture:** A Python stdlib HTTP server serves a web page over `adb reverse`; the page is a shell that mounts independent module panels. Backend modules register a `Module` (state + allow-listed actions). A separate half creates a headless Hyprland output streamed by wayvnc to AVNC. Theme comes from UDT's palette files, never restated. + +**Tech Stack:** Python 3.11+ stdlib (one dep: `websocket-client` for OBS), plain HTML/CSS/JS, bash launcher, wayvnc, adb. + +**Spec:** `docs/superpowers/specs/2026-11-09-deck-it-design.md` + +## Global Constraints + +- Python 3.11+; stdlib only except `websocket-client`. +- No systemd. SysV init + eudev. +- Server listens only on a Unix socket in `$XDG_RUNTIME_DIR/deck-it` (mode 0700), reached via `adb reverse tcp:8765 localfilesystem:$RUN/http.sock`. +- Client sends module + action ids, never a command string. +- UDT palette is read, never restated; a missing palette falls back. +- Commits are conventional, GPG-signed (`git commit -S`), one per task. +- Reused tabdeck code is MIT; keep its notice in `NOTICE`. + +## Review Focus + +- **OBS connection drops mid-action** → the HTTP request must return an error, never hang. +- **llm-cost-dashboard is down** → the llm panel shows "offline", not a 500. +- **Oversized or malformed POST body** → rejected without crashing the server. +- **Two taps race** (double record toggle) → serialized, no duplicate start. +- **Palette file missing** (before UDT install) → built-in fallback, page still renders. + +--- + +## Phase 0 — Core + +### Task 1: Scaffold and config + +**Files:** +- Create: `deckit/__init__.py`, `deckit/config.py`, `config.toml`, `tests/test_config.py` +- Create: `README.md`, `LICENSE`, `NOTICE`, `AGENTS.md`, `.gitignore`, `git init` + +**Interfaces:** +- Produces: `deckit.config.user_config_path() -> Path`, `load_config() -> dict`, `config_error(cfg) -> str | None`. + +- [ ] **Step 1: Scaffold the repo** + +Run `git init -b master`, create the directories. License: GPLv2 (confirm with maintainer; MIT tabdeck portions are compatible). `NOTICE` credits tabdeck (MIT) and its bundled fonts/icons. `AGENTS.md` stays thin and points at this plan and the spec. + +- [ ] **Step 2: Write the failing test** + +```python +# tests/test_config.py +import sys, tempfile, tomllib +from pathlib import Path +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +import unittest +import deckit.config as config + +class Config(unittest.TestCase): + def test_default_config_is_valid(self): + with open(ROOT / "config.toml", "rb") as f: + self.assertIsNone(config.config_error(tomllib.load(f))) + + def test_rejects_bad_values(self): + bad = [{"display": {"name": "TAB;rm"}}, + {"display": {"refresh": 0}}, + {"ports": {"command_center": 80}}, + {"ports": {"command_center": 5900, "display": 5900}}] + for cfg in bad: + self.assertIsNotNone(config.config_error(cfg), cfg) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 3: Run it, expect failure** + +Run: `python3 tests/test_config.py -v` +Expected: FAIL (no module `deckit.config`). + +- [ ] **Step 4: Implement config** + +`config.toml` carries: `[hyprland]` (`"auto"`), `[display]` (`resolution/position/scale/refresh/name`), `[ports]` (`command_center=8765`, `display=5900`), `[hardware]` (`net/battery/cpu_sensor/gpu`), `[modules]` (per-module bools, default true), `[obs]` (`host/port/password`), `[llm]` (`url`), `[sbbuilder]` (`host`). + +`config_error(cfg)` mirrors tabdeck: validate `display.name` (`[A-Za-z0-9_-]{1,32}`), `display.refresh` int 1–240, ports int 1024–65535 and differing. `user_config_path()` returns `$XDG_CONFIG_HOME/deck-it/config.toml`; `load_config()` uses it if present else the repo `config.toml`, exiting on `config_error`. + +- [ ] **Step 5: Run the test, expect pass** + +Run: `python3 tests/test_config.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** — `git add -A && git commit -S -m "feat: scaffold and config"` + +### Task 2: UDT theming + +**Files:** +- Create: `deckit/theme.py`, `tests/test_theme.py`, `tests/fixtures/palette.rasi`, `tests/fixtures/accent.rasi` + +**Interfaces:** +- Produces: `parse_palette(text) -> dict[str, str]`, `parse_accent(text) -> str | None`, `css(colors: dict, accent: str | None) -> str`, `load() -> str` (reads the two live paths, falling back). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_theme.py (same sys.path preamble as Task 1) +import unittest +import deckit.theme as theme + +PALETTE = """* { + base: #24273aff; + text: #cad3f5ff; + surface0: #363a4fff; +}""" +ACCENT = "* { accent: #b7bdf8; }" + +class Theme(unittest.TestCase): + def test_parses_palette(self): + self.assertEqual(theme.parse_palette(PALETTE), + {"base": "#24273aff", "text": "#cad3f5ff", "surface0": "#363a4fff"}) + + def test_parses_accent(self): + self.assertEqual(theme.parse_accent(ACCENT), "#b7bdf8") + self.assertIsNone(theme.parse_accent("nothing")) + + def test_css_exposes_vars_and_falls_back(self): + out = theme.css({"base": "#24273a"}, None) + self.assertIn("--udt-base: #24273a", out) + self.assertIn("--udt-accent:", out) # fallback accent + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — `python3 tests/test_theme.py -v`; FAIL. + +- [ ] **Step 3: Implement theme** + +`parse_palette` matches `^\s*([a-z0-9]+)\s*:\s*(#[0-9a-fA-F]{6,8});`. `parse_accent` matches `accent:\s*(#[0-9a-fA-F]{6,8})`. `css(colors, accent)` emits `:root {\n --udt-<name>: <value>;\n ... --udt-accent: <accent or FALLBACK["accent"]>;\n}`. `FALLBACK` is the Macchiato set (base `#24273a`, mantle `#1e2030`, crust `#181926`, surface0 `#363a4f`, surface1 `#494d64`, surface2 `#5b6078`, text `#cad3f5`, subtext0 `#a5adcb`, subtext1 `#b8c0e0`, overlay0 `#6e738d`, red `#ed8796`, green `#a6da95`, yellow `#eed49f`, teal `#8bd5ca`, blue `#8aadf4`, lavender `#b7bdf8`, peach `#f5a97f`, mauve `#c6a0f6`, accent `#b7bdf8`). `load()` reads `~/.config/rofi/udt/palette.rasi` + `~/.cache/udt/udt-accent.rasi`, using `FALLBACK` on `OSError` or empty parse. + +- [ ] **Step 4: Run the test, expect pass** — `python3 tests/test_theme.py -v`; PASS. + +- [ ] **Step 5: Commit** — `git add deckit/theme.py tests/test_theme.py tests/fixtures && git commit -S -m "feat: UDT palette theming"` + +### Task 3: Module contract and registry + +**Files:** +- Create: `deckit/module.py`, `tests/test_module.py` + +**Interfaces:** +- Produces: `ModuleError`, `Module` (dataclass: `name`, `title`, `state: Callable[[], dict]`, `actions: dict[str, Callable[[dict], dict | None]]`, `start`/`stop` optional), `Registry(modules)` with `names() -> list[dict]`, `state(name) -> dict`, `run_action(name, action, body) -> dict | None`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_module.py +import unittest +import deckit.module as mod + +def make(): + return mod.Module(name="demo", title="Demo", + state=lambda: {"ok": True}, + actions={"ping": lambda body: {"got": body.get("n")}}) + +class RegistryTest(unittest.TestCase): + def test_lists_and_dispatches(self): + r = mod.Registry([make()]) + self.assertEqual(r.names(), [{"name": "demo", "title": "Demo"}]) + self.assertEqual(r.state("demo"), {"ok": True}) + self.assertEqual(r.run_action("demo", "ping", {"n": 3}), {"got": 3}) + + def test_unknown_module_and_action_raise(self): + r = mod.Registry([make()]) + with self.assertRaises(mod.ModuleError): + r.state("nope") + with self.assertRaises(mod.ModuleError): + r.run_action("demo", "nope", {}) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement the contract** + +`Module` is a `@dataclass`. `Registry` stores `{m.name: m}`; `state`/`run_action` raise `ModuleError` on unknown names/actions; `run_action` calls the verb with the JSON body (a dict) and returns its result. + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: module contract and registry"` + +### Task 4: HTTP server, auth, routing + +**Files:** +- Create: `deckit/server.py`, `deckit/__main__.py`, `tests/test_server.py` + +**Interfaces:** +- Consumes: `Registry` (Task 3), `theme.load()` (Task 2). +- Produces: `serve(listen: tuple[str, int] | None = None) -> None`; module-global `TOKEN`; routes `GET /`, `GET /theme.css`, `GET /api/modules`, `GET /api/<name>`, `POST /api/<name>/<action>`, `GET /static/...`. + +- [ ] **Step 1: Write the failing test** (adapted from tabdeck's `Http` class) + +```python +# tests/test_server.py +import json, os, socket, tempfile, threading, unittest +from pathlib import Path +import http.client +import deckit.server as srv +import deckit.module as mod + +TOKEN = "test-token" +HOST = {"Host": "127.0.0.1:8765", "Cookie": f"deckit={TOKEN}"} +ORIGIN = {"Origin": "http://127.0.0.1:8765"} + +class UnixConnection(http.client.HTTPConnection): + def __init__(self, path): + super().__init__("localhost"); self.path = path + def connect(self): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(self.path) + +class Server(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.dir = tempfile.TemporaryDirectory() + cls.sock = os.path.join(cls.dir.name, "http.sock") + srv.TOKEN = TOKEN + srv.REGISTRY = mod.Registry([mod.Module("demo", "Demo", lambda: {"ok": True})]) + cls.server = srv.UnixHTTPServer(cls.sock, srv.Handler) + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + @classmethod + def tearDownClass(cls): + cls.server.shutdown(); cls.server.server_close(); cls.dir.cleanup() + + def req(self, method, path, headers=None, body=None): + c = UnixConnection(self.sock); c.request(method, path, body=body, headers=headers or {}) + r = c.getresponse(); data = r.read(); c.close(); return r.status, data + + def test_assets_and_api(self): + self.assertEqual(self.req("GET", "/", HOST)[0], 200) + self.assertEqual(self.req("GET", "/theme.css", HOST)[0], 200) + self.assertEqual(json.loads(self.req("GET", "/api/modules", HOST)[1]), + [{"name": "demo", "title": "Demo"}]) + self.assertEqual(json.loads(self.req("GET", "/api/demo", HOST)[1]), {"ok": True}) + + def test_trust_and_auth(self): + self.assertEqual(self.req("GET", "/api/demo", {"Host": "evil.com"})[0], 403) + self.assertEqual(self.req("POST", "/api/demo/x", {**HOST, "Origin": "https://evil.com"})[0], 403) + self.assertEqual(self.req("GET", "/api/demo", {"Host": "127.0.0.1:8765"})[0], 401) + + def test_unlock_and_bad_action(self): + c = UnixConnection(self.sock); c.request("GET", f"/?t={TOKEN}", headers={"Host": "127.0.0.1:8765"}) + r = c.getresponse(); r.read(); c.close() + self.assertEqual(r.status, 303) + self.assertIn("deckit=", r.getheader("Set-Cookie")) + self.assertIn("SameSite=Strict", r.getheader("Set-Cookie")) + self.assertEqual(self.req("POST", "/api/demo/nope", {**HOST, **ORIGIN})[0], 404) + + def test_rejects_oversized_body(self): + big = "x" * 5000 + status, _ = self.req("POST", "/api/demo/ping", + {**HOST, **ORIGIN, "Content-Type": "application/json"}, big) + self.assertEqual(status, 400) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement the server** + +Port tabdeck's `Handler`/`UnixHTTPServer`/`serve`, adding: `REGISTRY` (module-global, built in `serve`), `GET /api/modules -> REGISTRY.names()`, `GET /api/<name> -> REGISTRY.state(name)`, `POST /api/<name>/<action>` (JSON body ≤ 4096 bytes, `Content-Type: application/json`, else 400; `ModuleError` → 400). `GET /theme.css` serves `theme.load()`. Static files resolve under `static/` only. Auth/trust (Host/Origin), `?t=` unlock setting cookie `deckit` remain as tabdeck has them. `__main__.py` dispatches `serve`, `setup-display`, `env` (prints `MON= PORT= VNC_PORT=`). + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: HTTP server with trust, auth, module routing"` + +### Task 5: Frontend shell and theming + +**Files:** +- Create: `static/index.html`, `static/deck.css`, `static/app.js`, `tests/test_frontend.py` + +**Interfaces:** +- Consumes: `/theme.css`, `/api/modules`, `/api/<name>`. +- Produces: a page whose shell imports `./modules/<name>.js` per enabled module. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_frontend.py — served-page smoke test +# (run a server like Task 4; assert) +# GET / -> body contains 'app.js' and 'deck.css' +# GET /deck.css -> body contains '--udt-bg' usage +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement** + +`index.html`: viewport meta, link `theme.css` + `deck.css`, `<main id="grid">`, `<script type="module" src="app.js">`. `deck.css`: grid layout and shared card/button classes, all colours via `var(--udt-*)`. `app.js`: `fetch('/api/modules')`, then for each `await import('./modules/'+name+'.js')`, call `mount(el)` and poll `update(state)` at the module's `interval` (default 1000 ms). Refresh on `visibilitychange`. + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: frontend shell and UDT-themed layout"` + +### Task 6: Launcher, tunnel, Slackware autostart + +**Files:** +- Create: `deck-it` (bash), `tests/test_launcher.py` + +**Interfaces:** +- Consumes: `python3 -m deckit env`, `setup-display`. +- Produces: `deck-it` subcommands `start` (default), `stop`, `env`, `pair`, `wifi`, `install`, `uninstall`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_launcher.py +import subprocess, unittest +from pathlib import Path +ROOT = Path(__file__).resolve().parent.parent + +class Launcher(unittest.TestCase): + def test_syntax(self): + self.assertEqual(subprocess.run(["bash", "-n", str(ROOT / "deck-it")]).returncode, 0) + def test_env_prints_keys(self): + out = subprocess.run(["bash", str(ROOT / "deck-it"), "env"], + capture_output=True, text=True, cwd=ROOT).stdout + for key in ("MON=", "PORT=", "VNC_PORT="): + self.assertIn(key, out) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement the launcher** + +Adapt tabdeck's `tablet`: `RUN="$XDG_RUNTIME_DIR/deck-it"`, `CONF="${XDG_CONFIG_HOME:-$HOME/.config}/deck-it"`, `MON/PORT/VNC_PORT` from `python3 -m deckit env`; `tunnel()` uses `adb reverse tcp:5900 localfilesystem:$RUN/vnc.sock` and `tcp:8765 localfilesystem:$RUN/http.sock`; `vnc_setup()` and `open_avnc()` from tabdeck. `install` writes `/etc/udev/rules.d/70-deck-it.rules` with a non-blocking `RUN+="/bin/sh -c 'setsid <dir>/deck-it start >/dev/null 2>&1 &'"` and prints the `exec-once = <dir>/deck-it` line to add to hypr-theme. `uninstall` removes the rule. No systemd, no `systemctl`. + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: launcher, adb tunnel, Slackware autostart"` + +### Task 7: Second monitor + +**Files:** +- Create: `deckit/display.py`, `tests/test_display.py` + +**Interfaces:** +- Produces: `tablet_resolution(cfg, adb=None) -> str`, `setup_display(cfg, run_cmd=None) -> None`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_display.py +import unittest +import deckit.display as display + +class Res(unittest.TestCase): + def test_reads_adb(self): + self.assertEqual(display.tablet_resolution({"display": {}}, adb=lambda: "Physical size: 1920x1200"), + "1920x1200") + def test_config_override(self): + self.assertEqual(display.tablet_resolution({"display": {"resolution": "2560x1600"}}, + adb=lambda: "1920x1200"), "2560x1600") + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement** + +Port tabdeck's `tablet_resolution` (sorted `max x min`) and `setup_display` (create headless output if absent, then Lua `hl.monitor({...})` for the Lua config or `hyprctl keyword monitor` for classic). `adb`/`run_cmd` are injectable for tests. + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: virtual monitor setup"` + +### Task 8: stats module + +**Files:** +- Create: `deckit/modules/__init__.py`, `deckit/modules/stats.py`, `tests/test_stats.py` + +**Interfaces:** +- Produces: `stats.make(cfg) -> Module` (name `stats`); registry builder `build(cfg) -> Registry` in `deckit/modules/__init__.py` that lazily imports each `deckit.modules.<name>` present and enabled under `[modules]` (default on), skipping `ImportError`. Lazy discovery means a later phase just adds a file, and the assistant slot stays empty without touching the core. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_stats.py +import unittest +import deckit.modules.stats as stats + +class Stats(unittest.TestCase): + def test_state_shape(self): + st = stats.make({}).state() + for key in ("cpu", "memUsed", "memTotal", "net", "uptime", "host"): + self.assertIn(key, st) + self.assertIsInstance(st["cpu"], int) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement** + +Port tabdeck's `cpu_percent`, `mem`, `net_rates`, `gpu`, `detect_*` readers into `deckit/modules/stats.py`; `make(cfg)` closes over the `[hardware]` overrides. `deckit/modules/__init__.py`'s `build(cfg)` walks `Path(__file__).parent` for `*.py` (excluding `__init__`), `importlib.import_module`s each that `[modules]` does not disable, calls its `make(cfg)`, and returns a `Registry`. + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: stats module"` + +### Task 9: music module + +**Files:** +- Create: `deckit/modules/music.py`, `tests/test_music.py` + +**Interfaces:** +- Produces: `music.make(cfg) -> Module` (name `music`); state has `status/title/artist/album/length/position/art/player/volume`; actions `prev/play/next/voldown/volup/mute`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_music.py +import unittest +import deckit.modules.music as music + +class Music(unittest.TestCase): + def test_parses_metadata(self): + sep = "\x1f" + fake = lambda cmd, timeout=2: { + "metadata": sep.join(["Playing", "Song", "Artist", "Album", "200000000", "1000000", "", "spotify"]), + "get-volume": "Volume: 0.42", + }[cmd[1]] + m = music.make({"run": fake}) + st = m.state() + self.assertEqual(st["title"], "Song") + self.assertEqual(st["status"], "Playing") + self.assertEqual(st["volume"]["level"], 42) + def test_action_commands(self): + self.assertEqual(music.BASE_ACTIONS["next"], ["playerctl", "next"]) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement** + +Port tabdeck's `player()` (SEP split, art URL handling) and `BASE_ACTIONS` (wpctl/playerctl); inject `run` for tests. Register actions. + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: music module"` + +### Task 10: windows module + +**Files:** +- Create: `deckit/modules/windows.py`, `tests/test_windows.py` + +**Interfaces:** +- Produces: `windows.make(cfg) -> Module` (name `windows`); state `{windows, workspaces, activeClass}`; actions `workspace/<n>`, `focus/<addr>`, `close/<addr>`, `launch/<key>` from a `[launch]` table. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_windows.py +import unittest +import deckit.modules.windows as windows + +class Commands(unittest.TestCase): + def test_lua_and_classic(self): + self.assertEqual(windows.workspace_cmd(3, lua=False), ["hyprctl", "dispatch", "workspace", "3"]) + self.assertEqual(windows.workspace_cmd(3, lua=True)[:2], ["hyprctl", "eval"]) + def test_address_guard(self): + self.assertIsNone(windows.ADDR.fullmatch("0x1;rm")) + self.assertTrue(windows.ADDR.fullmatch("0x55897e5c0800")) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement** + +Port tabdeck's `workspace_cmd`, `window_cmd`, `windows()` (hyprctl clients/activewindow/monitors), and address regex. `[launch]` in config maps key id → command (string or list); actions run declared commands only. Client sends a key id, never a command. + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: windows module"` + +--- + +## Phase 1 — OBS stream-deck + +### Task 11: obs-websocket client + +**Files:** +- Create: `deckit/obsclient.py`, `tests/test_obsclient.py` + +**Interfaces:** +- Produces: `auth_string(password: str, salt: str, challenge: str) -> str`, `class ObsClient` with `connect()`, `close()`, `request(type: str, data: dict | None = None) -> dict` (`ModuleError` if disconnected or on error status), and an `events` callback setter. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_obsclient.py +import base64, hashlib, unittest +import deckit.obsclient as oc + +class Auth(unittest.TestCase): + def test_matches_protocol_formula(self): + pw, salt, challenge = "supersecret", "salty", "chall" + step1 = base64.b64encode(hashlib.sha256((pw + salt).encode()).digest()).decode() + expected = base64.b64encode(hashlib.sha256((step1 + challenge).encode()).digest()).decode() + self.assertEqual(oc.auth_string(pw, salt, challenge), expected) + self.assertNotEqual(oc.auth_string("other", salt, challenge), expected) + +class Disconnected(unittest.TestCase): + def test_request_raises_when_closed(self): + with self.assertRaises(oc.ModuleError): + oc.ObsClient(host="127.0.0.1", port=4455, password="").request("GetVersion") + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement the client** + +Use `websocket-client`. On connect, read `Hello`; if `authentication` present, send `Identify` with `authentication=auth_string(password, salt, challenge)` and `rpcVersion: 1`; wait for `Identified`. Run a receive loop in a daemon thread dispatching `Event` to the callback and resolving `RequestResponse` by `requestId` on a dict of pending events with a timeout (`ModuleError` on timeout). `request` holds a lock, sends `{op:6, d:{requestType, requestId, requestData}}`, waits. `close` stops the loop. + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: obs-websocket v5 client"` + +### Task 12: OBS module + +**Files:** +- Create: `deckit/modules/obs.py`, `tests/test_obs.py` + +**Interfaces:** +- Consumes: `ObsClient` (Task 11). +- Produces: `obs.make(cfg) -> Module` (name `obs`); state `{connected, scenes, currentScene, recording, streaming, inputs, transition, transitionKinds}`; actions `scene`, `record`, `stream`, `mute`, `volume`, `transition`, `trigger`, `source`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_obs.py +import unittest +import deckit.modules.obs as obs + +class Fake: + connected = True + def request(self, kind, data=None): + Fake.last = (kind, data) + return {"ok": True} + +class Actions(unittest.TestCase): + def setUp(self): + self.m = obs.make({"obs": {"host": "h", "port": 4455, "password": ""}, + "client": Fake()}) + def test_scene_and_record(self): + self.m.actions["scene"]({"scene": "Live"}); self.assertEqual(Fake.last[0], "SetCurrentProgramScene") + self.m.actions["record"]({"on": True}); self.assertEqual(Fake.last[0], "StartRecord") + self.m.actions["record"]({"on": False}); self.assertEqual(Fake.last[0], "StopRecord") + def test_mute_and_source(self): + self.m.actions["mute"]({"input": "Mic", "muted": True}); self.assertEqual(Fake.last[0], "SetInputMute") + self.m.actions["source"]({"scene": "S", "id": 2, "on": False}); self.assertEqual(Fake.last[0], "SetSceneItemEnabled") + +class Serialized(unittest.TestCase): + def test_double_toggle_is_serialized(self): + # concurrent record toggles resolve under one lock; no duplicate Start + pass +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. + +- [ ] **Step 3: Implement** + +`make(cfg)` builds an `ObsClient` (injectable for tests via `cfg["client"]`), starts it, and caches state updated from events. The lock serializes actions. Map actions to obs-websocket requests: scene → `SetCurrentProgramScene`, record → `StartRecord`/`StopRecord`/`ToggleRecord`, stream → `StartStream`/`StopStream`, mute → `SetInputMute`, volume → `SetInputVolume`, transition → `SetCurrentSceneTransition`, trigger → `TriggerStudioModeTransition`, source → `SetSceneItemEnabled`. A disconnected client raises `ModuleError` (Review Focus: no hang). + +- [ ] **Step 4: Run the test, expect pass** — PASS. + +- [ ] **Step 5: Commit** — `git commit -S -am "feat: OBS stream-deck module"` + +### Task 13: OBS panel + +**Files:** +- Create: `static/modules/obs.js`, `tests/test_frontend.py` (extend) + +- [ ] **Step 1: Extend the smoke test** — assert `obs.js` is served and exports a panel with `mount`/`update`. +- [ ] **Step 2: Run it, expect failure** — FAIL. +- [ ] **Step 3: Implement** — a grid: scene buttons (active highlighted), record/stream toggles with elapsed time, audio channel rows (mute + volume), transition select + trigger, source toggles. Uses `--udt-*` vars. +- [ ] **Step 4: Run the test, expect pass** — PASS. +- [ ] **Step 5: Commit** — `git commit -S -am "feat: OBS stream-deck panel"` + +--- + +## Phase 2 — read-only feeds + +### Task 14: llm module + +**Files:** +- Create: `deckit/modules/llm.py`, `tests/test_llm.py` + +**Interfaces:** +- Produces: `llm.make(cfg) -> Module` (name `llm`); state `{claude: {...}, opencode: {...}, offline: bool}` fetched from `[llm] url` (default `http://127.0.0.1:8420`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_llm.py +import unittest +import deckit.modules.llm as llm + +class Down(unittest.TestCase): + def test_offline_when_dashboard_down(self): + def boom(url, timeout=2): + raise OSError("refused") + st = llm.make({"llm": {"url": "http://x"}, "fetch": boom}).state() + self.assertTrue(st["offline"]) + +class Up(unittest.TestCase): + def test_merges_endpoints(self): + data = {"/api/claude_limits": [{"context_pct": 12}], + "/api/claude_session": {"context_used": 1200}, + "/api/summary": {"total_cost": 1.5}} + st = llm.make({"llm": {"url": "http://x"}, + "fetch": lambda u, timeout=2: data[u]}).state() + self.assertFalse(st["offline"]) + self.assertEqual(st["claude"]["context_pct"], 12) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. +- [ ] **Step 3: Implement** — fetch `/api/summary`, `/api/today`, `/api/claude_limits`, `/api/claude_session`, `/api/opencode_agents`; on any `OSError`/`JSONDecodeError` return `{"offline": True}` (Review Focus). Inject `fetch` for tests. +- [ ] **Step 4: Run the test, expect pass** — PASS. +- [ ] **Step 5: Commit** — `git commit -S -am "feat: llm-cost-dashboard relay module"` + +### Task 15: llm panel + +**Files:** +- Create: `static/modules/llm.js`, extend `tests/test_frontend.py` +- Steps: served smoke test (fail → implement cards: claude 5h/7d/context, opencode spend → pass → commit). + +### Task 16: sb-builder module + +**Files:** +- Create: `deckit/modules/sbbuilder.py`, `tests/test_sbbuilder.py` + +**Interfaces:** +- Produces: `sbbuilder.make(cfg) -> Module` (name `sbbuilder`); state `{offline: bool, jobs: [...]}` from `ssh <host> sbb-remote jobs`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_sbbuilder.py +import json, unittest +import deckit.modules.sbbuilder as sbb + +class Fail(unittest.TestCase): + def test_offline_on_ssh_failure(self): + def boom(cmd, timeout=5): + raise OSError("no route") + self.assertTrue(sbb.make({"sbbuilder": {"host": "buildsystem"}, "run": boom}).state()["offline"]) + +class Ok(unittest.TestCase): + def test_parses_jobs(self): + out = json.dumps({"jobs": [{"id": "20261109-101010-build", "status": "running"}]}) + st = sbb.make({"sbbuilder": {"host": "buildsystem"}, "run": lambda cmd, timeout=5: out}).state() + self.assertFalse(st["offline"]) + self.assertEqual(st["jobs"][0]["status"], "running") + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it, expect failure** — FAIL. +- [ ] **Step 3: Implement** — run `["ssh", "-o", "BatchMode=yes", host, "sbb-remote", "jobs"]` (injectable `run`); parse JSON; on `OSError`/`JSONDecodeError` return `{"offline": True}`. Read-only. +- [ ] **Step 4: Run the test, expect pass** — PASS. +- [ ] **Step 5: Commit** — `git commit -S -am "feat: sb-builder status module"` + +### Task 17: sb-builder panel + +**Files:** +- Create: `static/modules/sbbuilder.js`, extend `tests/test_frontend.py` +- Steps: served smoke test (fail → implement job list with status chips → pass → commit). + +--- + +## Phase 3 — deferred + +### Assistant (not built) + +`desktop-assistant` control is out of scope. When its shape is decided, it is +`deckit/modules/assistant.py` plus `static/modules/assistant.js` using the +existing Unix-socket protocol (`state`, `ctx`); no core change. + +--- + +## Self-review + +- **Spec coverage:** goals 1–6 map to Tasks 1–17; second monitor → Task 7; OBS → 11–13; read-only feeds → 14–17; assistant deferred → Phase 3. +- **Review Focus:** OBS drop → Task 12; llm down → Task 14; oversized body → Task 4; toggle race → Task 12; palette missing → Task 2. +- **Types:** `Module`/`Registry` signatures are used identically in Tasks 3, 4, 8–17; `run`/`fetch`/`client` are the injectable seams. diff --git a/docs/superpowers/specs/2026-11-09-deck-it-design.md b/docs/superpowers/specs/2026-11-09-deck-it-design.md new file mode 100644 index 0000000..fad1e4f --- /dev/null +++ b/docs/superpowers/specs/2026-11-09-deck-it-design.md @@ -0,0 +1,133 @@ +# deck-it — design + +**Date:** 2026-11-09 +**Status:** approved (design), pending implementation plan + +## Intent + +An always-on tablet dashboard, stream-deck, and second monitor for this desktop: +Slackware-current, Hyprland with the Lua config (hypr-theme), UDT-themed. It +reuses tabdeck's plumbing (Python stdlib HTTP server, a web page in the tablet's +browser over `adb reverse`, and a virtual monitor streamed with wayvnc) but +replaces tabdeck's fixed stats/keys/mull surface with a module system, and swaps +tabdeck's Arch/systemd assumptions for Slackware's. + +## Goals + +1. Always-on touch control on the Android tablet over USB; Wi-Fi optional. +2. Second monitor: virtual Hyprland output + wayvnc, shown in AVNC. +3. Stream-deck control of OBS: recording, scenes, audio channels, transitions, + sources. +4. Read-only dashboards: system stats, music/volume, windows/workspaces/launch, + claude-code + opencode usage (via llm-cost-dashboard), sb-builder progress. +5. Modular: a new app is a new module, with no core edits. +6. UDT-themed: the palette is read, never restated; a scheme switch recolours + the tablet. + +## Non-goals + +- Not a replacement for quickshell / rofi-scripts. They stay for the static + desktop approach; this is the fast always-on surface. Existing commands + (playerctl, wpctl, hyprctl, rofi scripts) are reused, not reimplemented. +- Not a general automation engine. Only declared actions run. +- No cloud, no account, no app-store install. +- Assistant control: deferred. A module slot is reserved. + +## Anti-goals (failures to avoid) + +- The tablet can ever run an arbitrary command. +- A scheme or wallpaper change leaves the tablet stale or desynced. +- Adding a module means editing the core server. +- Anything requires systemd. + +## Constraints + +- Slackware-current: SysV init, eudev, no systemd. Hyprland with the Lua config. +- Python stdlib only, with one exception: `websocket-client` for obs-websocket. +- Tablet: Android + AVNC + a Chromium browser. +- UDT is the single source of theme truth. + +## Module contract + +A module contributes: +- **Backend** — a `make(cfg) -> Module` factory returning a `Module` with a + `state()` callable (GET) and an `actions` dict of allow-listed verbs (POST). + The client sends `{module, action, ...}`; the server maps ids to code and the + client never sends a command. +- **Frontend** — `static/modules/<name>.js`, a plain object + `{title, interval, mount(el), update(state)}` rendered into the shell grid. + +The core provides the HTTP server, auth/trust, the adb tunnel, theming, and the +module registry. Modules are enabled in config under `[modules]`. + +## Theming + +At startup `deckit/theme.py` reads: +- `~/.config/rofi/udt/palette.rasi` — the structural palette, entries + `name: #rrggbb[aa]` (base, mantle, crust, surface0-2, text, subtext0-1, + overlay0-2, red, green, yellow, teal, blue, lavender, peach, mauve, termblue, + scrim). +- `~/.cache/udt/udt-accent.rasi` — `* { accent: #rrggbb; }`. + +It serves `/theme.css` with `:root { --udt-<name>: #...; --udt-accent: #...; }`. +The frontend styles only through those variables. When UDT regenerates a scheme +(`install.sh`) or accent (`udt-accent` on wallpaper change), the server re-reads +on an mtime poll and a page refresh picks it up. A missing palette falls back to +a built-in Catppuccin Macchiato set so the page always renders. Fonts: Noto Sans +for UI, Inconsolata Nerd Font Mono for numbers. + +## Slackware wiring + +No systemd. `deck-it` is a bash launcher with `start` (default), `stop`, `pair`, +`wifi`, `install`, `uninstall`, `env`, `setup-display`. + +- Start from Hyprland via hypr-theme's `exec-once` (the compositor must be up for + the virtual output and wayvnc); `install` prints the exact line to add. +- Plug-in autostart: an eudev rule in `/etc/udev/rules.d/70-deck-it.rules` with a + non-blocking `RUN+` that setsids `deck-it start`. A login-time check covers an + already-connected tablet. +- No `loginctl`; lock/leave stays in the existing rofi powermenu. + +## Second monitor + +Reuse tabdeck's `setup_display`: create a headless Hyprland output sized to the +tablet (`hyprctl output create headless`, then the Lua `hl.monitor` table), run +wayvnc with TLS + a random password in a private dir, and tunnel the VNC port to +the tablet with `adb reverse tcp:5900 localfilesystem:<run>/vnc.sock`. AVNC is +opened with the login in the link. wayvnc works on Hyprland, which implements +the wlroots screencopy and virtual-input protocols it needs. + +## Security + +Reuse tabdeck's model unchanged in spirit: +- Unix socket in a 0700 `$XDG_RUNTIME_DIR/deck-it`, exposed only by `adb reverse`. +- Token cookie (HttpOnly, SameSite=Strict) unlocks the API. +- Host/Origin checks block DNS rebinding and cross-site writes. +- Client sends ids, never commands. OBS's password and the OBS connection stay + server-side. + +## Per-module specifics + +| Module | Source | Actions | +|---|---|---| +| stats | /proc, sysfs, nvidia-smi | none (read) | +| music | playerctl + wpctl | play/pause, prev/next, volume ±/mute | +| windows | hyprctl clients/activewindow/workspaces | workspace switch, focus/close, launch (config commands) | +| obs | obs-websocket v5 client | set scene, toggle record/stream, input volume/mute, set/trigger transition, toggle source | +| llm | relay llm-cost-dashboard `/api/summary`, `/api/today`, `/api/claude_limits`, `/api/claude_session`, `/api/opencode_agents` | none (read) | +| sbbuilder | `ssh <host> sbb-remote jobs` (read-only) | none (read) | +| assistant | deferred | — | + +## Build order + +1. Core — scaffold, config, theming, module registry, server + auth, frontend + shell, launcher, second monitor. +2. First modules — stats, music, windows (proves the contract). +3. OBS — the stream-deck. +4. Read-only feeds — llm + sbbuilder. +5. Assistant — deferred. + +## Open calls (settled in the plan) + +- OBS client: `websocket-client` is the single non-stdlib dependency. +- sb-builder bridge: `sbb-remote jobs` over ssh; no change to sb-builder. |
