Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>: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.
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>.<tailnet>.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 `<host>.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://<host>.<tailnet>.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
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions client/screenshot-meters.mjs
Original file line number Diff line number Diff line change
@@ -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();
67 changes: 64 additions & 3 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -49,8 +50,66 @@ export function App() {
const [layout, setLayout] = useState<ServerLayout | null>(demoLayout);
const [view, setView] = useState<View>("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<string>();
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).
Expand Down Expand Up @@ -173,6 +232,8 @@ export function App() {
onJogEnd={jogEnd}
scrollScale={scroll.scale}
scrollInvert={scroll.invert}
meterReadings={meter.readings ?? undefined}
labelScale={labelScale.scale}
/>
) : (
<div className="empty">waiting for daemon…</div>
Expand Down
34 changes: 34 additions & 0 deletions client/src/ButtonGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string, MeterReading>;
/** 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;
Expand Down Expand Up @@ -46,6 +56,8 @@ export function ButtonGrid({
scrollScale,
scrollInvert,
orientation: orientationOverride,
meterReadings,
labelScale,
}: Props) {
const autoOrientation = useOrientation();
const orientation = orientationOverride ?? autoOrientation;
Expand Down Expand Up @@ -89,6 +101,28 @@ export function ButtonGrid({
/>
);
}
if (w.kind === "meter") {
return (
<MeterCell
key={w.id}
widget={w}
reading={(w.source ? meterReadings?.[w.source] : null) ?? null}
style={style}
labelScale={labelScale ?? 1}
/>
);
}
if (w.kind === "stats") {
return (
<StatsCell
key={w.id}
widget={w}
readings={meterReadings ?? {}}
style={style}
labelScale={labelScale ?? 1}
/>
);
}
const buttonStyle: CSSProperties = w.color
? { ...style, backgroundColor: w.color }
: style;
Expand Down
93 changes: 93 additions & 0 deletions client/src/MeterCell.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MeterCell widget={widget} reading={reading} />);
}

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%");
});
});
Loading
Loading