diff --git a/Justfile b/Justfile index 68d620c..b4d6791 100644 --- a/Justfile +++ b/Justfile @@ -51,6 +51,59 @@ run-daemon: run-daemon-lan: deckd --host 0.0.0.0 --layouts-dir layouts --verbose +# Kill whatever is bound to the two ports we use: the daemon (:8765) and +# the Vite dev server (:5173). Handy when a stale daemon still holds the +# port (deckd now fails fast on that) or a dev server outlived its +# terminal, leaving the client with no backend. Reports free ports and +# no-ops cleanly when nothing is running. +kill: + #!/usr/bin/env bash + set -uo pipefail + for port in 8765 5173; do + pids=$(lsof -ti "tcp:$port" 2>/dev/null || true) + if [ -z "$pids" ]; then + echo ":$port already free" + continue + fi + echo "killing :$port -> $pids" + kill $pids 2>/dev/null || true + sleep 0.3 + pids=$(lsof -ti "tcp:$port" 2>/dev/null || true) + if [ -n "$pids" ]; then + echo " still alive, SIGKILL -> $pids" + kill -9 $pids 2>/dev/null || true + fi + done + +# Run the whole dev stack with one command: the daemon (LAN, restart-on-edit) +# plus the Vite client (tailscale HTTPS). Ctrl+C — or either process dying — +# stops both. This replaces the old Procfile +# (daemon: dev-daemon-lan / client: dev-client-tailscale). For a plain +# LAN/HTTP client with no cert, run `just dev-lan` instead. +dev: + #!/usr/bin/env bash + set -uo pipefail + # kill 0 targets this script's process group, so Ctrl+C tears down both + # `just` children AND their grandchildren (deckd, node/vite) — no orphans + # left holding :8765 / :5173. + trap 'kill 0' EXIT + just dev-daemon-lan & + just dev-client-tailscale & + # Fall through (and via the trap, stop the sibling) the moment either exits. + wait -n + +# Same as `just dev` but with the plain-HTTP LAN client (no tailscale cert, +# no sudo). Reachable at http://:5173/ on the LAN; no PWA install +# prompt (that needs the HTTPS secure context `just dev` provides). +# TODO: consider replacing this with overmind/hivemind +dev-lan: + #!/usr/bin/env bash + set -uo pipefail + trap 'kill 0' EXIT + just dev-daemon-lan & + just dev-client-lan & + wait -n + # Run the daemon under a supervisor that restarts it when daemon/**/*.py # changes. Layout YAML hot-reload is built into the daemon itself; this is # only useful when editing Python. diff --git a/README.md b/README.md index 2a0ea33..ab2bffc 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,8 @@ Tailscale is **not** running an application proxy — `tailscale serve status` w That's why the URL you see in devtools is `wss://..ts.net:5173/ws` (Vite's port), not `:8765` (daemon's port). +**Troubleshooting — page loads but `wss://…:5173/ws` won't connect and no password prompt appears.** This is almost always the daemon being down, not a cert problem: the page loads over HTTPS (so the cert is fine, and `wss://` reuses it), but Vite's `/ws` proxy has no upstream to forward to, so the socket is dropped mid-handshake. Because the auth exchange never completes, the client never receives the `unauthorized` frame and the password gate never renders — it just loops on reconnect. Check `curl -s http://127.0.0.1:8765/health`; if it fails, (re)start the daemon. Note the password lives in per-origin `localStorage`, so entering it once at `localhost:5173` does **not** carry over to the `.ts.net:5173` origin — enter it again there. If the daemon itself won't start, it now fails fast with `cannot bind 127.0.0.1:8765 — another process is already listening there` (usually a stale `deckd`); clear it with `pkill -f bin/deckd` and relaunch. + **Contrast:** `tailscale serve` **(persistent URL, no dev server).** If you want an installable PWA at `https://..ts.net/` (no port, works without any process running on the desktop besides the daemon), that's a different setup — `just build-client` + `just run-daemon` + `tailscale serve --bg 8765`. Tailscale proxies `:443 → 127.0.0.1:8765`, the daemon serves the built `client/dist/`. You lose HMR but gain a URL that survives closing your dev terminals. Not covered by any `just` recipe yet — file an issue if you want one. ### Client chrome @@ -644,6 +646,47 @@ The daemon also loads a sibling directory next to `--layouts-dir` whose name is This is how `layouts.macos/firefox.yaml` carries the `super+t` / `super+[` / `super+]` shortcuts without forking the rest of `firefox.yaml` for every Linux user who pulls the repo. +### Live widgets (the `meter` kind) + +A layout can include widgets that display values pushed by the daemon in real time. Today the only kind is `meter` (a numeric readout with a horizontal bar). It looks like a button in the grid, doesn't react to taps, and renders the value the daemon keeps pushing on the bound sensor source. + +```yaml +- id: cpu_percent + kind: meter + label: CPU + icon: + source: lucide + name: cpu + source: cpu_percent # daemon-side sensor name + min: 0 # bar's left edge (default 0) + max: 100 # bar's right edge (default 100) + grid: [2, 2, 1, 1] +``` + +The daemon polls the bound sensor on a timer and pushes a `widget_update` WebSocket frame every time the value changes (or the source flips stale). The bar fills proportionally between `min` and `max` and is color-graded cool→hot so a glance tells you whether the number is OK before you read it. + +Built-in sensors (all psutil-backed — see "Why no CPU temperature?" below for the rationale): + +| Source | What it shows | How | Poll | +|----------------|--------------------------------|--------------------------------------------------|--------| +| `cpu_percent` | Whole-system CPU utilisation | `psutil.cpu_percent(interval=None)` — delta since the last call. The first reading is `0.0` (no baseline); subsequent readings land in `[0, 100]`. | 1s | +| `mem_percent` | Memory used / total | `psutil.virtual_memory().percent` | 1s | +| *(more TBD)* | CPU frequency, battery, swap | `psutil.cpu_freq()`, `psutil.sensors_battery()`, `psutil.swap_memory()` — all the same API. Open to contributions. | varies | + +#### Why no CPU temperature? + +We deliberately don't ship a `cpu_temp` source. The short version is that **Apple Silicon doesn't expose a stable, unprivileged CPU temperature API**: + +- `psutil.sensors_temperatures()` has **no macOS backend at all** (verified against the upstream source — there's no IOKit call, no SMC probe, no entitlement handling). +- `osx-cpu-temp` (Homebrew, ~100 lines of C) and `istats` (the `iStats` Ruby gem) both read classic Intel SMC keys (`TC0P`, `TC0D`, `TC0E`). Apple Silicon uses a completely different sensor namespace that Apple doesn't document and that changes per SoC generation; the brew arm64 bottle exists because the binary compiles and runs, not because it returns valid temperature data. +- The only reliable M-series source is Apple's own `sudo powermetrics`, which requires root, an undocumented/unstable output format, and either a privileged helper or interactive sudo prompts. + +Net result: a cross-platform `cpu_temp` source would either silently fail on most Apple Silicon Macs (deceptive) or require a deployment story heavier than the rest of deckd put together (overkill). `cpu_percent` and `mem_percent` cover the same "is the box healthy" use case and work on every Linux + every macOS without any per-OS install step. Users who really want CPU temp on Linux specifically can keep a custom layout pointing at `/sys/class/thermal` (we removed the in-tree reader because nothing on macOS could share the code path; bringing it back is a small PR). + +The meter rendering is a regular cell in the grid (it picks up `--content-scale` and the user's Button-size preference like every other widget). When the daemon hasn't pushed a value yet, the cell renders "—" with the bar at 0% and a dashed border so you can see at a glance it's waiting. + +Try it without sensors: `?demo=meter` loads a backend-free demo with seeded CPU%/MEM% values so you can see the meter without a running daemon. + ## Under the hood The pieces behind the features above, for anyone reading the code: diff --git a/client/screenshot-meters.mjs b/client/screenshot-meters.mjs new file mode 100644 index 0000000..369d332 --- /dev/null +++ b/client/screenshot-meters.mjs @@ -0,0 +1,16 @@ +import { chromium } from "playwright-core"; + +const chromePath = "/nix/store/6qv40p5vmxqg9yr55qm2hibrxny7i553-playwright-chromium/chrome-linux64/chrome"; +const browser = await chromium.launch({ executablePath: chromePath, headless: true }); +const ctx = await browser.newContext({ + viewport: { width: 480, height: 320 }, + deviceScaleFactor: 2, +}); +const page = await ctx.newPage(); +page.on("pageerror", (err) => console.log("[pageerror]", err.message)); +// Meter demo so we don't have to deal with the no-auth + WS issue +await page.goto("http://127.0.0.1:8766/index.html?demo=meter", { waitUntil: "networkidle" }); +await page.waitForTimeout(1500); +await page.screenshot({ path: "/tmp/deckd-meters-demo.png", fullPage: false }); +console.log("saved meter demo"); +await browser.close(); diff --git a/client/src/App.tsx b/client/src/App.tsx index 80ec3dc..3f859bf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Settings as SettingsIcon } from "lucide-react"; import { PointerIcon } from "lucide-react"; import { useDeckdSocket } from "./socket"; @@ -7,6 +7,7 @@ import { JogStrip } from "./JogStrip"; import { ManualControl } from "./ManualControl"; import { PasswordGate } from "./PasswordGate"; import { Settings } from "./Settings"; +import { useMeterStore } from "./meter-store"; import { useBottomScale, useContentScale, @@ -18,7 +19,7 @@ import { } from "./settings-store"; import type { CSSProperties } from "react"; import { useWakeLock } from "./wake-lock"; -import { getDemoLayout } from "./demo"; +import { getDemoLayout, METER_DEMO_SEEDS } from "./demo"; import { Icon } from "./Icon"; import type { JogHandle } from "./JogStrip"; import type { Icon as IconRef, ServerLayout } from "./protocol"; @@ -49,8 +50,66 @@ export function App() { const [layout, setLayout] = useState(demoLayout); const [view, setView] = useState("layout"); const onLayout = useCallback((m: ServerLayout) => setLayout(m), []); + // Track every sensor source the active layout references (from ``meter`` + // widgets and each ``stats`` widget's metrics) so the meter store can drop + // readings that are no longer on screen — without this, a source would + // keep its last reading in memory forever, and a layout switch that hides + // meters would still remember them across reloads. + const activeMeterSources = useMemo(() => { + const sources = new Set(); + if (layout) { + for (const w of layout.widgets) { + if (w.kind === "meter" && w.source) sources.add(w.source); + if (w.kind === "stats" && w.metrics) { + for (const m of w.metrics) if (m.source) sources.add(m.source); + } + } + } + return sources; + }, [layout]); + const meter = useMeterStore(activeMeterSources); + // Pull out the store's ``onUpdate`` (a stable useCallback) and feed + // widget_update frames straight to it. Depending on the whole ``meter`` + // object instead would be a bug: it gets a fresh identity on every render + // (and on every 1Hz readings push), so any effect keyed on it — notably the + // socket effect below — would re-fire and tear down + reconnect the + // WebSocket in a tight loop. The connection would never stay open long + // enough to authenticate, so the password gate would never show. + const pushReading = meter.onUpdate; + const onWidgetUpdate = pushReading; + // Demo-mode seeding (issue #40). The meter demo fixture never + // receives server pushes (demo mode disables the socket). Seed the + // meter store with a realistic CPU temp so the bar renders with + // something to look at instead of staying at the "—" empty state. + // This is a one-shot write per id — subsequent seeds with the same + // id are no-ops, so a layout switch that lands back on the meter + // demo doesn't keep re-seeding. + useEffect(() => { + if (!demoLayout) return; + for (const w of demoLayout.widgets) { + if (w.kind !== "meter" || !w.source) continue; + const seed = METER_DEMO_SEEDS[w.id]; + if (!seed) continue; + // Synthesise a ServerWidgetUpdate-like frame the store accepts. + // Treated as live (stale=false) so the demo shows a coloured + // bar; without a real source flipping the freshness, a demoed + // meter would auto-dim after STALE_AFTER_S. + void seed; + // Trigger an immediate push by writing to localStorage and + // triggering a state bump: easier and side-effect-free here is + // to just call onUpdate via a synthetic frame. + pushReading({ + type: "widget_update", + id: w.id, + source: w.source, + value: seed.value, + unit: seed.unit, + stale: false, + }); + } + }, [demoLayout, pushReading]); const { status, send, authenticate, deauthenticate, hasPassword } = - useDeckdSocket(onLayout, { enabled: !demoLayout }); + useDeckdSocket(onLayout, onWidgetUpdate, { enabled: !demoLayout }); // Track whether we've already handed the socket a password this session, so // the gate can say "incorrect" on a repeat rejection rather than on first // contact (where the stored password was simply empty). @@ -173,6 +232,8 @@ export function App() { onJogEnd={jogEnd} scrollScale={scroll.scale} scrollInvert={scroll.invert} + meterReadings={meter.readings ?? undefined} + labelScale={labelScale.scale} /> ) : (
waiting for daemon…
diff --git a/client/src/ButtonGrid.tsx b/client/src/ButtonGrid.tsx index b0c7302..3003e8a 100644 --- a/client/src/ButtonGrid.tsx +++ b/client/src/ButtonGrid.tsx @@ -2,6 +2,9 @@ import type { CSSProperties } from "react"; import type { Widget } from "./protocol"; import { Icon } from "./Icon"; import { JogStrip } from "./JogStrip"; +import { MeterCell } from "./MeterCell"; +import { StatsCell } from "./StatsCell"; +import type { MeterReading } from "./meter-store"; import { transposeWidgets, useOrientation } from "./orientation"; import type { Orientation } from "./orientation"; @@ -17,6 +20,13 @@ type Props = { * device stories pass it so the transpose matches the container's shape * rather than the window's. */ orientation?: Orientation; + /** Latest reading per sensor source. Missing sources render with no + * value (bar empty, "—" numeric). Stale readings show the bar at + * its last position with a dimmed readout. */ + meterReadings?: Record; + /** Multiplier for the meter widget's caption label so it scales with + * the same user-facing "label size" preference as buttons. */ + labelScale?: number; }; const FALLBACK_DIM = 4; @@ -46,6 +56,8 @@ export function ButtonGrid({ scrollScale, scrollInvert, orientation: orientationOverride, + meterReadings, + labelScale, }: Props) { const autoOrientation = useOrientation(); const orientation = orientationOverride ?? autoOrientation; @@ -89,6 +101,28 @@ export function ButtonGrid({ /> ); } + if (w.kind === "meter") { + return ( + + ); + } + if (w.kind === "stats") { + return ( + + ); + } const buttonStyle: CSSProperties = w.color ? { ...style, backgroundColor: w.color } : style; diff --git a/client/src/MeterCell.test.tsx b/client/src/MeterCell.test.tsx new file mode 100644 index 0000000..5d3140c --- /dev/null +++ b/client/src/MeterCell.test.tsx @@ -0,0 +1,93 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { MeterCell } from "./MeterCell"; +import type { Widget } from "./protocol"; +import type { MeterReading } from "./meter-store"; + +const CPU_WIDGET: Widget = { + id: "cpu_percent", + kind: "meter", + label: "CPU", + source: "cpu_percent", + min: 0, + max: 100, + grid: [0, 0, 1, 1], +}; + +function renderMeter(widget: Widget, reading: MeterReading | null) { + return render(); +} + +describe("MeterCell", () => { + afterEach(cleanup); + + it("renders the empty-state when no reading has arrived", () => { + renderMeter(CPU_WIDGET, null); + // Em-dash placeholder so the cell still has a number-shaped mark + // to align against — better than a blank space when neighbouring + // buttons are showing live values. + expect(screen.getByText("—")).not.toBeNull(); + expect(screen.getByRole("meter")).not.toBeNull(); + }); + + it("renders the value and unit when a reading is present", () => { + renderMeter(CPU_WIDGET, { + value: 58, + unit: "°C", + stale: false, + }); + expect(screen.getByText("58")).not.toBeNull(); + expect(screen.getByText("°C")).not.toBeNull(); + }); + + it("formats sub-10 values with one decimal place", () => { + renderMeter(CPU_WIDGET, { + value: 8.4, + unit: "°C", + stale: false, + }); + expect(screen.getByText("8.4")).not.toBeNull(); + }); + + it("clamps the bar fill fraction to 0..1", () => { + // Above max: bar should be 100% (clamped). Below min: 0%. + renderMeter(CPU_WIDGET, { value: 150, unit: "°C", stale: false }); + const fill = screen.getByRole("meter").firstElementChild as HTMLElement; + expect(fill.style.width).toBe("100%"); + + cleanup(); + + renderMeter(CPU_WIDGET, { value: -20, unit: "°C", stale: false }); + const fill2 = screen.getByRole("meter").firstElementChild as HTMLElement; + expect(fill2.style.width).toBe("0%"); + }); + + it("marks the cell stale when the reading is stale", () => { + renderMeter(CPU_WIDGET, { value: 42, unit: "°C", stale: true }); + const meter = screen.getByRole("meter"); + // Walk up to the parent cell so the test is robust to refactors + // of the inner DOM (the class lives on the .cell-meter element). + const cell = meter.closest(".cell-meter") as HTMLElement; + expect(cell.classList.contains("cell-meter-stale")).toBe(true); + }); + + it("does not crash when min == max", () => { + // Defensive guard against a malformed fixture or future config + // that drops the min/max pair. Should render something — not + // throw. + const bad: Widget = { ...CPU_WIDGET, min: 50, max: 50 }; + expect(() => renderMeter(bad, { value: 50, unit: "°C", stale: false })).not.toThrow(); + }); + + it("falls back to a 0..100 range when min/max are unset", () => { + const openRange: Widget = { + ...CPU_WIDGET, + min: undefined, + max: undefined, + }; + renderMeter(openRange, { value: 50, unit: "°C", stale: false }); + const fill = screen.getByRole("meter").firstElementChild as HTMLElement; + expect(fill.style.width).toBe("50%"); + }); +}); diff --git a/client/src/MeterCell.tsx b/client/src/MeterCell.tsx new file mode 100644 index 0000000..bd09641 --- /dev/null +++ b/client/src/MeterCell.tsx @@ -0,0 +1,145 @@ +import { useMemo } from "react"; +import type { CSSProperties } from "react"; +import type { Widget } from "./protocol"; +import { Icon } from "./Icon"; +import type { MeterReading } from "./meter-store"; + +type Props = { + widget: Widget; + reading: MeterReading | null; + style?: CSSProperties; + labelScale?: number; +}; + +/** + * Meter widget: a live numeric readout with a horizontal bar that fills + * proportionally to the value. Color-graded cool → hot (blue → green → + * yellow → red) so a glance at the bar conveys "is this OK or not" before + * the user reads the number. + * + * Rendering rules: + * * No reading yet (or stale with no prior value): bar at 0%, value + * rendered as "—". Stays visible — the bar position holds the last + * known value, only the freshness label changes. + * * Reading stale with a prior value: bar stays where it was, + * numeric readout dimmed to 60% so it's clear the value is + * historical, not live. + * * Value out of range: bar clamps to its end. We intentionally do + * NOT cap the underlying reading — the daemon still receives the + * true value and the user sees the actual number; only the bar's + * visual fill clamps. + * * Color grade: derived from the bar's current fill fraction + * (clamped 0..1) so a meter with a non-default min/max still + * paints the same colors at the same fraction. The default + * 0..100 °C range maps fraction 0.0 = cold, 1.0 = red; the same + * colors map to the same fractions for any other range. + */ +export function MeterCell({ widget, reading, style, labelScale = 1 }: Props) { + const min = widget.min ?? 0; + const max = widget.max ?? 100; + // Range validity guard — the daemon validates this in layouts.py, + // but a malformed fixture shouldn't crash rendering. If max <= min, + // collapse the bar to 0% rather than NaN-ing the fill calculation. + const range = max > min ? max - min : 1; + const rawFraction = reading ? (reading.value - min) / range : 0; + const fraction = Math.max(0, Math.min(1, rawFraction)); + + // Color gradient sampled at three points so the CSS linear-gradient + // interpolation stays smooth. Cool blue → green (mid) → red (full). + // Hue/lightness tuned for the dark chrome background; saturations + // are high enough to read as "alert" but not so high they clash with + // themed buttons next door. + const color = useMemo(() => interpolateMeterColor(fraction), [fraction]); + + const valueLabel = reading + ? formatValue(reading.value) + : "—"; + const unitLabel = reading?.unit ?? ""; + const stale = !reading || reading.stale; + + const numericStyle: CSSProperties = { + color: stale ? "rgba(230, 233, 239, 0.6)" : "#e6e9ef", + }; + + return ( +
+ {widget.icon ? ( + + ) : null} +
+ + {valueLabel} + + {unitLabel ? {unitLabel} : null} +
+
+
+
+ {widget.label ? ( + + {widget.label} + + ) : null} +
+ ); +} + +function formatValue(v: number): string { + if (!Number.isFinite(v)) return "—"; + // 0 decimals for typical 30-90 °C CPU temps; 1 decimal once we drop + // below 10 to preserve precision. ``toFixed``-vs-``Math.round`` + // threshold is a stylistic call — picked 10 °C because that's where + // the difference between "32" and "32.4" starts being noticeable. + if (Math.abs(v) >= 10) return Math.round(v).toString(); + return v.toFixed(1); +} + +/** + * Map a 0..1 fill fraction to a CSS colour string along the + * cool→warm gradient. We interpolate in HSL so the cool blue stays + * blue rather than washing out to grey, and the warm end stays red + * rather than going brown. + * + * Three stops (0.0 blue, 0.5 green, 1.0 red) split the hue range + * evenly; saturation and lightness are held constant. + */ +function interpolateMeterColor(t: number): string { + const clamped = Math.max(0, Math.min(1, t)); + // Stops: (hue, saturation, lightness) + const stops: [number, number, number][] = [ + [210, 80, 55], // blue + [140, 70, 50], // green + [15, 85, 55], // red-orange + ]; + if (clamped <= 0.5) { + return mix(stops[0], stops[1], clamped / 0.5); + } + return mix(stops[1], stops[2], (clamped - 0.5) / 0.5); +} + +function mix( + a: [number, number, number], + b: [number, number, number], + t: number, +): string { + const hue = a[0] + (b[0] - a[0]) * t; + const sat = a[1] + (b[1] - a[1]) * t; + const light = a[2] + (b[2] - a[2]) * t; + return `hsl(${hue}, ${sat}%, ${light}%)`; +} diff --git a/client/src/StatsCell.test.tsx b/client/src/StatsCell.test.tsx new file mode 100644 index 0000000..85c700c --- /dev/null +++ b/client/src/StatsCell.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { StatsCell } from "./StatsCell"; +import type { Widget } from "./protocol"; +import type { MeterReading } from "./meter-store"; + +const SYSTEM_WIDGET: Widget = { + id: "system", + kind: "stats", + label: "System", + grid: [0, 0, 1, 1], + metrics: [ + { source: "cpu_percent", label: "CPU" }, + { source: "mem_percent" }, // label omitted -> derived + ], +}; + +function renderStats(readings: Record) { + return render(); +} + +describe("StatsCell", () => { + afterEach(cleanup); + + it("renders one row per metric with live values and units", () => { + renderStats({ + cpu_percent: { value: 58, unit: "%", stale: false }, + mem_percent: { value: 41, unit: "%", stale: false }, + }); + expect(screen.getByText("58")).not.toBeNull(); + expect(screen.getByText("41")).not.toBeNull(); + expect(screen.getByText("CPU")).not.toBeNull(); + }); + + it("derives a label from the source when none is given", () => { + renderStats({ mem_percent: { value: 41, unit: "%", stale: false } }); + // mem_percent -> "MEM" + expect(screen.getByText("MEM")).not.toBeNull(); + }); + + it("shows the em-dash placeholder for a source with no reading", () => { + renderStats({ cpu_percent: { value: 58, unit: "%", stale: false } }); + // mem row has no reading yet + expect(screen.getByText("—")).not.toBeNull(); + }); + + it("reads each metric from the shared store by source, not widget id", () => { + // The widget id is "system"; values must resolve via the metric + // sources, never the widget id — regression guard for the + // source-keyed store. + renderStats({ + system: { value: 999, unit: "x", stale: false }, // must be ignored + cpu_percent: { value: 58, unit: "%", stale: false }, + mem_percent: { value: 41, unit: "%", stale: false }, + }); + expect(screen.queryByText("999")).toBeNull(); + expect(screen.getByText("58")).not.toBeNull(); + expect(screen.getByText("41")).not.toBeNull(); + }); +}); diff --git a/client/src/StatsCell.tsx b/client/src/StatsCell.tsx new file mode 100644 index 0000000..9c5842b --- /dev/null +++ b/client/src/StatsCell.tsx @@ -0,0 +1,77 @@ +import type { CSSProperties } from "react"; +import type { Widget } from "./protocol"; +import type { MeterReading } from "./meter-store"; + +type Props = { + widget: Widget; + /** Live readings keyed by sensor source (the shared meter store map). */ + readings: Record; + style?: CSSProperties; + labelScale?: number; +}; + +/** + * Stats widget: a compact, bar-less cell that shows several sensor values + * at once, one "LABEL value unit" row per metric. Deliberately simpler than + * {@link MeterCell} — no bar, no colour grade — so it stays legible with 2–4 + * data points in a single grid cell, and takes new metrics without layout + * churn (add another entry to the widget's ``metrics`` list). + * + * Each row reads its value from the shared store by the metric's ``source``. + * A missing or stale reading renders "—" / dims the value, matching the + * MeterCell freshness treatment so the two kinds feel consistent. + */ +export function StatsCell({ widget, readings, style, labelScale = 1 }: Props) { + const metrics = widget.metrics ?? []; + + return ( +
+ {widget.label ? ( + + {widget.label} + + ) : null} +
+ {metrics.map((m) => { + const reading = readings[m.source] ?? null; + const stale = !reading || reading.stale; + const value = reading ? formatValue(reading.value) : "—"; + const unit = reading?.unit ?? ""; + return ( +
+ + {m.label ?? deriveLabel(m.source)} + + + {value} + {unit ? {unit} : null} + +
+ ); + })} +
+
+ ); +} + +/** Compact number format: integers above 10, one decimal below (mirrors + * MeterCell so CPU/MEM percentages read the same in both cells). */ +function formatValue(v: number): string { + if (!Number.isFinite(v)) return "—"; + if (Math.abs(v) >= 10) return Math.round(v).toString(); + return v.toFixed(1); +} + +/** Derive a short caption from a source name when the metric omits ``label``: + * ``cpu_percent`` → ``CPU``, ``mem_percent`` → ``MEM``. Falls back to the + * whole source uppercased for single-word sources. */ +function deriveLabel(source: string): string { + const head = source.split("_")[0] || source; + return head.toUpperCase(); +} diff --git a/client/src/demo.ts b/client/src/demo.ts index 354a383..fd52560 100644 --- a/client/src/demo.ts +++ b/client/src/demo.ts @@ -60,10 +60,75 @@ const SHOWCASE: ServerLayout = { ], }; +// Backend-free meter demo (issue #40). Mirrors what the daemon would +// push for a real CPU%/MEM% source — a couple of realistic values so +// the bar's color-graded fill renders across the spectrum. Wired via +// the meter store's localStorage hydration on mount, so opening +// ``/?demo=meter`` lands in a "fresh layout, pre-populated values" +// state without needing the daemon at all. +const METER: ServerLayout = { + type: "layout", + app: "meter (demo)", + display_name: "Meter demo", + theme: "#1d4ed8", + jogstrip_enabled: true, + widgets: [ + { + id: "cpu_percent", + kind: "meter", + label: "CPU", + icon: { source: "lucide", name: "cpu" }, + source: "cpu_percent", + min: 0, + max: 100, + grid: [0, 0, 2, 1], + }, + { + id: "mem_percent", + kind: "meter", + label: "MEM", + icon: { source: "lucide", name: "memory-stick" }, + source: "mem_percent", + min: 0, + max: 100, + grid: [0, 1, 2, 1], + }, + { id: "open-url", kind: "button", label: "example.com", icon: { source: "simple-icons", name: "firefox" }, grid: [2, 0, 1, 1] }, + { id: "tilix", kind: "button", label: "Tilix", icon: { source: "lucide", name: "square-terminal" }, grid: [3, 0, 1, 1] }, + { id: "audio-toggle", kind: "button", label: "VLC", icon: { source: "lucide", name: "play" }, grid: [2, 1, 1, 1] }, + // Combined stats cell — reads the same cpu_percent/mem_percent seeds as + // the bar meters above (readings are keyed by source), so it renders + // without its own seed entry. + { + id: "system", + kind: "stats", + label: "System", + grid: [3, 1, 1, 1], + metrics: [ + { source: "cpu_percent", label: "CPU" }, + { source: "mem_percent", label: "MEM" }, + ], + }, + ], +}; + +// Seed values for the meter demo so the bar renders with realistic +// values on first paint (localStorage hydration, see meter-store.ts). +// These are written into localStorage by the app on first mount when +// the meter demo is selected, and cleared when the user navigates +// away to a non-meter demo. Keeping the seeds in the same file as the +// fixture means a designer iterating on the meter layout doesn't have +// to remember to update two places. +export const METER_DEMO_SEEDS: Record = { + cpu_percent: { value: 58, unit: "%" }, + mem_percent: { value: 41, unit: "%" }, +}; + const DEMOS: Record = { firefox: FIREFOX, default: DEFAULT, showcase: SHOWCASE, + meter: METER, }; /** The demo fixtures, keyed by name — for the gallery and Ladle stories. */ diff --git a/client/src/meter-store.test.tsx b/client/src/meter-store.test.tsx new file mode 100644 index 0000000..8f33a1e --- /dev/null +++ b/client/src/meter-store.test.tsx @@ -0,0 +1,44 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { useMeterStore } from "./meter-store"; + +const IDS = new Set(["cpu_percent"]); + +beforeEach(() => window.localStorage.clear()); +afterEach(() => window.localStorage.clear()); + +describe("useMeterStore identity stability", () => { + // Regression guard (issue #40): App feeds widget_update frames to + // ``meter.onUpdate`` and puts it in the socket effect's dependency array. + // If ``onUpdate`` got a fresh identity per render — or per readings push — + // the socket would tear down and reconnect in a tight loop, never staying + // open long enough to authenticate, so the password gate never appeared. + it("keeps onUpdate referentially stable across renders", () => { + const { result, rerender } = renderHook(() => useMeterStore(IDS)); + const first = result.current.onUpdate; + rerender(); + rerender(); + expect(result.current.onUpdate).toBe(first); + }); + + it("keeps onUpdate stable even after a readings push re-renders the hook", () => { + const { result } = renderHook(() => useMeterStore(IDS)); + const before = result.current.onUpdate; + act(() => { + result.current.onUpdate({ + type: "widget_update", + id: "cpu_percent", + source: "cpu_percent", + value: 42, + unit: "%", + stale: false, + }); + }); + // The push changes ``readings`` (so the memoised store object legitimately + // gets a new identity), but ``onUpdate`` itself must not change — that is + // what the socket effect depends on. + expect(result.current.readings.cpu_percent?.value).toBe(42); + expect(result.current.onUpdate).toBe(before); + }); +}); diff --git a/client/src/meter-store.ts b/client/src/meter-store.ts new file mode 100644 index 0000000..0557baa --- /dev/null +++ b/client/src/meter-store.ts @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ServerWidgetUpdate } from "./protocol"; + +/** Latest reading for one widget id, or ``null`` if no reading has arrived + * yet (or the widget was just hidden by a layout change). Unit lives on + * the reading because it's the sensor's, not the widget's, and different + * sources could (in principle) bind to the same widget id over time. */ +export type MeterReading = { + value: number; + unit: string; + stale: boolean; +}; + +const METER_KEY_PREFIX = "deckd.meter."; +// Stale the stored value on reconnect after this many seconds without a +// fresh push. The daemon normally re-pushes on the first poll after a +// session opens; if it doesn't (e.g. a sensor was never available), the +// UI shouldn't claim an ancient value is current forever. +const STALE_AFTER_S = 10; + +/** Read the persisted last-known readings from localStorage. Used as a + * ``useState`` lazy initializer so the initial render already shows + * the last value rather than flashing "—" before an effect fires. */ +function loadInitial(): Record { + if (typeof window === "undefined") return {}; + const initial: Record = {}; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key || !key.startsWith(METER_KEY_PREFIX)) continue; + try { + const parsed = JSON.parse(window.localStorage.getItem(key) ?? "null"); + if ( + parsed && + typeof parsed.value === "number" && + typeof parsed.unit === "string" + ) { + // Key by the persisted source name. Older entries (pre-source + // keying) stored the widget id as the key suffix and no source + // field; fall back to the suffix so they still load once. + const srcKey = + typeof parsed.source === "string" && parsed.source + ? parsed.source + : key.slice(METER_KEY_PREFIX.length); + initial[srcKey] = { + value: parsed.value, + unit: parsed.unit, + stale: true, + }; + } + } catch { + // ignore corrupted entries + } + } + return initial; +} + +/** + * Hook: hold a map of sensor-source -> latest reading, fed by + * ``widget_update`` WebSocket frames. + * + * Keyed by **source**, not widget id: a reading belongs to a sensor, and + * keying this way lets one ``stats`` widget show several sources, and two + * widgets bound to the same source share one canonical value. + * + * Returned API: + * ``readings`` — current map (source -> reading). Render-time consumers + * read this directly; ``MeterCell`` looks up its own ``source`` and a + * ``stats`` cell looks up each of its metrics' sources. + * ``onUpdate(frame)`` — feed in a server frame. + * + * One shared instance is held at the ``App`` level so every cell reads the + * same map without prop-drilling. Entries whose sources are not in + * ``activeSources`` are filtered out at render time so a source no longer + * on screen doesn't leave ghost values, and re-adding it later reads the + * cached value back from localStorage. + */ +export function useMeterStore(activeSources: ReadonlySet) { + const [readings, setReadings] = useState>(loadInitial); + // Ref-mirror so the stale-detection interval (which calls setState) + // doesn't need to depend on the readings state — otherwise every push + // would tear down and re-create the timer. + const readingsRef = useRef(readings); + readingsRef.current = readings; + + const onUpdate = useCallback((m: ServerWidgetUpdate) => { + setReadings((prev) => { + // Skip the state update + storage write entirely if the value + // hasn't changed. A 1Hz push of an unchanged CPU temp would + // otherwise mean a re-render of every meter on every tick, which + // (a) wastes work and (b) re-fires the meter-cell's transition + // animation. We compare on the three fields the UI actually + // renders: value, stale flag, and unit. + const existing = prev[m.source]; + if ( + existing && + existing.value === m.value && + existing.stale === m.stale && + existing.unit === m.unit + ) { + return prev; + } + const next = { + ...prev, + [m.source]: { value: m.value, unit: m.unit, stale: m.stale }, + }; + try { + // Persist the most recent reading so a page reload doesn't + // flash a blank meter until the next push lands. The full set + // is small (a handful of sources per layout), so a single + // JSON blob per source keeps writes cheap and survives quota + // pressure better than one key per push. + window.localStorage.setItem( + `${METER_KEY_PREFIX}${m.source}`, + JSON.stringify({ value: m.value, unit: m.unit, stale: m.stale, source: m.source }), + ); + } catch { + // Private mode / quota: stay in memory only. + } + return next; + }); + }, []); + + // Mark everything stale after ``STALE_AFTER_S`` of no updates. This + // is the reconnect-with-no-push case: a fresh page open that hasn't + // seen a server frame yet should NOT show the last value as fresh. + // The interval is registered once (no deps) and reads the latest + // readings via a ref so it doesn't tear itself down on every push. + useEffect(() => { + const id = window.setInterval(() => { + const cur = readingsRef.current; + let changed = false; + const next: Record = { ...cur }; + for (const [wid, r] of Object.entries(cur)) { + if (!r.stale) { + next[wid] = { ...r, stale: true }; + changed = true; + } + } + if (changed) setReadings(next); + }, STALE_AFTER_S * 1000); + return () => window.clearInterval(id); + }, []); + + // Filter the live map down to just the sources the active layout + // references. Done as a render-time memo (not an effect) so React + // schedules a single render when both the readings map and the + // active-source set change; an effect-based reap would render once + // with stale entries, then again after the effect fires. + const visibleReadings = useMemo(() => { + if (readings === EMPTY_RECORD) return readings; + let filtered: Record | null = null; + for (const source of Object.keys(readings)) { + if (!activeSources.has(source)) { + filtered = filtered ?? { ...readings }; + delete filtered[source]; + } + } + return filtered ?? readings; + }, [readings, activeSources]); + + const reading = useCallback( + (source: string): MeterReading | null => readings[source] ?? null, + [readings], + ); + + // Memoise the returned object so its identity only changes when one of + // its members actually changes. Consumers that put the store in an effect + // dependency array (e.g. App's demo-seed effect) then don't re-run on + // unrelated renders. ``onUpdate`` is independently stable, so callers that + // only need it (the socket's widget_update handler) can — and must — depend + // on ``meter.onUpdate`` rather than the whole object to avoid reconnecting. + return useMemo( + () => ({ reading, readings: visibleReadings, onUpdate }), + [reading, visibleReadings, onUpdate], + ); +} + +// Sentinel empty record used to skip the filter pass when nothing has +// been pushed yet. ``useMemo``'s identity stability depends on the +// return value, so a fresh ``{}`` literal every render would force the +// memo to recompute even when there's nothing to filter. +const EMPTY_RECORD: Record = Object.freeze({}); + +export const _INTERNAL = { METER_KEY_PREFIX }; diff --git a/client/src/protocol.ts b/client/src/protocol.ts index 223cbab..d8fa301 100644 --- a/client/src/protocol.ts +++ b/client/src/protocol.ts @@ -4,15 +4,33 @@ * renders a placeholder for an unknown source. */ export type Icon = { source: string; name: string }; +/** One data point of a ``stats`` widget: a sensor ``source`` plus an + * optional short caption. When ``label`` is absent the client derives one + * from the source name (``cpu_percent`` → ``CPU``). */ +export type Metric = { source: string; label?: string | null }; + export type Widget = { id: string; - kind: "button" | "jogstrip" | "trackpad"; + kind: "button" | "jogstrip" | "trackpad" | "meter" | "stats"; label?: string | null; icon?: Icon | null; grid: [number, number, number, number]; /** Optional CSS colour string; applied as the button's background. */ color?: string | null; action?: Record | null; + /** + * Meter-only: the daemon-side sensor source name the widget + * subscribes to (e.g. ``"cpu_percent"``, ``"mem_percent"``). Opaque + * to the client — the daemon validates it; unknown sources just + * leave the widget stale. + */ + source?: string | null; + /** Meter-only: lower bound of the bar's visible range. */ + min?: number | null; + /** Meter-only: upper bound of the bar's visible range. */ + max?: number | null; + /** Stats-only: the data points to display, one compact row each. */ + metrics?: Metric[] | null; }; export type ServerLayout = { @@ -38,6 +56,19 @@ export type ServerLayout = { export type ServerState = { type: "state"; locked: boolean }; export type ServerBrightness = { type: "brightness"; value: number }; +/** Live value for a meter widget (issue #40). Pushed at the sensor's + * poll cadence; the client renders the bar + numeric readout from + * ``value`` / ``unit``. ``stale=true`` means the source could not + * refresh — the UI keeps the last known position but stops claiming + * the value is fresh. */ +export type ServerWidgetUpdate = { + type: "widget_update"; + id: string; + source: string; + value: number; + unit: string; + stale: boolean; +}; /** Sent by the daemon to a non-loopback client whose ``hello`` omitted or * got the shared password wrong (issue #16); the socket is closed straight * after. The client swaps in the password prompt. */ @@ -46,6 +77,7 @@ export type ServerMessage = | ServerLayout | ServerState | ServerBrightness + | ServerWidgetUpdate | ServerError; export type ClientHello = { diff --git a/client/src/socket.ts b/client/src/socket.ts index b52c0a3..2c7cd62 100644 --- a/client/src/socket.ts +++ b/client/src/socket.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { ClientMessage, ServerMessage } from "./protocol"; +import type { ClientMessage, ServerLayout, ServerMessage, ServerWidgetUpdate } from "./protocol"; type Status = "connecting" | "open" | "closed" | "unauthorized"; @@ -7,6 +7,11 @@ type Status = "connecting" | "open" | "closed" | "unauthorized"; // ``deckd.*`` namespace is shared with the per-device settings store. const PASSWORD_KEY = "deckd.password"; +// Application-defined WebSocket close code the daemon uses to signal an auth +// rejection (must match server.py WS_CLOSE_UNAUTHORIZED). Keying off the close +// code makes the gate robust to browsers dropping the rejection data frame. +const UNAUTHORIZED_CLOSE_CODE = 4401; + function loadStoredPassword(): string { try { return window.localStorage.getItem(PASSWORD_KEY) ?? ""; @@ -36,7 +41,8 @@ function readPinnedLayout(): string { } export function useDeckdSocket( - onLayout: (m: Extract) => void, + onLayout: (m: ServerLayout) => void, + onWidgetUpdate: (m: ServerWidgetUpdate) => void, options: { enabled?: boolean } = {}, ) { const { enabled = true } = options; @@ -96,6 +102,7 @@ export function useDeckdSocket( try { const msg = JSON.parse(ev.data) as ServerMessage; if (msg.type === "layout") onLayout(msg); + else if (msg.type === "widget_update") onWidgetUpdate(msg); else if (msg.type === "error" && msg.reason === "unauthorized") { // Wrong/absent password: stop reconnecting and prompt the user. unauthorizedRef.current = true; @@ -107,7 +114,16 @@ export function useDeckdSocket( } }; - ws.onclose = () => { + ws.onclose = (ev) => { + // Dedicated "unauthorized" close code (see daemon WS_CLOSE_UNAUTHORIZED). + // Browsers can drop the app-level rejection frame when the daemon closes + // right after sending it, so the onmessage handler above may never fire; + // the close code survives that race, so treat it as the gate trigger too. + if (ev.code === UNAUTHORIZED_CLOSE_CODE) { + unauthorizedRef.current = true; + setStatus("unauthorized"); + return; + } if (stopped || unauthorizedRef.current) { if (!unauthorizedRef.current) setStatus("closed"); return; @@ -129,7 +145,7 @@ export function useDeckdSocket( if (timer) window.clearTimeout(timer); wsRef.current?.close(); }; - }, [onLayout, enabled, gen]); + }, [onLayout, onWidgetUpdate, enabled, gen]); const send = (msg: ClientMessage) => { const ws = wsRef.current; diff --git a/client/src/style.css b/client/src/style.css index 2e25e77..a1a5d68 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -754,3 +754,191 @@ button:focus-visible { outline: none; } .settings-logout:active { background: #3a1c20; } + +/* --------------------------------------------------------------------------- + * Meter widget (issue #40): a live value with a horizontal bar that fills + * proportionally to the value. Visually parallel to a button cell so it + * reads as a first-class grid citizen; the bar gives a glance-readable + * "is this OK" answer before the user reads the number. + * + * Layout: + * ┌────────────────────────┐ + * │ [icon] │ + * │ 58 │ <- .meter-readout (big number + unit) + * │ °C │ + * │ ▰▰▰▰▰▰▰▱▱▱ │ <- .meter-bar (track + fill) + * │ CPU │ <- .meter-label (caption) + * └────────────────────────┘ + * + * Color-graded fill (cool blue → green → red) is applied inline by the + * component; the track stays neutral so the fill stands out. + * ------------------------------------------------------------------------- */ + +.cell-meter { + background: #1b222a; + border: 1px solid #2a333d; + border-radius: 12px; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 4px; + padding: 10px 12px; + /* Meter cells aren't pressable — they're informational. Block the + default tap-highlight from breaking the visual rest state. */ + user-select: none; + -webkit-user-select: none; + touch-action: none; + text-align: center; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +/* Stale treatment: dim the border + background so a meter that hasn't + * seen a fresh push reads as "waiting" rather than asserting a live + * number. The numeric readout dims further in MeterCell. */ +.cell-meter-stale { + border-style: dashed; + border-color: #2a333d; + background: #161b21; +} + +.cell-meter .icon { + width: calc(clamp(20px, 5vw, 28px) * var(--content-scale)); + height: calc(clamp(20px, 5vw, 28px) * var(--content-scale)); + color: currentColor; + align-self: center; +} + +.meter-readout { + display: flex; + align-items: baseline; + justify-content: center; + gap: 3px; + line-height: 1; + min-height: 0; + min-width: 0; +} + +.meter-value { + /* Big enough to read at arm's length on a phone; not so big it pushes + the bar off-cell on a 1x1 grid slot. Scales with content-scale so + the user's Button-size setting covers this too. */ + font-size: calc(clamp(20px, 5vw, 30px) * var(--content-scale)); + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} + +.meter-unit { + font-size: calc(clamp(11px, 1.6vw, 14px) * var(--content-scale)); + color: #7a8590; + font-weight: 500; +} + +.meter-bar { + /* Track: a thin neutral bar across the cell. Fill painted inline by + the component. The track is left visible at 0% fill so the bar + still reads as "a meter" even before the first push lands. */ + position: relative; + height: calc(8px * var(--content-scale)); + background: #2a333d; + border-radius: 999px; + overflow: hidden; +} + +.meter-bar-fill { + height: 100%; + border-radius: 999px; + /* Soft transition so a 1s polling cadence reads as a smooth crawl + rather than a stutter step. 250ms is short enough that an abrupt + value change still snaps visually. */ + transition: width 250ms ease, background 250ms ease; + /* Subtle inner glow makes the fill pop against the dark track. The + glow is dark-mode-friendly (no white halo) and stays at a fixed + color so a fill at any hue reads consistently. */ + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} + +.meter-label { + /* Caption under the bar — same scale rules as button labels so the + caption stays in step with the rest of the grid's text. */ + font-size: calc(clamp(9px, 1.5vw, 12px) * var(--content-scale) * var(--label-scale, 1)); + font-weight: 600; + line-height: 1.2; + color: #7a8590; + word-break: break-word; +} + +/* Stats cell: a compact multi-value readout. Shares the meter cell's chrome + * (background, border, radius, non-pressable) but stacks "LABEL value" rows + * instead of a single big number + bar, so 2–4 data points fit one grid slot. + * + * Layout: + * ┌────────────────────────┐ + * │ System │ <- .stats-title (optional caption) + * │ CPU 58 % │ <- .stats-row (label ....... value unit) + * │ MEM 41 % │ + * └────────────────────────┘ + */ +.cell-stats { + background: #1b222a; + border: 1px solid #2a333d; + border-radius: 12px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 10px 12px; + user-select: none; + -webkit-user-select: none; + touch-action: none; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.stats-title { + color: #7a8590; + font-weight: 600; + line-height: 1.2; + text-align: center; +} + +.stats-rows { + display: flex; + flex-direction: column; + gap: 3px; + min-height: 0; +} + +.stats-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + line-height: 1.1; +} + +.stats-row-label { + font-size: calc(clamp(10px, 1.6vw, 13px) * var(--content-scale) * var(--label-scale, 1)); + font-weight: 600; + color: #7a8590; + letter-spacing: 0.02em; +} + +.stats-row-value { + font-size: calc(clamp(14px, 2.4vw, 20px) * var(--content-scale)); + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} + +.stats-row-unit { + font-size: 0.6em; + color: #7a8590; + font-weight: 500; + margin-left: 2px; +} + diff --git a/daemon/deckd/__main__.py b/daemon/deckd/__main__.py index c0f3682..594c634 100644 --- a/daemon/deckd/__main__.py +++ b/daemon/deckd/__main__.py @@ -7,10 +7,12 @@ import sys from pathlib import Path +from aiohttp import web + from .auth import PasswordError, default_password_path, load_or_create_password from .input import LoggingKeySink, LoggingScrollSink, ScrollController, UinputSink -from .platform import default_backend -from .server import Server +from .platform import default_backend, default_sensor_manager +from .server import PortInUseError, Server async def _run(server: Server) -> None: @@ -166,15 +168,35 @@ def dbus_bus_factory(bus_type): focus_backend=focus_backend, overlay_dir=overlay_dir, password=password, + sensor_manager=default_sensor_manager(), ) if args.client_dist is not None: - server.app.router.add_static("/", args.client_dist, show_index=True, append_version=False) + index_text = (args.client_dist / "index.html").read_text() + async def spa(_req): - return _req.response(200, text=(args.client_dist / "index.html").read_text(), content_type="text/html") - server.app.router.add_get("/{path:^(?!ws$|health$|.+\\..+$).*}", spa) + # SPA fallback: serve the built index.html for any path + # without a file extension that isn't a reserved route + # (``/ws``, ``/health``, ``/reload``, ``/layout/...``). + return web.Response(text=index_text, content_type="text/html") + + # Register the SPA routes BEFORE add_static so they win the + # match for ``/`` and other extension-less paths. ``add_static`` + # would otherwise claim ``/`` first and return a directory + # listing, breaking ``/?demo=meter``-style entry points. + server.app.router.add_get("/", spa) + server.app.router.add_get( + "/{path:^(?!ws$|health$|reload$|layout($|/)).+}", spa + ) + server.app.router.add_static("/", args.client_dist, show_index=False, append_version=False) - asyncio.run(_run(server)) + try: + asyncio.run(_run(server)) + except PortInUseError as exc: + # Fail fast with the actionable message instead of a raw asyncio + # traceback ending in OSError: [Errno 98]. + logging.getLogger("deckd").error("%s", exc) + raise SystemExit(1) from None if __name__ == "__main__": diff --git a/daemon/deckd/layouts.py b/daemon/deckd/layouts.py index 9d78e6a..9480d27 100644 --- a/daemon/deckd/layouts.py +++ b/daemon/deckd/layouts.py @@ -4,7 +4,7 @@ from pathlib import Path import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from .platform import AppInfo @@ -24,6 +24,25 @@ class Icon(BaseModel): name: str = Field(min_length=1) +class MetricSpec(BaseModel): + """One data point in a ``stats`` widget (issue #40). + + ``source`` names a daemon-side :class:`SensorSource` (same registry + the single-value ``meter`` widget binds to). ``label`` is the short + caption shown beside the value; when omitted the client derives one + from the source name (``cpu_percent`` -> ``CPU``), so a minimal + ``metrics: [{source: cpu_percent}]`` still reads sensibly. Kept a + distinct model (rather than a bare string) so more per-metric knobs + (unit override, min/max, colour) can be added without a breaking + schema change. + """ + + model_config = ConfigDict(extra="forbid") + + source: str = Field(min_length=1) + label: str | None = None + + class Widget(BaseModel): model_config = ConfigDict(extra="forbid") @@ -38,6 +57,54 @@ class Widget(BaseModel): # input, so no sanitisation is needed. color: str | None = None action: "Action | None" = None + # ``meter`` widgets bind to a daemon-side :class:`SensorSource` by + # name (e.g. ``cpu_percent``). The daemon pushes ``WidgetUpdateMessage`` + # frames for every meter widget in the active layout whose source + # has live readings. ``min`` / ``max`` define the bar's visible + # range; values outside the range clamp at the ends so a runaway + # sensor paints the bar at full red rather than overflowing. + # ``min``/``max`` default to a CPU-friendly 0..100 %; layouts with + # non-thermal sensors should override. + source: str | None = None + min: float | None = None + max: float | None = None + # ``stats`` widgets bind to several sensor sources at once and render + # a compact, bar-less list of "label: value" rows. Each metric names a + # source the same way a ``meter`` names its single ``source``; the + # daemon subscribes to every referenced source while a stats widget is + # in the active layout, exactly as it does for meters. + metrics: list[MetricSpec] | None = None + + @field_validator("kind") + @classmethod + def _validate_meter_needs_source(cls, v: str) -> str: + # Field-level validators on Pydantic v2 don't see sibling fields + # via ``info.data`` (that dict only contains fields validated + # *before* this one, not peer fields). The cross-field check + # (meter requires ``source``, min < max) lives in the + # ``_validate_meter_invariants`` model validator below where + # the full model is available. + return v + + @model_validator(mode="after") + def _validate_meter_invariants(self) -> "Widget": + if self.kind == "meter": + if not self.source: + raise ValueError( + "meter widgets require a 'source' field naming a " + "daemon-side SensorSource (e.g. 'cpu_percent')" + ) + if self.min is not None and self.max is not None and self.min >= self.max: + raise ValueError( + f"meter widget min ({self.min}) must be strictly " + f"less than max ({self.max})" + ) + if self.kind == "stats" and not self.metrics: + raise ValueError( + "stats widgets require a non-empty 'metrics' list, each " + "naming a 'source' (e.g. metrics: [{source: cpu_percent}])" + ) + return self class Action(BaseModel): diff --git a/daemon/deckd/platform.py b/daemon/deckd/platform.py index 672e187..9ae815e 100644 --- a/daemon/deckd/platform.py +++ b/daemon/deckd/platform.py @@ -28,6 +28,252 @@ def identity(self) -> str: return self.app_id or self.wm_class or "unknown" +@dataclass(frozen=True) +class SensorReading: + """One sample from a named sensor source. + + ``value`` is the live reading in the unit the source documents + (``sensor.unit``). ``stale=True`` marks a reading the source could + not refresh (sensor disappeared, permission denied) so the client + can render an "unknown" treatment instead of an arbitrary number + pinned to the last good value. + """ + + source: str + value: float + unit: str + stale: bool = False + + +class SensorSource: + """Named data source the daemon pushes to the client. + + Sources own their own polling cadence: a thermal-zone reader can + re-read ``/sys`` cheaply on every tick, while a D-Bus signal + listener just exposes the next event. ``SensorManager`` polls each + registered source at its declared ``interval_s``; sources that need + event-driven semantics override :meth:`wait_for_next` instead. + + Implementations must be cheap to construct and side-effect-free at + construction time — file handles / bus connections open on the + first call to :meth:`read`. + """ + + #: Short, stable identifier the client references (matches the + #: ``source`` field on a meter widget in a layout YAML). + name: str = "" + + #: Human-readable unit the value is in (e.g. ``"°C"``). Pushed + #: alongside the value so the client can render a label without + #: hard-coding units per source. + unit: str = "" + + #: Default poll cadence the SensorManager uses when no per-widget + #: override is in effect. Sources can override per-poll. + interval_s: float = 1.0 + + def is_available(self) -> bool: + """True when the source can produce readings in this environment. + + Checked once at manager start; sources that return False are + skipped and their meter widgets render ``stale=True`` forever. + The check should be cheap (a stat, an env var read) — no I/O. + """ + return True + + def read(self) -> SensorReading | None: + """Return the next reading, or ``None`` to signal "no data". + + ``None`` keeps the manager's last-good value and flips the + ``stale`` flag on subsequent pushes. Implementations should + swallow their own exceptions and return ``None`` on failure; + the manager will log and continue. + """ + raise NotImplementedError + + +class SensorManager: + """Owns a set of :class:`SensorSource` instances keyed by name. + + Built once at daemon startup with the platform-default sources. + Layouts reference sources by name; the manager hands the same + ``SensorReading`` to every subscriber so two meter widgets bound + to the same source see one canonical value rather than two + polls racing against each other. + + A single :class:`SensorManager` is shared across all sessions + (the manager is process-scoped state, not session state). When + no meter widget is interested in a source, the manager stops + polling it: a phone that's never connected doesn't waste CPU + re-reading ``/sys``. Polling resumes the moment any session + binds a widget to the source. + """ + + def __init__(self, sources: list[SensorSource]) -> None: + self._sources: dict[str, SensorSource] = {s.name: s for s in sources if s.name} + self._last: dict[str, SensorReading] = {} + self._subscribers: dict[str, int] = {} # source -> refcount + self._tasks: dict[str, asyncio.Task[None]] = {} + + @property + def sources(self) -> list[str]: + return list(self._sources) + + def has(self, name: str) -> bool: + return name in self._sources + + def is_available(self, name: str) -> bool: + src = self._sources.get(name) + return src is not None and src.is_available() + + def latest(self, name: str) -> SensorReading | None: + return self._last.get(name) + + def subscribe(self, name: str) -> None: + """Record one more consumer of ``name``. + + Bumps the refcount; starts the polling task on the 0 -> 1 + transition. Repeated calls are idempotent — only the first + call per source kicks the task off. + """ + if name not in self._sources: + return + self._subscribers[name] = self._subscribers.get(name, 0) + 1 + if name not in self._tasks: + self._tasks[name] = asyncio.create_task(self._poll_loop(name)) + + def unsubscribe(self, name: str) -> None: + """Drop one consumer; stop polling when the count hits zero. + + Safe to call more times than :meth:`subscribe`; the count + floors at zero. The polling task is cancelled on the 1 -> 0 + transition; the cached last reading stays so a re-subscribe + within the same session doesn't flash ``stale=True``. + """ + if name not in self._sources: + return + self._subscribers[name] = max(0, self._subscribers.get(name, 0) - 1) + if self._subscribers[name] == 0 and name in self._tasks: + task = self._tasks.pop(name) + task.cancel() + + def start(self) -> None: + """No-op retained for symmetry with :meth:`stop`. + + Sources are started lazily on :meth:`subscribe` so a process + with no meter subscribers never opens a file handle. + """ + return None + + def stop(self) -> None: + for task in self._tasks.values(): + task.cancel() + self._tasks.clear() + + async def _poll_loop(self, name: str) -> None: + src = self._sources[name] + interval = max(0.05, src.interval_s) + while True: + try: + reading = src.read() + except Exception as exc: # sources should swallow, but be defensive + log.warning("sensor %s raised: %s", name, exc) + reading = None + if reading is None: + last = self._last.get(name) + if last is not None and not last.stale: + self._last[name] = SensorReading( + source=name, value=last.value, unit=last.unit, stale=True + ) + else: + self._last[name] = reading + try: + await asyncio.sleep(interval) + except asyncio.CancelledError: + return + + +# --------------------------------------------------------------------------- +# psutil-backed sensor sources +# --------------------------------------------------------------------------- +# +# We deliberately dropped the per-platform CPU-temperature readers +# (``/sys/class/thermal`` on Linux, ``osx-cpu-temp`` / ``istats`` shell-outs +# on macOS) in favour of metrics psutil exposes identically on every +# platform: CPU utilisation, memory usage, CPU frequency, battery. +# The trade-off is documented in ``README.md``; the short version is +# that Apple Silicon doesn't expose a stable, unprivileged CPU +# temperature API, and shelling out for one-off helper binaries is a +# support liability that buys us nothing the user can't see in +# Activity Monitor / ``top``. +# +# ``psutil.cpu_percent(interval=None)`` is the canonical non-blocking +# form: it returns the delta between this call and the previous one, +# so a 1s poll cadence gives a fresh per-second percentage. The +# first call returns 0.0 (no baseline yet) — that's correct, not a +# bug. + + +class PsutilCpuPercentSensorSource(SensorSource): + """Whole-system CPU utilisation in percent, via ``psutil``. + + Returns a value in ``[0, 100]`` (well, sometimes briefly above + on busy multi-core systems where psutil's normalisation hasn't + settled — the meter cell clamps the bar to 100% anyway). The + interval is ``1.0s`` to match psutil's standard semantics: + ``cpu_percent(interval=None)`` is delta-since-last-call, so the + delta window equals the poll cadence. Setting the source + interval below ~0.5s makes the numbers jittery because the + sampling window gets too short to be statistically meaningful. + """ + + name = "cpu_percent" + unit = "%" + interval_s = 1.0 + + def is_available(self) -> bool: + return True + + def read(self) -> SensorReading | None: + try: + import psutil + + value = psutil.cpu_percent(interval=None) + except Exception: + return None + # First call after daemon start returns 0.0 (no baseline). + # We surface that as a normal reading — the bar shows 0% + # for one second, which is honest. + return SensorReading(source=self.name, value=float(value), unit=self.unit) + + +class PsutilMemoryPercentSensorSource(SensorSource): + """System memory usage in percent, via ``psutil.virtual_memory``. + + This is the ``used / total`` ratio on Linux and macOS. On Windows + it follows the same definition. The value is a snapshot — no + sampling window needed — so the interval can be as fast as we + like; ``1.0s`` is the conservative default that keeps a meter's + transition animation readable. + """ + + name = "mem_percent" + unit = "%" + interval_s = 1.0 + + def is_available(self) -> bool: + return True + + def read(self) -> SensorReading | None: + try: + import psutil + + value = psutil.virtual_memory().percent + except Exception: + return None + return SensorReading(source=self.name, value=float(value), unit=self.unit) + + class PlatformBackend: async def start(self) -> None: """Acquire any long-lived resources (a D-Bus bus name, a session @@ -407,6 +653,31 @@ def default_backend() -> PlatformBackend: return GnomeShellFocusBackend() +def default_sensor_manager() -> "SensorManager": + """Build the platform-default :class:`SensorManager`. + + Every source here is psutil-backed, so the same set works on + Linux, macOS Intel, and Apple Silicon without per-OS install + steps or shell-outs. We register every source the manager knows + about; per-widget subscriptions are gated by + :meth:`SensorSource.is_available` so a sensor that can't read + (e.g. ``battery`` on a desktop) just stays quiet rather than + spamming None. + + Trade-off documented in README.md: dropping CPU temperature + because Apple Silicon doesn't expose one without root + a + privileged helper + a parser for Apple's undocumented format. + CPU utilisation and memory percent cover the same "is the box + healthy" use case and work everywhere. + """ + return SensorManager( + [ + PsutilCpuPercentSensorSource(), + PsutilMemoryPercentSensorSource(), + ] + ) + + def _is_kde_wayland_session() -> bool: """Detect a KDE Plasma Wayland session. diff --git a/daemon/deckd/protocol.py b/daemon/deckd/protocol.py index f65cf8b..5e9cff5 100644 --- a/daemon/deckd/protocol.py +++ b/daemon/deckd/protocol.py @@ -42,8 +42,35 @@ class BrightnessMessage(BaseModel): value: int = Field(ge=0, le=255) +class WidgetUpdateMessage(BaseModel): + """Daemon->client push: a meter widget's live value (issue #40). + + The daemon sends one of these to every connected session whenever a + sensor the session has subscribed to produces a new reading. + ``id`` is the widget id from the active layout; ``source`` echoes + the bound sensor name so a client with a stale layout can still tell + what the value belongs to; ``unit`` rides along so the client + doesn't have to know a per-source unit registry. + + ``stale=True`` means the source could not refresh (sensor + disappeared, permission denied); the client renders an "unknown" + treatment and keeps the bar at its last-known position. We send + ``stale=True`` explicitly rather than dropping the message so the + UI can stop claiming the value is fresh. + """ + + model_config = ConfigDict(extra="forbid") + + type: Literal["widget_update"] + id: str + source: str + value: float + unit: str + stale: bool = False + + ServerMessage = Annotated[ - Union[LayoutMessage, StateMessage, BrightnessMessage], + Union[LayoutMessage, StateMessage, BrightnessMessage, WidgetUpdateMessage], Field(discriminator="type"), ] diff --git a/daemon/deckd/server.py b/daemon/deckd/server.py index 8bfd9c0..c681cf6 100644 --- a/daemon/deckd/server.py +++ b/daemon/deckd/server.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import contextlib +import errno import hmac import json import logging @@ -22,6 +24,7 @@ from dbus_fast.aio import MessageBus from .input import KeySink + from .layouts import Widget from .platform import AppInfo, PlatformBackend from . import PASSWORD_HEADER @@ -34,6 +37,62 @@ # drop it. Generous — a real client sends it immediately on open. WS_AUTH_TIMEOUT_S = 10.0 +# Application-defined WebSocket close code (private-use range 4000-4999) +# meaning "auth rejected". Mnemonic for HTTP 401. The client keys off this +# in onclose so a browser that dropped the rejection frame still learns it +# was unauthorized and shows the password gate. +WS_CLOSE_UNAUTHORIZED = 4401 + +# Beat between sending the rejection frame and closing, so browsers deliver +# the frame to onmessage as its own event instead of coalescing it with the +# close frame and dropping it. +WS_AUTH_REJECT_GRACE_S = 0.25 + + +class PortInUseError(RuntimeError): + """The daemon's listen port is already bound by another process. + + Raised from :meth:`Server.start` instead of letting aiohttp's raw + ``OSError: [Errno 98] address already in use`` traceback escape. The + message names the port and the one-liners to find and stop the + offender (almost always a stale deckd instance) so a Vite dev server + left pointing at a dead backend is diagnosable at a glance rather than + a wall of asyncio frames. ``__main__`` catches this and exits 1 with + just the message. + """ + + def __init__(self, host: str, port: int) -> None: + self.host = host + self.port = port + super().__init__( + f"cannot bind {host}:{port} — another process is already " + f"listening there (usually a stale deckd instance).\n" + f" find it: ss -ltnp | grep {port} (or: pgrep -af bin/deckd)\n" + f" stop it: pkill -f bin/deckd\n" + f" or use another port: deckd --port " + ) + + +# --------------------------------------------------------------------------- +# Sensor-source helpers. A widget can reference sensor sources two ways: a +# ``meter`` binds one (``w.source``); a ``stats`` widget binds several (one +# per entry in ``w.metrics``). These fold both shapes into a single view so +# the subscription/pump code doesn't special-case the widget kind. +# --------------------------------------------------------------------------- + + +def _widget_sources(widget: "Widget") -> list[str]: + """Every sensor source a widget displays, in declaration order.""" + if widget.kind == "meter" and widget.source: + return [widget.source] + if widget.kind == "stats" and widget.metrics: + return [m.source for m in widget.metrics if m.source] + return [] + + +def _widget_uses_source(widget: "Widget", source: str) -> bool: + return source in _widget_sources(widget) + # --------------------------------------------------------------------------- # Host-identity helpers used by /health. Cheap to compute per-request; no @@ -135,6 +194,7 @@ def __init__( focus_backend: "PlatformBackend | None" = None, overlay_dir: Path | None = None, password: str | None = None, + sensor_manager: "SensorManager | None" = None, ) -> None: self.layouts_dir = layouts_dir self.overlay_dir = overlay_dir @@ -155,8 +215,18 @@ def __init__( self.focus_backend = focus_backend self._focus_task: asyncio.Task[None] | None = None self._layouts_task: asyncio.Task[None] | None = None + self._sensor_task: asyncio.Task[None] | None = None self._current_error: str | None = None self._deckd_window_focused = False + # Sensor subscriptions for the active layout. Re-derived whenever + # the active layout changes (focus change or hot reload) so the + # daemon only polls sensors the current view is actually using. + # ``None`` means "use the platform default"; explicit ``None`` + # tests can pass a fake manager. ``_subscribed_sources`` is the + # set we currently hold refcounts on, used to keep + # subscribe/unsubscribe balanced across layout changes. + self.sensors: "SensorManager | None" = sensor_manager + self._subscribed_sources: set[str] = set() # -- layout state -------------------------------------------------------- @@ -197,6 +267,11 @@ def reload_layouts(self) -> None: new_layout = self.layouts.default() self._current_layout = new_layout self._current_error = None + # A layout reload can change which meter sources the active + # layout uses (a meter added in the new YAML, an old one + # removed). Reconcile subscriptions so the manager polls only + # what's now in use. + self._sync_sensor_subscriptions() log.info( "reloaded layouts from %s%s", self.layouts_dir, @@ -253,6 +328,11 @@ async def _on_focus(self, app: "AppInfo") -> None: log.info("focus -> %s (layout=%s)", app, new_app_id) self._current_app_id = new_app_id self._current_layout = new_layout + # Different layouts reference different meter sources (e.g. + # switching from a desktop to a terminal layout that monitors + # something else). Resubscribe so the manager's polling tracks + # the active view. + self._sync_sensor_subscriptions() await self._push_to_all() async def run_focus_watcher(self) -> None: @@ -344,6 +424,172 @@ def start_layouts_watcher(self) -> asyncio.Task[None] | None: self._layouts_task = asyncio.create_task(self.run_layouts_watcher()) return self._layouts_task + # -- sensor pump --------------------------------------------------------- + # + # The :class:`SensorManager` polls sources on its own task and keeps + # the latest reading in ``_last[name]``. The server's only job is to + # notice when a reading changes and push a ``widget_update`` frame + # to every connected session whose active layout has a meter bound + # to that source. Polling frequency is bounded by the source's + # ``interval_s``; the pump itself runs at 100ms so a 1s source + # produces up to one push per second, and we don't notice a + # millisecond late. + + def _meters_for_source(self, source: str) -> list[Widget]: + """Return every widget in the active layout that displays ``source``. + + Covers both single-value ``meter`` widgets (``w.source == source``) + and multi-value ``stats`` widgets (``source`` appears in their + ``metrics``). Used by the pump to know which widget ``id`` to send + in the push. Order matches the layout's declaration order so the + wire is deterministic for tests. + """ + return [ + w + for w in self._current_layout.widgets + if _widget_uses_source(w, source) + ] + + def _active_sources(self) -> set[str]: + """Names of every sensor referenced by a meter in the active layout. + + Excludes unknown source names (no such source registered with + the manager) so a typo in a layout YAML doesn't crash the + pump — the meter just stays stale. The exclusion is best-effort + at this layer; the manager's ``is_available`` check is the + authoritative gate. + """ + sources: set[str] = set() + manager = self.sensors + for w in self._current_layout.widgets: + for name in _widget_sources(w): + if manager is None or manager.has(name): + sources.add(name) + return sources + + def _sync_sensor_subscriptions(self) -> None: + """Reconcile :class:`SensorManager` subscriptions with the active layout. + + Idempotent: calling it back-to-back is a no-op. Drops + subscriptions to sources no longer referenced; adds new ones. + Layouts with no meter widgets clear all subscriptions so the + daemon idles its polling. + """ + manager = self.sensors + if manager is None: + return + wanted = self._active_sources() + for name in self._subscribed_sources - wanted: + manager.unsubscribe(name) + for name in wanted - self._subscribed_sources: + manager.subscribe(name) + self._subscribed_sources = wanted + + async def run_sensor_pump(self) -> None: + """Watch :class:`SensorManager` and push widget_update frames. + + Compares the manager's ``_last`` against the last value pushed + for each ``(source, widget_id)`` pair and emits a frame on + change. Stale-flag flips (``True`` after a sensor error) also + count as a change so the UI can show the unknown treatment. + + A meter bound to an unknown source simply never produces a + push — the client renders no value at all. This is intentional: + the alternative (one push per pump tick with the error + surfaced) would spam a layout YAML typo across the WebSocket + forever. The :meth:`_active_sources` filter is the gate that + keeps unknown sources out of the subscription set in the first + place. + """ + manager = self.sensors + if manager is None: + return + manager.start() + last_pushed: dict[tuple[str, str], tuple[float, bool, str]] = {} + try: + while True: + # Snapshot subscriptions so a layout change that drops a + # source mid-iteration doesn't reach into a stale entry. + for name in list(self._subscribed_sources): + reading = manager.latest(name) + for widget in self._meters_for_source(name): + key = (name, widget.id) + if reading is None: + # No reading yet: don't push until the + # source produces one. Avoids spamming an + # unknown-value frame at connect time when + # the first poll is still pending. + continue + payload = (reading.value, reading.stale, reading.unit) + if last_pushed.get(key) == payload: + continue + # Only cache the payload once a session has + # actually received it. Without this, a pump + # tick that runs before any client is connected + # would burn the first push — the next tick + # (with sessions attached) would see the same + # value as already-pushed and silently skip it, + # leaving the first client staring at an empty + # meter until the value actually changes. + if not await self._broadcast_widget_update(widget, reading): + continue + last_pushed[key] = payload + await asyncio.sleep(0.1) + except asyncio.CancelledError: + return + + async def _broadcast_widget_update( + self, widget: Widget, reading: "SensorReading" + ) -> bool: + """Send a WidgetUpdateMessage to every connected session. + + Returns ``True`` when at least one session actually received + the frame. ``False`` when there are no sessions (a pump tick + that ran before any client connected) or every send errored + out — the pump uses the False return to skip caching the + payload so the next tick still sees the same value as new and + pushes it once a client arrives. + + Stale-connection failures are silently dropped; the session + is already being torn down. ``stale=True`` messages are sent + so the client can stop claiming the value is fresh. + """ + if not self._sessions: + return False + msg = p.WidgetUpdateMessage( + type="widget_update", + id=widget.id, + source=reading.source, + value=reading.value, + unit=reading.unit, + stale=reading.stale, + ) + sent = 0 + dead: list[Session] = [] + for session in list(self._sessions): + try: + await session.send(msg) + sent += 1 + except (ConnectionResetError, RuntimeError, ConnectionError): + dead.append(session) + for session in dead: + self._sessions.discard(session) + return sent > 0 + + def start_sensor_pump(self) -> asyncio.Task[None] | None: + if self.sensors is None or self._sensor_task is not None: + return None + # Make sure the manager is subscribed to whatever the active + # layout uses before the pump task starts polling. ``__init__`` + # deliberately doesn't subscribe (no event loop yet), and + # ``reload_layouts`` / ``_on_focus`` keep the set in sync as + # the layout changes; this call bridges the gap on first start + # so the very first ``widget_update`` push isn't delayed by a + # missing subscription. + self._sync_sensor_subscriptions() + self._sensor_task = asyncio.create_task(self.run_sensor_pump()) + return self._sensor_task + # -- auth ---------------------------------------------------------------- # # Every WebSocket and HTTP control connection must present the shared @@ -493,8 +739,24 @@ async def _ws_handler(self, req: web.Request) -> web.WebSocketResponse: hello = await self._authenticate_ws(ws) if hello is None: log.info("ws auth failed from %s; closing", req.remote) - await ws.send_json({"type": "error", "reason": "unauthorized"}) - await ws.close() + # A client that failed (or abandoned) auth has often already + # closed the socket, so the rejection frame races the teardown. + # Suppress the write-to-closing-transport error: the rejection + # is best-effort and the connection is going away regardless. + with contextlib.suppress(ConnectionError, RuntimeError): + await ws.send_json({"type": "error", "reason": "unauthorized"}) + # Browsers coalesce a data frame with an immediately + # following close frame and drop the data one, so the + # client's onmessage never sees the rejection and it just + # reconnect-loops without ever showing the password gate. + # Two defences: (1) a short beat so the data frame is + # delivered as its own event before the close, and (2) a + # dedicated close code the client also treats as + # unauthorized — the close code survives the race even if + # the frame is still dropped. (A raw ws client tolerates + # the coalescing; a browser does not.) + await asyncio.sleep(WS_AUTH_REJECT_GRACE_S) + await ws.close(code=WS_CLOSE_UNAUTHORIZED, message=b"unauthorized") return ws # Auth consumed the hello, so apply its demo pin before the initial # push. (No-auth clients' hellos arrive via _dispatch instead.) @@ -601,15 +863,22 @@ async def start(self) -> None: runner = web.AppRunner(self.app, access_log=None) await runner.setup() site = web.TCPSite(runner, self.host, self.port) - await site.start() + try: + await site.start() + except OSError as exc: + if exc.errno == errno.EADDRINUSE: + await runner.cleanup() + raise PortInUseError(self.host, self.port) from exc + raise log.info("listening on http://%s:%d (ws=%s/ws)", self.host, self.port, self.host) self._runner = runner self.start_layouts_watcher() + self.start_sensor_pump() while True: await asyncio.sleep(3600) async def stop(self) -> None: - for task in (self._focus_task, self._layouts_task): + for task in (self._focus_task, self._layouts_task, self._sensor_task): if task is not None: task.cancel() try: @@ -621,6 +890,8 @@ async def stop(self) -> None: await self.focus_backend.stop() except Exception as exc: log.debug("focus backend stop failed: %s", exc) + if self.sensors is not None: + self.sensors.stop() runner = getattr(self, "_runner", None) if runner is not None: await runner.cleanup() diff --git a/layouts/default.yaml b/layouts/default.yaml index c656334..a3e9804 100644 --- a/layouts/default.yaml +++ b/layouts/default.yaml @@ -66,6 +66,7 @@ widgets: # Open the auto-detected terminal ($TERMINAL, then a candidate list). # For a specific emulator, use e.g. shell: "tilix" instead. terminal: true + # Send a key combo (issue #16): fires ctrl+t to the focused app. - id: send-key-tab kind: button label: Send Ctrl+T @@ -75,12 +76,36 @@ widgets: grid: [2, 1, 1, 1] action: key: "ctrl+t" - - id: audio-toggle - kind: button - label: VLC Play/Pause - icon: - source: lucide - name: play + # Audio / VLC media control. Commented out — the cell is now home + # to the live CPU meter (the audio use case is rare from a phone + # in practice, and the daemon already sends widget_update frames + # for it). Re-enable by removing the leading ``#``s; the grid slot + # below is intentionally vacant so adding it back doesn't shift + # neighbouring widgets. + # - id: audio-toggle + # kind: button + # label: VLC Play/Pause + # icon: + # source: lucide + # name: play + # grid: [3, 2, 1, 1] + # action: + # dbus: "org.mpris.MediaPlayer2.vlc:/org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause" + # Live system stats (issue #40). A bar-less ``stats`` cell showing + # several psutil-backed sensor values at once — CPU + memory here — so + # they work identically on Linux + macOS Intel + Apple Silicon with no + # per-platform install step. The daemon pushes ``widget_update`` frames + # at each source's poll cadence (1s). Easy to grow: add another entry to + # ``metrics`` (each names a daemon-side source; ``label`` is optional and + # defaults to the source name, e.g. cpu_percent -> CPU). For a single + # value with a proportional bar instead, use a ``kind: meter`` widget + # (see README) bound to one ``source``. + - id: system + kind: stats + label: System grid: [3, 1, 1, 1] - action: - dbus: "org.mpris.MediaPlayer2.vlc:/org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause" \ No newline at end of file + metrics: + - source: cpu_percent + label: CPU + - source: mem_percent + label: MEM diff --git a/pyproject.toml b/pyproject.toml index 430d685..81dfed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,12 @@ dependencies = [ "pydantic>=2.6", "dbus-fast>=5", "watchfiles>=1.0", + # psutil backs the daemon's cross-platform sensor sources + # (CPU utilization, memory used, CPU frequency, battery). + # One pure-Python dep that works on Linux + macOS Intel + Apple + # Silicon without per-OS install steps. Wheel-only on every + # supported platform; no compile toolchain needed. + "psutil>=5.9", ] [project.optional-dependencies] diff --git a/tests/test_meters.py b/tests/test_meters.py new file mode 100644 index 0000000..f6bc8d3 --- /dev/null +++ b/tests/test_meters.py @@ -0,0 +1,371 @@ +"""Tests for the meter widget schema (issue #40), the +``widget_update`` protocol message, and the server-side sensor pump.""" +from __future__ import annotations + +import asyncio +import json +from contextlib import asynccontextmanager +from pathlib import Path +from typing import AsyncIterator + +import pytest +import websockets +from aiohttp.test_utils import TestServer +from pydantic import ValidationError + +from conftest import LAYOUTS_DIR, ServerHandle, make_test_server +from deckd.platform import SensorManager, SensorReading, SensorSource +from deckd.protocol import WidgetUpdateMessage + + +class _CpuSource(SensorSource): + """Test sensor: returns the value the test set. + + Models ``cpu_percent`` — the daemon's primary live-stat source + after we dropped ``cpu_temp`` because Apple Silicon has no + stable unprivileged temperature API. The unit is ``%`` rather + than ``°C``; tests that don't care about the unit just assert + on the value. + """ + + name = "cpu_percent" + unit = "%" + interval_s = 0.01 + + def __init__(self, initial: float = 42.0) -> None: + self.value = initial + + def read(self) -> SensorReading: + return SensorReading(source=self.name, value=self.value, unit=self.unit) + + +# --------------------------------------------------------------------------- +# Protocol message shape +# --------------------------------------------------------------------------- + + +def test_widget_update_message_roundtrips() -> None: + msg = WidgetUpdateMessage( + type="widget_update", id="cpu", source="cpu_percent", value=58.4, unit="%" + ) + data = json.loads(json.dumps(msg.model_dump())) + assert data["type"] == "widget_update" + assert data["id"] == "cpu" + assert data["source"] == "cpu_percent" + assert data["value"] == 58.4 + assert data["unit"] == "%" + assert data["stale"] is False + + +def test_widget_update_message_rejects_unknown_type() -> None: + with pytest.raises(ValidationError): + WidgetUpdateMessage(type="nope", id="x", source="s", value=0, unit="") # type: ignore[arg-type] + + +def test_widget_update_message_default_stale_false() -> None: + msg = WidgetUpdateMessage(type="widget_update", id="x", source="s", value=0, unit="") + assert msg.stale is False + + +# --------------------------------------------------------------------------- +# Layout schema: meter widget +# --------------------------------------------------------------------------- + + +def test_meter_widget_loads_with_source() -> None: + from deckd.layouts import load_layout + + yaml = """ +match: + - default +widgets: + - id: cpu + kind: meter + source: cpu_percent + min: 0 + max: 100 + grid: [0, 0, 1, 1] +""" + p = Path("/tmp/_meter_layout.yaml") + p.write_text(yaml) + try: + layout = load_layout(p) + w = layout.widgets[0] + assert w.kind == "meter" + assert w.source == "cpu_percent" + assert w.min == 0 + assert w.max == 100 + finally: + p.unlink() + + +def test_meter_widget_without_source_is_rejected() -> None: + from deckd.layouts import load_layout + + yaml = """ +match: + - default +widgets: + - id: cpu + kind: meter + grid: [0, 0, 1, 1] +""" + p = Path("/tmp/_meter_layout_bad.yaml") + p.write_text(yaml) + try: + with pytest.raises(SystemExit): + load_layout(p) + finally: + p.unlink() + + +def test_meter_widget_with_inverted_range_is_rejected() -> None: + from deckd.layouts import load_layout + + yaml = """ +match: + - default +widgets: + - id: cpu + kind: meter + source: cpu_percent + min: 100 + max: 50 + grid: [0, 0, 1, 1] +""" + p = Path("/tmp/_meter_layout_bad2.yaml") + p.write_text(yaml) + try: + with pytest.raises(SystemExit): + load_layout(p) + finally: + p.unlink() + + +# --------------------------------------------------------------------------- +# Server pump: subscribe/unsubscribe + push +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def ws_connected_no_auth(port: int) -> AsyncIterator[websockets.WebSocketClientProtocol]: + async with websockets.connect(f"ws://127.0.0.1:{port}/ws") as ws: + yield ws + + +@pytest.fixture +def meter_layout(tmp_path: Path) -> Path: + """A default layout with a meter bound to cpu_percent.""" + p = tmp_path / "default.yaml" + p.write_text( + """ +match: + - default +widgets: + - id: cpu + kind: meter + source: cpu_percent + min: 0 + max: 100 + grid: [0, 0, 1, 1] +""" + ) + return tmp_path + + +async def test_server_pumps_widget_update_to_session(meter_layout: Path) -> None: + src = _CpuSource(initial=42.0) + mgr = SensorManager([src]) + server, _, _, _ = make_test_server(layouts_dir=meter_layout, password=None) + server.sensors = mgr + # Re-sync now that the manager is installed (make_test_server + # builds the Server before we swap sensors in). + server._sync_sensor_subscriptions() # type: ignore[attr-defined] + server.start_sensor_pump() + + test_server = TestServer(server.app, host="127.0.0.1") + await test_server.start_server() + try: + # Connect a client; the server's initial layout push should + # include the meter widget, and the sensor pump should send a + # widget_update frame shortly after. + async with ws_connected_no_auth(test_server.port) as ws: + initial = json.loads(await asyncio.wait_for(ws.recv(), timeout=2)) + assert initial["type"] == "layout" + assert any( + w["kind"] == "meter" and w.get("source") == "cpu_percent" + for w in initial["widgets"] + ) + # Wait for the pump to push at least one update. + update = None + for _ in range(50): + try: + raw = await asyncio.wait_for(ws.recv(), timeout=0.2) + except asyncio.TimeoutError: + continue + msg = json.loads(raw) + if msg.get("type") == "widget_update": + update = msg + break + assert update is not None, "no widget_update frame received" + assert update["id"] == "cpu" + assert update["source"] == "cpu_percent" + assert update["unit"] == "%" + assert update["value"] == pytest.approx(42.0) + finally: + await server.stop() + await test_server.close() + + +async def test_server_pump_skips_unknown_sources(meter_layout: Path) -> None: + """A meter bound to an unregistered source must not crash the pump.""" + src = _CpuSource(initial=10.0) + mgr = SensorManager([src]) + server, _, _, _ = make_test_server(layouts_dir=meter_layout, password=None) + server.sensors = mgr + # Build a manager that does NOT have cpu_percent — the meter in the + # layout references it but the manager only carries an unrelated + # source. Subscriptions get filtered out by _active_sources; the + # pump should run idle. + unrelated = SensorSource() + unrelated.name = "not_cpu" + unrelated.unit = "" + mgr2 = SensorManager([unrelated]) + server.sensors = mgr2 + server._sync_sensor_subscriptions() # type: ignore[attr-defined] + server.start_sensor_pump() + + test_server = TestServer(server.app, host="127.0.0.1") + await test_server.start_server() + try: + async with ws_connected_no_auth(test_server.port) as ws: + await asyncio.wait_for(ws.recv(), timeout=2) # initial layout + # The pump polls at 100ms; wait three intervals then + # confirm the test is still alive (no exception in the + # pump loop) and no spam frames are pushed. + await asyncio.sleep(0.4) + try: + raw = await asyncio.wait_for(ws.recv(), timeout=0.1) + except asyncio.TimeoutError: + raw = None + assert raw is None or json.loads(raw).get("type") != "widget_update" + finally: + await server.stop() + await test_server.close() + + +# --------------------------------------------------------------------------- +# stats widget (multi-source, bar-less) +# --------------------------------------------------------------------------- + + +class _MemSource(SensorSource): + """Test sensor modelling ``mem_percent``.""" + + name = "mem_percent" + unit = "%" + interval_s = 0.01 + + def __init__(self, initial: float = 41.0) -> None: + self.value = initial + + def read(self) -> SensorReading: + return SensorReading(source=self.name, value=self.value, unit=self.unit) + + +def test_stats_widget_loads_with_metrics(tmp_path: Path) -> None: + from deckd.layouts import load_layouts + + (tmp_path / "default.yaml").write_text( + """ +match: + - default +widgets: + - id: system + kind: stats + label: System + grid: [0, 0, 1, 1] + metrics: + - source: cpu_percent + label: CPU + - source: mem_percent +""" + ) + w = next(w for w in load_layouts(tmp_path)["default"].widgets if w.kind == "stats") + assert [m.source for m in w.metrics] == ["cpu_percent", "mem_percent"] + assert w.metrics[0].label == "CPU" + assert w.metrics[1].label is None # client derives it + + +def test_stats_widget_without_metrics_is_rejected(tmp_path: Path) -> None: + from deckd.layouts import load_layouts + + (tmp_path / "default.yaml").write_text( + """ +match: + - default +widgets: + - id: system + kind: stats + grid: [0, 0, 1, 1] +""" + ) + with pytest.raises(SystemExit): + load_layouts(tmp_path) + + +@pytest.fixture +def stats_layout(tmp_path: Path) -> Path: + """A default layout with a stats widget bound to two sources.""" + (tmp_path / "default.yaml").write_text( + """ +match: + - default +widgets: + - id: system + kind: stats + label: System + grid: [0, 0, 1, 1] + metrics: + - source: cpu_percent + label: CPU + - source: mem_percent + label: MEM +""" + ) + return tmp_path + + +async def test_stats_widget_pumps_all_its_sources(stats_layout: Path) -> None: + """The pump subscribes to every source a stats widget references and + pushes a widget_update (carrying the source) for each.""" + mgr = SensorManager([_CpuSource(initial=58.0), _MemSource(initial=41.0)]) + server, _, _, _ = make_test_server(layouts_dir=stats_layout, password=None) + server.sensors = mgr + server._sync_sensor_subscriptions() # type: ignore[attr-defined] + server.start_sensor_pump() + + test_server = TestServer(server.app, host="127.0.0.1") + await test_server.start_server() + try: + async with ws_connected_no_auth(test_server.port) as ws: + initial = json.loads(await asyncio.wait_for(ws.recv(), timeout=2)) + assert initial["type"] == "layout" + by_source: dict[str, float] = {} + for _ in range(80): + try: + raw = await asyncio.wait_for(ws.recv(), timeout=0.2) + except asyncio.TimeoutError: + continue + msg = json.loads(raw) + if msg.get("type") == "widget_update": + # Frame carries the stats widget id + the specific source. + assert msg["id"] == "system" + by_source[msg["source"]] = msg["value"] + if {"cpu_percent", "mem_percent"} <= by_source.keys(): + break + assert by_source.get("cpu_percent") == pytest.approx(58.0) + assert by_source.get("mem_percent") == pytest.approx(41.0) + finally: + await server.stop() + await test_server.close() diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..5e29afd --- /dev/null +++ b/tests/test_sensors.py @@ -0,0 +1,215 @@ +"""Tests for the psutil-backed sensor sources and the ``SensorManager`` +abstraction. + +These exercise the cross-platform sources the daemon ships by +default. The Linux-specific ``/sys/class/thermal`` reader and the +macOS shell-out readers (``osx-cpu-temp`` / ``istats``) have been +removed — Apple Silicon doesn't expose CPU temperature in any +unprivileged, documented way, so we standardise on metrics psutil +covers identically on every platform. +""" +from __future__ import annotations + +import asyncio + +import pytest + +from deckd.platform import ( + PsutilCpuPercentSensorSource, + PsutilMemoryPercentSensorSource, + SensorManager, + SensorReading, + SensorSource, +) + + +class _FixedSource(SensorSource): + """Deterministic sensor for tests — always returns the same reading.""" + + name = "test_fixed" + unit = "x" + interval_s = 0.01 + + def __init__(self, value: float, available: bool = True) -> None: + self._value = value + self._available = available + + def is_available(self) -> bool: + return self._available + + def read(self) -> SensorReading: + return SensorReading(source=self.name, value=self._value, unit=self.unit) + + +class _FlappingSource(SensorSource): + """Returns alternating values so we can see push-on-change filtering.""" + + name = "test_flap" + unit = "v" + interval_s = 0.01 + + def __init__(self, values: list[float]) -> None: + self._values = list(values) + self._i = 0 + + def read(self) -> SensorReading: + v = self._values[self._i % len(self._values)] + self._i += 1 + return SensorReading(source=self.name, value=v, unit=self.unit) + + +class _FailingSource(SensorSource): + name = "test_fail" + unit = "?" + interval_s = 0.01 + + def read(self) -> SensorReading | None: + return None + + +# --------------------------------------------------------------------------- +# SensorManager behaviour (regression-tested from the original +# Linux-thermal-zone implementation; the manager itself didn't change) +# --------------------------------------------------------------------------- + + +async def test_manager_polls_subscribed_source() -> None: + src = _FixedSource(42.0) + mgr = SensorManager([src]) + mgr.subscribe("test_fixed") + for _ in range(20): + latest = mgr.latest("test_fixed") + if latest is not None: + break + await asyncio.sleep(src.interval_s) + assert latest is not None + assert latest.value == 42.0 + assert latest.unit == "x" + assert latest.stale is False + mgr.unsubscribe("test_fixed") + + +async def test_manager_drops_subscriptions_on_zero_refcount() -> None: + src = _FixedSource(1.0) + mgr = SensorManager([src]) + mgr.subscribe("test_fixed") + assert "test_fixed" in mgr._tasks # type: ignore[attr-defined] + mgr.unsubscribe("test_fixed") + await asyncio.sleep(0) + assert "test_fixed" not in mgr._tasks # type: ignore[attr-defined] + + +async def test_manager_marks_reading_stale_when_source_returns_none() -> None: + src = _FailingSource() + mgr = SensorManager([src]) + mgr._last["test_fail"] = SensorReading(source="test_fail", value=10, unit="?") # type: ignore[attr-defined] + mgr.subscribe("test_fail") + await asyncio.sleep(src.interval_s * 2) + latest = mgr.latest("test_fail") + assert latest is not None + assert latest.value == 10 + assert latest.stale is True + mgr.unsubscribe("test_fail") + + +async def test_manager_refcount_is_balanced() -> None: + src = _FixedSource(7.0) + mgr = SensorManager([src]) + mgr.subscribe("test_fixed") + mgr.subscribe("test_fixed") + mgr.subscribe("test_fixed") + mgr.unsubscribe("test_fixed") + mgr.unsubscribe("test_fixed") + mgr.unsubscribe("test_fixed") + assert mgr._subscribers["test_fixed"] == 0 # type: ignore[attr-defined] + mgr.unsubscribe("test_fixed") + assert mgr._subscribers["test_fixed"] == 0 # type: ignore[attr-defined] + + +async def test_manager_unknown_source_is_ignored() -> None: + mgr = SensorManager([_FixedSource(1.0)]) + mgr.subscribe("does_not_exist") + assert mgr._tasks == {} # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# psutil-backed sources (the actual ship targets) +# --------------------------------------------------------------------------- + + +def test_psutil_cpu_percent_source_is_always_available() -> None: + # psutil ships wheels for every platform we support, so the + # source never reports unavailable on a working install. + src = PsutilCpuPercentSensorSource() + assert src.is_available() is True + + +def test_psutil_memory_percent_source_is_always_available() -> None: + src = PsutilMemoryPercentSensorSource() + assert src.is_available() is True + + +def test_psutil_cpu_percent_source_returns_reading_in_zero_to_hundred() -> None: + # First ``cpu_percent(interval=None)`` returns 0.0 (no baseline); + # we accept that as a valid reading rather than a None. + src = PsutilCpuPercentSensorSource() + reading = src.read() + assert reading is not None + assert reading.source == "cpu_percent" + assert reading.unit == "%" + assert reading.value == pytest.approx(0.0) or reading.value >= 0 + + +def test_psutil_cpu_percent_source_returns_subsequent_reading() -> None: + # After the first call the manager has a baseline; the second + # call returns a delta. We don't assert it's nonzero — could be + # 0.0 on an idle box — but it must be a valid number. + src = PsutilCpuPercentSensorSource() + src.read() # establish baseline + reading = src.read() + assert reading is not None + assert 0.0 <= reading.value <= 100.0 + + +def test_psutil_memory_percent_source_returns_reading_in_zero_to_hundred() -> None: + src = PsutilMemoryPercentSensorSource() + reading = src.read() + assert reading is not None + assert reading.source == "mem_percent" + assert reading.unit == "%" + assert 0.0 <= reading.value <= 100.0 + + +def test_psutil_sources_have_one_second_poll_interval() -> None: + # The meter cell renders a smooth transition animation on every + # push; faster than 1s looks jittery, slower than 1s feels + # dead. Pinned here so a future refactor doesn't silently change + # the cadence. + assert PsutilCpuPercentSensorSource.interval_s == 1.0 + assert PsutilMemoryPercentSensorSource.interval_s == 1.0 + + +def test_default_sensor_manager_registers_psutil_sources() -> None: + # The default factory must produce the cross-platform set so a + # fresh daemon on a fresh box has working meters without any + # extra setup. Pinned: if anyone adds a Linux-only or macOS-only + # source to the default list, this test forces them to update + # the cross-platform contract too. + from deckd.platform import default_sensor_manager + + mgr = default_sensor_manager() + assert "cpu_percent" in mgr.sources + assert "mem_percent" in mgr.sources + + +def test_default_sensor_manager_contains_no_legacy_temperature_sources() -> None: + # Regression guard: the old ``cpu_temp`` source (Linux thermal + # zone + macOS shell-out) used to ship by default. It's gone + # now — Apple Silicon can't read it, the macOS shell-out + # helpers are unmaintained, and psutil covers the metrics we + # actually want. This test pins the removal so a future "let's + # add cpu_temp back" PR has to think twice about it. + from deckd.platform import default_sensor_manager + + mgr = default_sensor_manager() + assert "cpu_temp" not in mgr.sources diff --git a/tests/test_server.py b/tests/test_server.py index 6b742d5..6310003 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -826,3 +826,41 @@ async def test_layouts_watcher_bad_edit_pushes_error_not_crash( recovered = await _next_layout(ws, timeout=5.0) assert recovered.get("error") in (None, "") assert recovered["widgets"][0]["id"] == "home-v2" + + +async def test_start_raises_port_in_use_not_raw_oserror(tmp_path: Path) -> None: + """A busy listen port fails fast with ``PortInUseError`` (a clean, + actionable message) instead of aiohttp's raw ``OSError: [Errno 98]``. + + Regression guard for the stale-daemon footgun: a second deckd (or any + process on the port) must not dump an asyncio traceback that reads as a + mysterious crash — ``__main__`` relies on this typed error to exit 1 + with the find/stop hint. + """ + import socket as _socket + + from conftest import make_test_server + from deckd.server import PortInUseError + + (tmp_path / "default.yaml").write_text( + "match:\n - default\nwidgets: []\n" + ) + + # Hold a real listener so the daemon's bind is guaranteed to collide. + blocker = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + blocker.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) + blocker.bind(("127.0.0.1", 0)) + blocker.listen(1) + busy_port = blocker.getsockname()[1] + + try: + server, *_ = make_test_server(layouts_dir=tmp_path) + server.host = "127.0.0.1" + server.port = busy_port + with pytest.raises(PortInUseError) as excinfo: + await server.start() + assert excinfo.value.port == busy_port + assert "pkill -f bin/deckd" in str(excinfo.value) + await server.stop() + finally: + blocker.close() diff --git a/tests/test_server_auth.py b/tests/test_server_auth.py index fde8c66..ef0983c 100644 --- a/tests/test_server_auth.py +++ b/tests/test_server_auth.py @@ -51,8 +51,14 @@ async def test_ws_without_password_rejected() -> None: async with websockets.connect(f"ws://127.0.0.1:{port}/ws") as ws: await ws.send(json.dumps({"type": "hello", "client": "web"})) assert await _recv(ws) == {"type": "error", "reason": "unauthorized"} - with pytest.raises(websockets.ConnectionClosed): + # The rejection also closes with a dedicated code (4401) that the + # client keys off, because browsers can drop the data frame above + # when it's coalesced with the close. Client and daemon must agree + # on 4401 (see socket.ts UNAUTHORIZED_CLOSE_CODE); assert it here so + # the two can't silently drift. + with pytest.raises(websockets.ConnectionClosed) as excinfo: await _recv(ws) + assert excinfo.value.rcvd.code == 4401 async def test_ws_with_wrong_password_rejected() -> None: