diff --git a/README.md b/README.md index e966174..2994337 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Pre-alpha, but usable day-to-day. Here's what deckd can do today and what's stil - [x] **Per-device tuning** — a settings panel for scroll speed/direction, trackpad sensitivity, content and text size, bar sizes, and keep-screen-awake, all saved on the device. - [x] **Addressable client views** — the layout, manual control, now playing, settings, editor, and running-windows views have their own URL paths for deep links and browser history. - [x] **Keep screen awake** while the surface is in use. -- [x] **Install to home screen** (PWA) for a fullscreen, app-like surface. +- [x] **Install to home screen** (PWA) for a fullscreen, app-like surface. The daemon serves the shell uncached and the client auto-reloads when the served bundle changes, so installed clients can't get stuck on a stale UI. See [ADR-0011](docs/adr/0011-client-freshness.md). - [x] **Password auth** — every client authenticates with a shared password (on by default; `--no-auth` disables it for local development). See [Client auth](docs/GUIDE.md#client-auth). - [x] **LAN scope control** — bind the daemon to a specific network interface (`--bind iface:wlan0`) or literal address; defaults to localhost-only for safety. - [x] **Accessibility** — keyboard navigation, visible focus ring, screen-reader landmarks and live announcements, larger controls, high contrast, and reduced motion. diff --git a/client/src/main.tsx b/client/src/main.tsx index eaa7025..d2fc30e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,9 +1,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; +import { startUpdateCheck } from "./update-check"; import "./fonts"; import "./style.css"; +// Watch the daemon's client fingerprint and hard-reload when the served +// bundle changes, so an installed PWA can't drive a stale UI indefinitely. +startUpdateCheck(); + ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/client/src/socket.ts b/client/src/socket.ts index 9d8181a..fb1facf 100644 --- a/client/src/socket.ts +++ b/client/src/socket.ts @@ -215,7 +215,7 @@ export function useDeckdSocket( return { status, send, authenticate, deauthenticate, hasPassword }; } -function resolve_health_url(): string { +export function resolve_health_url(): string { return new URL("/health", window.location.href).toString(); } diff --git a/client/src/update-check.test.ts b/client/src/update-check.test.ts new file mode 100644 index 0000000..c59fc69 --- /dev/null +++ b/client/src/update-check.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + UPDATE_POLL_MS, + browserStorage, + probeForUpdate, + startUpdateCheck, + type BuildStorage, +} from "./update-check"; + +function jsonResponse(body: unknown, ok = true): Response { + return { ok, json: async () => body } as unknown as Response; +} + +function memoryStorage(initial: string | null = null): BuildStorage & { peek(): string | null } { + let value = initial; + return { + get: () => value, + set: (next: string) => { + value = next; + }, + peek: () => value, + }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("probeForUpdate", () => { + it("records the first build it sees without reloading", async () => { + const storage = memoryStorage(); + const reload = vi.fn(); + const result = await probeForUpdate({ + healthUrl: "/health", + fetchImpl: (async () => jsonResponse({ client_build: "v1" })) as unknown as typeof fetch, + reload, + storage, + }); + + expect(result).toBe("recorded"); + expect(storage.peek()).toBe("v1"); + expect(reload).not.toHaveBeenCalled(); + }); + + it("leaves an unchanged build alone", async () => { + const reload = vi.fn(); + const result = await probeForUpdate({ + healthUrl: "/health", + fetchImpl: (async () => jsonResponse({ client_build: "v1" })) as unknown as typeof fetch, + reload, + storage: memoryStorage("v1"), + }); + + expect(result).toBe("unchanged"); + expect(reload).not.toHaveBeenCalled(); + }); + + it("records the new build before reloading so a blocked reload can't loop", async () => { + const storage = memoryStorage("v1"); + const order: string[] = []; + const result = await probeForUpdate({ + healthUrl: "/health", + fetchImpl: (async () => jsonResponse({ client_build: "v2" })) as unknown as typeof fetch, + reload: () => { + order.push(`reload at ${storage.peek()}`); + }, + storage: { + get: storage.get, + set: (next: string) => { + order.push(`set ${next}`); + storage.set(next); + }, + }, + }); + + expect(result).toBe("reloaded"); + expect(order).toEqual(["set v2", "reload at v2"]); + }); + + it("passes cache: no-store so the health check never reads a cached body", async () => { + const fetchImpl = vi.fn(async (_url: unknown, _init?: RequestInit) => jsonResponse({})); + await probeForUpdate({ + healthUrl: "/health", + fetchImpl: fetchImpl as unknown as typeof fetch, + reload: vi.fn(), + storage: memoryStorage(), + }); + + expect(fetchImpl).toHaveBeenCalledWith("/health", { cache: "no-store" }); + }); + + it("returns unknown for a dev daemon with no client_build field", async () => { + const storage = memoryStorage(); + const reload = vi.fn(); + const result = await probeForUpdate({ + healthUrl: "/health", + fetchImpl: (async () => jsonResponse({ ok: true })) as unknown as typeof fetch, + reload, + storage, + }); + + expect(result).toBe("unknown"); + expect(storage.peek()).toBeNull(); + expect(reload).not.toHaveBeenCalled(); + }); + + it("returns unknown for a non-string fingerprint", async () => { + const storage = memoryStorage(); + const result = await probeForUpdate({ + healthUrl: "/health", + fetchImpl: (async () => jsonResponse({ client_build: 42 })) as unknown as typeof fetch, + reload: vi.fn(), + storage, + }); + + expect(result).toBe("unknown"); + expect(storage.peek()).toBeNull(); + }); + + it("returns unknown for a failed request", async () => { + const storage = memoryStorage(); + const result = await probeForUpdate({ + healthUrl: "/health", + fetchImpl: (async () => jsonResponse({}, false)) as unknown as typeof fetch, + reload: vi.fn(), + storage, + }); + + expect(result).toBe("unknown"); + expect(storage.peek()).toBeNull(); + }); + + it("returns unknown when the daemon is offline", async () => { + const result = await probeForUpdate({ + healthUrl: "/health", + fetchImpl: (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch, + reload: vi.fn(), + storage: memoryStorage(), + }); + + expect(result).toBe("unknown"); + }); +}); + +describe("startUpdateCheck", () => { + it("polls on the interval and reloads when the served bundle changes", async () => { + vi.useFakeTimers(); + let build = "v1"; + const fetchImpl = vi.fn(async () => jsonResponse({ client_build: build })); + const reload = vi.fn(); + const stop = startUpdateCheck({ + intervalMs: 1000, + deps: { + healthUrl: "/health", + fetchImpl: fetchImpl as unknown as typeof fetch, + reload, + storage: memoryStorage(), + }, + }); + + // Immediate check records v1. + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(0); + + await vi.advanceTimersByTimeAsync(1000); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(reload).not.toHaveBeenCalled(); + + build = "v2"; + await vi.advanceTimersByTimeAsync(1000); + expect(reload).toHaveBeenCalledTimes(1); + + stop(); + await vi.advanceTimersByTimeAsync(5000); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it("re-checks when an installed PWA resumes", async () => { + vi.useFakeTimers(); + const fetchImpl = vi.fn(async () => jsonResponse({ client_build: "v1" })); + const stop = startUpdateCheck({ + intervalMs: 60_000, + deps: { + healthUrl: "/health", + fetchImpl: fetchImpl as unknown as typeof fetch, + reload: vi.fn(), + storage: memoryStorage(), + }, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(0); + + window.dispatchEvent(new Event("pageshow")); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledTimes(2); + + stop(); + window.dispatchEvent(new Event("pageshow")); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("defaults the poll interval to 30s", async () => { + vi.useFakeTimers(); + const fetchImpl = vi.fn(async () => jsonResponse({ client_build: "v1" })); + const stop = startUpdateCheck({ + deps: { + healthUrl: "/health", + fetchImpl: fetchImpl as unknown as typeof fetch, + reload: vi.fn(), + storage: memoryStorage(), + }, + }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(UPDATE_POLL_MS - 1); + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchImpl).toHaveBeenCalledTimes(2); + stop(); + }); + + it("falls back to an in-memory store when localStorage throws", () => { + const spy = vi.spyOn(window.localStorage, "getItem").mockImplementation(() => { + throw new Error("private mode"); + }); + const storage = browserStorage(); + expect(storage.get()).toBeNull(); + storage.set("v1"); + expect(storage.get()).toBe("v1"); + spy.mockRestore(); + }); +}); diff --git a/client/src/update-check.ts b/client/src/update-check.ts new file mode 100644 index 0000000..1d72f04 --- /dev/null +++ b/client/src/update-check.ts @@ -0,0 +1,124 @@ +import { resolve_health_url } from "./socket"; + +/** How often a live client re-reads ``/health`` looking for a new bundle. */ +export const UPDATE_POLL_MS = 30_000; + +const STORAGE_KEY = "deckd.build"; + +export type BuildStorage = { + get(): string | null; + set(value: string): void; +}; + +export type UpdateProbeDeps = { + healthUrl: string; + fetchImpl: typeof fetch; + reload: () => void; + storage: BuildStorage; +}; + +export type UpdateProbeResult = "reloaded" | "recorded" | "unchanged" | "unknown"; + +/** One freshness check: read the daemon's client fingerprint, compare it + * with the last one this browser saw, and reload on a change. + * + * Pure in its dependencies (fetch / storage / reload are injected) so the + * decision table is unit-testable without a daemon or a browser reload. + * ``unknown`` covers every "can't tell" case — offline, no daemon, a dev + * server without ``--client-dist`` (no ``client_build`` field) — and always + * means "leave the page alone". */ +export async function probeForUpdate(deps: UpdateProbeDeps): Promise { + let build: string | null = null; + try { + const res = await deps.fetchImpl(deps.healthUrl, { cache: "no-store" }); + if (!res.ok) return "unknown"; + const body = (await res.json()) as { client_build?: unknown }; + if (typeof body.client_build === "string" && body.client_build) { + build = body.client_build; + } + } catch { + return "unknown"; + } + if (build === null) return "unknown"; + + const seen = deps.storage.get(); + if (seen === build) return "unchanged"; + // Record before reloading: if the reload is blocked (browser prompt, + // interrupted navigation) a later check must not reload again forever. + deps.storage.set(build); + if (seen === null) return "recorded"; + deps.reload(); + return "reloaded"; +} + +/** ``localStorage`` when usable, an in-memory shim when not (Safari private + * mode and some embedded WebViews throw on access). A shim only loses the + * cross-session memory; within one page life the check still works. */ +export function browserStorage(): BuildStorage { + try { + const store = window.localStorage; + store.getItem(STORAGE_KEY); + return { + get: () => store.getItem(STORAGE_KEY), + set: (value) => store.setItem(STORAGE_KEY, value), + }; + } catch { + let memory: string | null = null; + return { + get: () => memory, + set: (value) => { + memory = value; + }, + }; + } +} + +export type UpdateCheckOptions = { + intervalMs?: number; + deps?: Partial; +}; + +/** Keep the running bundle fresh for the life of the page. + * + * Polls on an interval, and also checks the moment an installed PWA comes + * back from suspension (``pageshow`` / ``visibilitychange``) or regains + * connectivity — the cases where a phone surfaces a long-cached shell. + * Returns a teardown function. */ +export function startUpdateCheck(options: UpdateCheckOptions = {}): () => void { + const intervalMs = options.intervalMs ?? UPDATE_POLL_MS; + const deps: UpdateProbeDeps = { + healthUrl: options.deps?.healthUrl ?? resolve_health_url(), + fetchImpl: options.deps?.fetchImpl ?? fetch, + reload: options.deps?.reload ?? (() => window.location.reload()), + storage: options.deps?.storage ?? browserStorage(), + }; + + let inFlight = false; + const checkNow = async () => { + // A slow /health must not stack requests behind a 30s interval. + if (inFlight) return; + inFlight = true; + try { + await probeForUpdate(deps); + } finally { + inFlight = false; + } + }; + + const onResume = () => { + if (!document.hidden) void checkNow(); + }; + + const timer = window.setInterval(() => void checkNow(), intervalMs); + window.addEventListener("pageshow", onResume); + window.addEventListener("online", onResume); + document.addEventListener("visibilitychange", onResume); + void checkNow(); + + return () => { + window.clearInterval(timer); + window.removeEventListener("pageshow", onResume); + window.removeEventListener("online", onResume); + document.removeEventListener("visibilitychange", onResume); + }; +} diff --git a/daemon/deckd/__main__.py b/daemon/deckd/__main__.py index 3830c18..5bd7802 100644 --- a/daemon/deckd/__main__.py +++ b/daemon/deckd/__main__.py @@ -2,6 +2,7 @@ import argparse import asyncio +import hashlib import logging import os import signal @@ -90,6 +91,85 @@ def _build_sinks() -> tuple[object | None, ScrollSink, KeySink]: return None, LoggingScrollSink(), LoggingKeySink() +def _client_build_id(dist: Path) -> str: + """Stable fingerprint of a built client tree. + + Hashes every file's relative path and bytes, so a rebuilt bundle always + yields a new id (Vite renames content-hashed assets, and the walk covers + unhashed files like ``manifest.json`` too). The running client compares + this value against the one it saw last and hard-reloads when it changes — + see ``client/src/update-check.ts``. + """ + digest = hashlib.sha256() + for path in sorted(p for p in dist.rglob("*") if p.is_file()): + digest.update(path.relative_to(dist).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest()[:12] + + +@web.middleware +async def _client_cache_headers( + request: web.Request, handler: Callable[[web.Request], Any] +) -> web.StreamResponse: + """Force a fresh shell without giving up content-addressed caching. + + The SPA HTML, manifest, and other unhashed files answer ``no-store`` so a + phone (including an installed PWA) must re-fetch them from the daemon. + ``/assets/*`` files are content-hashed by Vite — a changed byte means a + changed filename — so those stay immutable and cheap to cache. A handler + that sets its own ``Cache-Control`` is never overridden. + """ + response = await handler(request) + route = request.match_info.route + if not ( + getattr(route, "name", None) in ("client_spa", "client_spa_fallback") + or isinstance(getattr(route, "resource", None), web.StaticResource) + ): + return response + if "Cache-Control" not in response.headers: + if request.path.startswith("/assets/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + else: + response.headers["Cache-Control"] = "no-store" + return response + + +def _add_client_routes(server: Server, dist: Path) -> None: + """Serve the built client at ``/`` with the freshness headers. + + Split out of ``main`` so tests can boot an app around a throwaway dist + directory without the full argparse surface. + """ + index_text = (dist / "index.html").read_text() + + async def spa(_req: web.Request) -> web.Response: + # 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. + # + # The fallback regex must not swallow real files: ``[^.]+$`` admits + # deep links like ``/settings`` and ``/now-playing`` (client view + # routes, ADR-0011's reload target) while letting dotted paths + # (``/assets/*``, ``/manifest.json``) fall through to the static + # handler. Reserved daemon routes are excluded explicitly. + server.app.router.add_get("/", spa, name="client_spa") + server.app.router.add_get( + "/{path:(?!ws$|health$|reload$|layout($|/))[^.]+$}", + spa, + name="client_spa_fallback", + ) + server.app.router.add_static("/", dist, show_index=False, append_version=False) + server.app.middlewares.append(_client_cache_headers) + + def main() -> None: from .bind import DEFAULT_BIND @@ -269,6 +349,14 @@ def _make_dbus_bus(bus_type): len(seed), ) + # Fingerprint the served client so ``/health`` can tell a running client + # whether the bundle it loaded is still the one on disk (issue: stale + # installed PWAs). Recomputed per daemon start — the deploy path is + # rebuild + restart. + client_build = ( + _client_build_id(args.client_dist) if args.client_dist is not None else None + ) + server = Server( layouts_dir=args.layouts_dir, bind=args.bind if args.bind is not None else list(DEFAULT_BIND), @@ -286,26 +374,11 @@ def _make_dbus_bus(bus_type): password=password, sensor_manager=default_sensor_manager(), media_manager=MediaManager(), + client_build=client_build, ) if args.client_dist is not None: - index_text = (args.client_dist / "index.html").read_text() - - async def spa(_req): - # 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) + _add_client_routes(server, args.client_dist) try: asyncio.run(_run(server)) diff --git a/daemon/deckd/server.py b/daemon/deckd/server.py index 05d2f2e..7e00aad 100644 --- a/daemon/deckd/server.py +++ b/daemon/deckd/server.py @@ -573,9 +573,15 @@ def __init__( media_manager: MediaManager | None = None, mpris_backend: MprisBackend | None = None, mpris_art_resolver: "Callable[[str | None], Awaitable[tuple[str, bytes] | None]] | None" = None, + client_build: str | None = None, ) -> None: self.layouts_dir = layouts_dir self.overlay_dir = overlay_dir + # Fingerprint of the client bundle the daemon serves (``--client-dist``), + # surfaced on ``/health`` so a running client can spot a stale bundle + # and reload. ``None`` when no client dist is configured (dev server, + # headless daemon) — the client treats that as "don't check". + self.client_build = client_build # ``None``/empty disables auth entirely (every connection is treated # as authorized). When set, every client must present it. self.password = password or None @@ -1769,6 +1775,10 @@ async def _health(self, _req: web.Request) -> web.Response: "addresses": [f"{_url_host_for_log(b)}:{port}" for b in resolved_for_url], "url": _bind_url_for(resolved_for_url, port), } + # Present only when the daemon serves a built client; the client uses + # it to notice that the bundle on the daemon changed under it. + if self.client_build is not None: + body["client_build"] = self.client_build return web.json_response(body, headers={"Access-Control-Allow-Origin": "*"}, ) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index a7c6da2..7918542 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -261,6 +261,8 @@ That's why the URL you see in devtools is `wss://..ts.net:5173/ws **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. +**Staying fresh (and never cached).** The phone is never left holding a stale bundle: the daemon serves `index.html`, `manifest.json`, and the icon with `Cache-Control: no-store`, keeps Vite's content-hashed `/assets/*` files immutable, and reports a fingerprint of the served bundle at `/health`. A running client re-checks that fingerprint every 30 seconds and whenever the installed PWA resumes (or regains connectivity), then reloads itself if the daemon's bundle changed — so `just build-client` + daemon restart rolls every phone forward automatically. There is no service worker and no offline mode; see [ADR-0011](adr/0011-client-freshness.md). + ### Client chrome Every layout renders inside a persistent **chrome** shell that the daemon does not know about: diff --git a/docs/adr/0011-client-freshness.md b/docs/adr/0011-client-freshness.md new file mode 100644 index 0000000..2615943 --- /dev/null +++ b/docs/adr/0011-client-freshness.md @@ -0,0 +1,106 @@ +# Client freshness: fingerprinted bundle + auto-reload, no offline cache + +The daemon serves the built client itself (`--client-dist`), so in a +normal deployment the two ship together. The failure mode this ADR +fixes is the *cached* half of that pair: a phone — especially an +installed PWA — can keep running the old shell against a rebuilt +daemon, and because there is no wire version negotiation (see +"Deliberately not here" below) the stale surface can drive an +incompatible protocol without ever being noticed. + +The property we want is narrow and testable: **a running client never +keeps using a bundle different from the one the daemon is serving.** +It is not general protocol compatibility; it is shell freshness. + +## Decisions + +### No service worker + +deckd installs as a PWA with just a manifest and icon: Chrome dropped +the service-worker install requirement, and iOS "Add to Home Screen" +never had one. Shipping no service worker keeps the cache story to a +single layer (HTTP) that the server controls with headers, instead of +two (HTTP + Cache Storage) where the SW's own update lifecycle becomes +the thing that goes stale. Offline operation is not a goal. + +### The shell is `no-store`, content-hashed assets are immutable + +`__main__._client_cache_headers` applies to the SPA routes and the +static client tree only. `index.html`, `manifest.json`, `icon.svg`, +and any other unhashed file answer `Cache-Control: no-store` — every +navigation re-fetches them. Vite's `/assets/*` files are +content-addressed (a changed byte means a changed filename), so they +keep `public, max-age=31536000, immutable` and the app stays cheap to +load. The middleware skips responses that already carry +`Cache-Control`, so the album-art proxies' own caching is untouched. + +### `/health` carries a fingerprint of the served bundle + +At startup the daemon hashes every file in the client dist (relative +path + bytes) into a short id and exposes it as `client_build` on +`/health`. Absent when no `--client-dist` was given (dev server, +headless daemon) — the client treats that as "don't check". The +fingerprint covers *all* files, not just `index.html`, so unhashed +edits (`manifest.json`, `icon.svg`) also register. + +The id is computed once per daemon start. Rebuilding the dist under a +running daemon does not change it; "new bundle" means "daemon +restarted with a new bundle", which is the actual deploy path +(`just build-client` + restart) and keeps the check free. + +### The client reloads itself when the fingerprint moves + +`client/src/update-check.ts` remembers the last id it saw in +`localStorage` and re-reads `/health`: + +- immediately on load; +- every 30 s; +- on `pageshow`, `visibilitychange` (to visible), and `online` — the + moments an installed PWA comes back from suspension, when a stale + shell is most likely to surface. + +A changed id is recorded **before** `location.reload()` so a blocked +reload cannot loop. A first-ever sighting is only recorded. Anything +unreadable — offline, demo mode, no `client_build` field — is +`unknown`, and `unknown` never reloads. A slow check doesn't stack: +one request is in flight at a time. + +## Deliberately not here + +- **Protocol version negotiation / capability handshake.** This ADR + assumes client and server are version-matched by construction after + a reload. Making an old client work against a new daemon (or vice + versa) is a separate, larger decision about additive-only protocol + evolution, and belongs in its own ADR. +- **Offline mode / service-worker caching.** Explicitly rejected + above; a surface that works against a down daemon is a different + product. +- **Per-request fingerprints.** A live dist edit under a running + daemon serves neither the old bundle consistently nor the new one; + restart is the contract. +- **A "new version, tap to reload" affordance.** The surface is a + remote control, mid-session state lives on the daemon, and a reload + costs well under a second; auto-reload is the simpler contract. + +## Consequences + +- A daemon restart with a new bundle briefly drops every client's + WebSocket as the page reloads and reconnects. Acceptable for a + control surface; the alternative (mixed-version clients) is worse. +- `client/src/main.tsx` starts the check unconditionally; demo and + gallery entry points don't import it, and demo mode no-ops because + `/health` is unreachable. +- The client-serving routes moved out of `main()` into + `_add_client_routes`, so tests boot a throwaway dist without the + argparse surface (`tests/test_client_serving.py`). The decision + table in `probeForUpdate` is unit-tested in + `client/src/update-check.test.ts`. +- `localStorage` holds one key (`deckd.build`). Clearing storage only + loses the comparison baseline; the next check re-records. +- The reload is only safe if the current URL itself reloads. The SPA + fallback regex had an anchor bug (`^` inside the named group) that made + every extension-less path 404 on the daemon-served client — invisible in + Vite dev, but a hard reload of `/settings`, `/editor`, etc. would have + failed. The fallback now matches extension-less paths only, leaving + dotted assets to the static handler; `tests/test_client_serving.py` + pins both halves. diff --git a/docs/adr/README.md b/docs/adr/README.md index dde5d66..8dc4ed5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -63,3 +63,17 @@ _Amends: [0003](0003-persistent-chrome.md) — chrome knowledge now includes pay [0009-bind-scope-control.md](0009-bind-scope-control.md) Replace `--host` with repeatable `--bind` supporting literal IPs and `iface:`. Default `127.0.0.1` + `::1`. Localhost-only by default; LAN reachability is opt-in. + +## 0010 — Grid layout: ordered-list reflow with a banded cell size + +[0010-grid-reflow.md](0010-grid-reflow.md) + +Widgets are an ordered list that the client packs and wraps (no absolute coordinates); portrait falls out of reflow, and cell size is a client-side per-device band. + +_Supersedes: [0004](0004-orientation-scaling.md)_ + +## 0011 — Client freshness: fingerprinted bundle + auto-reload, no offline cache + +[0011-client-freshness.md](0011-client-freshness.md) + +The daemon fingerprints its client dist into `/health`; the shell answers `no-store`, hashed assets stay immutable, and the client reloads when the fingerprint changes. No service worker, no offline cache; protocol version negotiation is explicitly out of scope. diff --git a/tests/conftest.py b/tests/conftest.py index bd9ddef..01b9912 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -291,6 +291,7 @@ def make_test_server( password: str | None = None, media_manager=None, mpris_backend=None, + client_build: str | None = None, ) -> tuple[Server, FakeScrollSink, FakePointerSink, FakeDbusBusFactory]: """Build a ``Server`` with the same fake sinks used by the fixtures. @@ -313,5 +314,6 @@ def make_test_server( password=password, media_manager=media_manager, mpris_backend=mpris_backend, + client_build=client_build, ) return server, scroll_sink, key_sink, dbus_factory diff --git a/tests/test_client_serving.py b/tests/test_client_serving.py new file mode 100644 index 0000000..06c23ea --- /dev/null +++ b/tests/test_client_serving.py @@ -0,0 +1,107 @@ +"""Client-bundle freshness: fingerprint, ``/health`` field, cache headers. + +The daemon serves the built client itself (``--client-dist``). A phone that +kept an old shell — especially an installed PWA — must not keep driving it +after a rebuild. The server side of that contract: a content fingerprint on +``/health``, ``no-store`` on the shell and other unhashed files, immutable +caching on Vite's content-hashed ``/assets/*``. The client side lives in +``client/src/update-check.ts`` (unit-tested in ``update-check.test.ts``). +""" +from __future__ import annotations + +from pathlib import Path + +import aiohttp +import pytest +from aiohttp.test_utils import TestClient, TestServer + +from conftest import LAYOUTS_DIR, ServerHandle, make_test_server +from deckd.__main__ import _add_client_routes, _client_build_id + + +@pytest.fixture +def dist(tmp_path: Path) -> Path: + """A miniature Vite build output: shell, manifest, one hashed asset.""" + root = tmp_path / "dist" + (root / "assets").mkdir(parents=True) + (root / "index.html").write_text("v1") + (root / "manifest.json").write_text("{}") + (root / "assets" / "index-abc123.js").write_text("console.log(1)") + return root + + +def test_client_build_id_is_stable_and_content_sensitive(dist: Path) -> None: + first = _client_build_id(dist) + assert _client_build_id(dist) == first + (dist / "assets" / "index-abc123.js").write_text("console.log(2)") + assert _client_build_id(dist) != first + + +def test_client_build_id_covers_unhashed_files(dist: Path) -> None: + """``manifest.json`` isn't content-hashed by Vite, so the walk — not the + filename — has to make its edits visible.""" + first = _client_build_id(dist) + (dist / "manifest.json").write_text('{"name": "deckd"}') + assert _client_build_id(dist) != first + + +async def test_health_reports_client_build() -> None: + server, *_ = make_test_server(layouts_dir=LAYOUTS_DIR, client_build="abc123def456") + async with TestClient(TestServer(server.app, host="127.0.0.1")) as client: + r = await client.get("/health") + body = await r.json() + assert body["client_build"] == "abc123def456" + + +async def test_health_omits_client_build_without_dist(srv: ServerHandle) -> None: + """Dev-server / headless daemons have no bundle to fingerprint; the + absent field is the client's "don't check" signal.""" + async with aiohttp.ClientSession() as http: + async with http.get(f"{srv.http_url}/health") as r: + body = await r.json() + assert "client_build" not in body + + +async def test_client_routes_set_freshness_cache_headers(dist: Path) -> None: + server, *_ = make_test_server(layouts_dir=LAYOUTS_DIR) + _add_client_routes(server, dist) + async with TestClient(TestServer(server.app, host="127.0.0.1")) as client: + root = await client.get("/") + manifest = await client.get("/manifest.json") + asset = await client.get("/assets/index-abc123.js") + health = await client.get("/health") + + assert root.headers["Cache-Control"] == "no-store" + assert manifest.headers["Cache-Control"] == "no-store" + assert asset.headers["Cache-Control"] == "public, max-age=31536000, immutable" + # Non-client routes are untouched by the middleware. + assert "Cache-Control" not in health.headers + + +async def test_extensionless_deep_links_serve_the_shell(dist: Path) -> None: + """Client view routes (``/settings``, ``/editor``, ``/now-playing`` …) + must survive a hard reload — the auto-reload at a deep link depends on + it — while real dotted files still come from the static handler. + + Regression guard: the previous fallback regex anchored ``^`` inside the + named group, so it matched nothing and every deep link 404'd once served + by the daemon. + """ + server, *_ = make_test_server(layouts_dir=LAYOUTS_DIR) + _add_client_routes(server, dist) + async with TestClient(TestServer(server.app, host="127.0.0.1")) as client: + for path in ("/settings", "/editor", "/now-playing", "/windows", "/trackpad"): + deep = await client.get(path) + assert deep.status == 200, path + assert "v1" in await deep.text(), path + assert deep.headers["Cache-Control"] == "no-store", path + + asset = await client.get("/assets/index-abc123.js") + asset_text = await asset.text() + missing = await client.get("/nope.json") + + # The fallback must not swallow dotted paths… + assert asset.status == 200 + assert asset_text == "console.log(1)" + # …nor invent a shell for a missing file. + assert missing.status == 404