Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions client/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<React.StrictMode>
<App />
Expand Down
2 changes: 1 addition & 1 deletion client/src/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
237 changes: 237 additions & 0 deletions client/src/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
124 changes: 124 additions & 0 deletions client/src/update-check.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateProbeResult> {
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<UpdateProbeDeps>;
};

/** 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);
};
}
Loading
Loading