From 7f99167f0b6a3c83bf77e20bc5e03942b901fb71 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 9 Nov 2026 18:25:59 +0100 Subject: fix: OBS idle timeout, payload validation, workspace list --- deckit/modules/obs.py | 18 +++++++++++++----- deckit/modules/windows.py | 10 +++++++--- deckit/obsclient.py | 5 +++++ deckit/server.py | 7 +++++-- tests/test_llm.py | 9 +++++++++ tests/test_obs.py | 9 ++++++++- tests/test_obsclient.py | 22 ++++++++++++++++++++++ tests/test_server.py | 7 ++++++- tests/test_theme.py | 10 ++++++++++ tests/test_windows.py | 10 ++++++++++ 10 files changed, 95 insertions(+), 12 deletions(-) diff --git a/deckit/modules/obs.py b/deckit/modules/obs.py index f4bf71a..96217c4 100644 --- a/deckit/modules/obs.py +++ b/deckit/modules/obs.py @@ -18,6 +18,7 @@ def make(cfg: dict) -> Module: host=o.get("host", "127.0.0.1"), port=o.get("port", 4455), password=o.get("password", "")) lock = threading.Lock() stop = threading.Event() + last_error = {"msg": None} def req(kind, data=None): with lock: @@ -28,13 +29,14 @@ def make(cfg: dict) -> Module: if not getattr(client, "connected", False): try: client.connect() - except Exception: - pass + last_error["msg"] = None + except Exception as e: + last_error["msg"] = str(e) or type(e).__name__ stop.wait(5) def state(): if not getattr(client, "connected", False): - return {"connected": False} + return {"connected": False, "error": last_error["msg"]} try: scenes = req("GetSceneList") rec = req("GetRecordStatus") @@ -80,11 +82,17 @@ def make(cfg: dict) -> Module: raise ModuleError("scene required") req("SetCurrentProgramScene", {"sceneName": name}) + def flag(body): + on = body.get("on") + if not isinstance(on, bool): + raise ModuleError('"on" must be true or false') + return on + def record(body): - req("StartRecord" if body.get("on") else "StopRecord") + req("StartRecord" if flag(body) else "StopRecord") def stream(body): - req("StartStream" if body.get("on") else "StopStream") + req("StartStream" if flag(body) else "StopStream") def mute(body): req("SetInputMute", {"inputName": str(body.get("input")), "inputMuted": bool(body.get("muted"))}) diff --git a/deckit/modules/windows.py b/deckit/modules/windows.py index 022bdd7..d02fc0c 100644 --- a/deckit/modules/windows.py +++ b/deckit/modules/windows.py @@ -45,7 +45,7 @@ def _state(run): try: clients = json.loads(run(["hyprctl", "clients", "-j"]) or "[]") active = json.loads(run(["hyprctl", "activewindow", "-j"]) or "{}").get("address") - monitors = json.loads(run(["hyprctl", "monitors", "-j"]) or "[]") + workspaces = json.loads(run(["hyprctl", "workspaces", "-j"]) or "[]") except json.JSONDecodeError: return {"windows": [], "workspaces": [], "activeClass": None} wins = sorted( @@ -54,7 +54,8 @@ def _state(run): for c in clients if c.get("mapped", True) and c["workspace"]["id"] > 0), key=lambda w: (w["workspace"], w["class"])) active_class = next((w["class"] for w in wins if w["active"]), None) - return {"windows": wins, "workspaces": [m["activeWorkspace"]["id"] for m in monitors], + return {"windows": wins, + "workspaces": sorted(w["id"] for w in workspaces if w.get("id", 0) > 0), "activeClass": active_class} @@ -88,5 +89,8 @@ def make(cfg: dict) -> Module: raise ModuleError("unknown launch key") _popen(["sh", "-c", command] if isinstance(command, str) else command) - return Module(name="windows", title="Windows", state=lambda: _state(run), + def state(): + return {**_state(run), "launches": list(launch)} + + return Module(name="windows", title="Windows", state=state, actions={"workspace": workspace, "focus": focus, "close": close, "launch": launch_key}) diff --git a/deckit/obsclient.py b/deckit/obsclient.py index 2a8fd8c..181225a 100644 --- a/deckit/obsclient.py +++ b/deckit/obsclient.py @@ -49,9 +49,14 @@ class ObsClient: self._thread.start() def _recv_loop(self): + import websocket + while not self._stop.is_set(): try: msg = json.loads(self.ws.recv()) + except websocket.WebSocketTimeoutException: + # An idle OBS is not a closed one; keep reading. + continue except Exception: break op, d = msg.get("op"), msg.get("d", {}) diff --git a/deckit/server.py b/deckit/server.py index bbd92cf..bddbb00 100644 --- a/deckit/server.py +++ b/deckit/server.py @@ -129,14 +129,17 @@ class Handler(BaseHTTPRequestHandler): return self._send(404, str(e).encode()) if fn is None: return self._send(404, b"unknown action") - length = int(self.headers.get("Content-Length", 0) or 0) + try: + length = int(self.headers.get("Content-Length", 0) or 0) + except ValueError: + return self._send(400, b"Invalid Content-Length") if self.headers.get("Content-Type") != "application/json" or not 0 < length <= 4096: return self._send(400, b"Expected a small JSON body") try: result = fn(json.loads(self.rfile.read(length))) except module.ModuleError as e: return self._send(400, str(e).encode()) - except (ValueError, AttributeError): + except (ValueError, AttributeError, TypeError, KeyError): return self._send(400, b"Invalid request") if result is None: return self._send(204) diff --git a/tests/test_llm.py b/tests/test_llm.py index 759666c..9cdfcf6 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -27,6 +27,15 @@ class Up(unittest.TestCase): self.assertEqual(st["claude"]["context_pct"], 12) self.assertEqual(st["opencode"]["total_cost"], 1.5) + def test_handles_the_real_latest_shape(self): + data = {"/api/claude_limits": {"latest": {"context_pct": 42, "five_hour_pct": 10}}, + "/api/claude_session": {}, + "/api/summary": {}} + st = llm.make({"llm": {"url": "http://x"}, + "fetch": lambda u, timeout=2: data[u]}).state() + self.assertEqual(st["claude"]["context_pct"], 42) + self.assertEqual(st["claude"]["five_hour_pct"], 10) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_obs.py b/tests/test_obs.py index 876d48b..aeb0791 100644 --- a/tests/test_obs.py +++ b/tests/test_obs.py @@ -39,7 +39,14 @@ class Actions(unittest.TestCase): def test_state_offline(self): class Off: connected = False - self.assertEqual(obs.make({"client": Off()}).state(), {"connected": False}) + st = obs.make({"client": Off()}).state() + self.assertFalse(st["connected"]) + self.assertIn("error", st) + + def test_record_requires_a_bool(self): + for bad in ({}, {"on": "false"}, {"on": 1}): + with self.assertRaises(obs.ModuleError, msg=bad): + self.m.actions["record"](bad) class Serialized(unittest.TestCase): diff --git a/tests/test_obsclient.py b/tests/test_obsclient.py index 7d1a5ca..54eadd1 100644 --- a/tests/test_obsclient.py +++ b/tests/test_obsclient.py @@ -25,5 +25,27 @@ class Disconnected(unittest.TestCase): oc.ObsClient(host="127.0.0.1", port=4455, password="").request("GetVersion") +class RecvLoop(unittest.TestCase): + def test_a_recv_timeout_does_not_end_the_loop(self): + import websocket + + class FakeWS: + calls = 0 + + def recv(self): + FakeWS.calls += 1 + if FakeWS.calls <= 3: + raise websocket.WebSocketTimeoutException("idle") + raise RuntimeError("closed") + + c = oc.ObsClient() + c.ws = FakeWS() + c.connected = True + c._recv_loop() + # It kept calling recv past three idle timeouts; a timeout must not + # look like a closed socket. + self.assertEqual(FakeWS.calls, 4) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index 1583ad2..bf571a6 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -38,7 +38,8 @@ class Server(unittest.TestCase): srv.TOKEN = TOKEN srv.REGISTRY = mod.Registry([ mod.Module("demo", "Demo", lambda: {"ok": True}, - {"ping": lambda body: {"got": body.get("n")}}), + {"ping": lambda body: {"got": body.get("n")}, + "boom": lambda body: int(body.get("x"))}), ]) cls.server = srv.UnixHTTPServer(cls.sock, srv.Handler) threading.Thread(target=cls.server.serve_forever, daemon=True).start() @@ -86,6 +87,10 @@ class Server(unittest.TestCase): {**HOST, **ORIGIN, "Content-Type": "application/json"}, big) self.assertEqual(status, 400) + def test_rejects_wrong_typed_body(self): + h = {**HOST, **ORIGIN, "Content-Type": "application/json"} + self.assertEqual(self.req("POST", "/api/demo/boom", h, '{"x": null}')[0], 400) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_theme.py b/tests/test_theme.py index 5f2f533..6530af5 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -29,6 +29,16 @@ class Theme(unittest.TestCase): self.assertIn("--udt-base: #24273a", out) self.assertIn("--udt-accent:", out) # fallback accent + def test_load_falls_back_when_files_are_missing(self): + saved = (theme.PALETTE_PATH, theme.ACCENT_PATH) + theme.PALETTE_PATH = Path("/nonexistent/palette.rasi") + theme.ACCENT_PATH = Path("/nonexistent/accent.rasi") + try: + out = theme.load() + finally: + theme.PALETTE_PATH, theme.ACCENT_PATH = saved + self.assertIn("--udt-base: #24273a", out) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_windows.py b/tests/test_windows.py index 1573a77..6aa7925 100644 --- a/tests/test_windows.py +++ b/tests/test_windows.py @@ -18,6 +18,16 @@ class Commands(unittest.TestCase): self.assertIsNone(windows.ADDR.fullmatch("0x1;rm")) self.assertTrue(windows.ADDR.fullmatch("0x55897e5c0800")) + def test_state_lists_all_workspaces(self): + responses = { + ("hyprctl", "clients", "-j"): "[]", + ("hyprctl", "activewindow", "-j"): "{}", + ("hyprctl", "monitors", "-j"): "[]", + ("hyprctl", "workspaces", "-j"): '[{"id": 1}, {"id": 2}, {"id": 5}]', + } + st = windows._state(lambda cmd: responses[tuple(cmd)]) + self.assertEqual(st["workspaces"], [1, 2, 5]) + if __name__ == "__main__": unittest.main() -- cgit v1.2.3