diff --git a/src/browser/storage.ts b/src/browser/storage.ts index 06c6867..c262db7 100644 --- a/src/browser/storage.ts +++ b/src/browser/storage.ts @@ -159,14 +159,74 @@ export function createBrowserStore(scope: typeof globalThis = globalThis): Signa }; } -/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage — an insecure - * origin in development, a policy that refuses — a display works exactly as it did before: online. */ +/** + * Image bytes held in this page's memory, for a browser that offers no Cache Storage — a display + * opened over plain `http://` on a LAN address is not a secure context, and gets none. + * + * The bytes, not the delivery URL: a URL needs the network, so a cache that remembered only URLs + * looked identical online and could paint nothing new offline. Holding the bytes keeps a running + * display cycling through what it already has. Nothing survives a reload, and nothing needs to: on + * such an origin the platform cannot cache the display page itself either. + */ +function createPageMediaCache(fetchFn: typeof fetch): SignageMediaCache { + /** One object URL per key, revoked when the key is pruned or replaced, and all of them on stop. */ + const objectUrls = new Map(); + + function forget(key: string): void { + const url = objectUrls.get(key); + if (url !== undefined) { + objectUrls.delete(key); + URL.revokeObjectURL(url); + } + } + + return { + count: () => Promise.resolve(objectUrls.size), + find: (key: string) => Promise.resolve(objectUrls.get(key)), + prune(keep: readonly string[]): Promise { + const retained = new Set(keep); + for (const key of [...objectUrls.keys()]) { + if (!retained.has(key)) { + forget(key); + } + } + return Promise.resolve(); + }, + release(): void { + for (const key of [...objectUrls.keys()]) { + forget(key); + } + }, + async store(key: string, url: string): Promise { + try { + const response = await fetchFn(url); + // The same rule as Cache Storage: only a complete, successful response is kept. + if (!response.ok || response.status !== 200) { + return undefined; + } + const blob = await response.blob(); + forget(key); + const local = URL.createObjectURL(blob); + objectUrls.set(key, local); + return local; + } catch { + return undefined; + } + }, + }; +} + +/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage, the bytes are + * held in the page instead (`createPageMediaCache`), so a running display still survives an outage. */ export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache { const maybeCaches = (scope as { caches?: CacheStorage }).caches; const fetchFn = (scope as { fetch?: typeof fetch }).fetch; - if (!maybeCaches || !fetchFn) { + if (!fetchFn) { return createMemoryMediaCache(); } + if (!maybeCaches) { + return createPageMediaCache(fetchFn); + } const caches: CacheStorage = maybeCaches; async function open(): Promise { diff --git a/tests/browser/media-cache.test.ts b/tests/browser/media-cache.test.ts index 4618c55..606c7ec 100644 --- a/tests/browser/media-cache.test.ts +++ b/tests/browser/media-cache.test.ts @@ -117,3 +117,90 @@ describe("Signage's media cache and object URLs", () => { expect(live.size).toBe(0); }); }); + +/** + * A display opened over plain `http://` on a LAN address is not a secure context, and a browser + * gives such a page no Cache Storage. Stage 0's real kiosk ran exactly that way. + * + * The fallback used to remember only the signed delivery URL and report it as cached. Online that + * looked identical; offline, the next slide's URL could not be fetched, the slide was skipped as + * unusable, and the wall sat on one image until the network came back. The bytes are what a display + * needs offline, so the bytes are what this keeps — in the page's memory, for as long as it runs. + */ +describe("Signage's media cache on an origin without Cache Storage", () => { + const realCreate = URL.createObjectURL; + const realRevoke = URL.revokeObjectURL; + let live: Set; + let reachable: boolean; + let media: SignageMediaCache; + + beforeEach(() => { + live = new Set(); + reachable = true; + let next = 0; + URL.createObjectURL = () => { + next += 1; + const url = `blob:test/${next}`; + live.add(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + live.delete(url); + }; + media = createBrowserMediaCache({ + // No `caches`: what an insecure origin gets. + fetch: async (url: string) => { + if (!reachable) { + throw new TypeError("Failed to fetch"); + } + return url.includes("missing") + ? new Response("not found", { status: 404 }) + : new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } }); + }, + location: { origin: "http://192.168.1.20:3000" }, + } as unknown as typeof globalThis); + }); + + afterEach(() => { + URL.createObjectURL = realCreate; + URL.revokeObjectURL = realRevoke; + }); + + it("keeps the bytes, so a slide it cached can still be painted with no network", async () => { + const stored = await media.store("slide/a", "http://files.example.test/a?sig=1"); + + // A local URL for bytes this page holds — never the delivery URL, which needs the network. + expect(stored).toMatch(/^blob:/u); + expect(stored).not.toContain("files.example.test"); + + reachable = false; + expect(await media.find("slide/a")).toBe(stored); + expect(await media.count()).toBe(1); + }); + + it("reports nothing as cached that it does not actually hold", async () => { + expect(await media.store("slide/a", "http://files.example.test/missing?sig=1")).toBeUndefined(); + reachable = false; + expect(await media.store("slide/b", "http://files.example.test/b?sig=1")).toBeUndefined(); + + expect(await media.find("slide/a")).toBeUndefined(); + expect(await media.find("slide/b")).toBeUndefined(); + expect(await media.count()).toBe(0); + }); + + it("gives back the URLs it replaces, prunes, and holds when the display stops", async () => { + for (const key of ["slide/a", "slide/b", "slide/c"]) { + await media.store(key, `http://files.example.test/${key}?sig=1`); + } + await media.store("slide/a", "http://files.example.test/slide/a?sig=2"); + expect(live.size).toBe(3); + + await media.prune(["slide/a", "slide/b"]); + expect(live.size).toBe(2); + expect(await media.find("slide/c")).toBeUndefined(); + expect(await media.count()).toBe(2); + + media.release(); + expect(live.size).toBe(0); + }); +}); diff --git a/tests/browser/offline.test.ts b/tests/browser/offline.test.ts index c24703e..42ded36 100644 --- a/tests/browser/offline.test.ts +++ b/tests/browser/offline.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { type OfflineSnapshot, resolveOffline, @@ -8,6 +8,7 @@ import { } from "../../src/browser/offline.js"; import type { SceneSelection } from "../../src/browser/scene-request.js"; import { + createBrowserMediaCache, createMemoryMediaCache, createMemoryStore, signageMediaKey, @@ -574,3 +575,259 @@ describe("a display that loses its network", () => { await controller.stop(); }); }); + +/** + * Stage 0's real kiosk: two native images cycling, then the laptop's network switched off for a + * minute. The offline note appeared, but the wall stayed on one image until the network returned. + * + * The page was served over plain `http://` on a LAN address, which a browser does not treat as a + * secure context, so there was no Cache Storage. The media cache's fallback then remembered only each + * slide's signed delivery URL. Offline, the next slide's URL could not be fetched, the slide was + * marked unusable, and local continuation kept landing on the one image already on screen. + * + * This runs the whole controller on a clock that only moves when the test says so, across several + * slide intervals, with every timer it arms honoured in order. + */ +describe("a running display that loses its network for several slides", () => { + const SLOT_MS = 5000; + const realCreate = URL.createObjectURL; + const realRevoke = URL.revokeObjectURL; + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.append(root); + let next = 0; + URL.createObjectURL = () => { + next += 1; + return `blob:display/${next}`; + }; + URL.revokeObjectURL = () => undefined; + }); + + afterEach(() => { + URL.createObjectURL = realCreate; + URL.revokeObjectURL = realRevoke; + }); + + async function settle(): Promise { + for (let index = 0; index < 12; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + /** A clock and timer queue the controller and its stage both run on. */ + function createClock(startMs: number) { + let nowMs = startMs; + let nextHandle = 0; + const timers = new Map void }>(); + return { + clearTimer: (handle: unknown) => { + timers.delete(handle as number); + }, + now: () => nowMs, + setTimer: (handler: () => void, delayMs: number) => { + nextHandle += 1; + timers.set(nextHandle, { atMs: nowMs + Math.max(0, delayMs), handler }); + return nextHandle; + }, + /** Moves time forward, running every timer that falls due on the way, in order. */ + async advance(ms: number): Promise { + const targetMs = nowMs + ms; + for (;;) { + let due: [number, { readonly atMs: number; readonly handler: () => void }] | undefined; + for (const entry of timers) { + if (entry[1].atMs <= targetMs && (!due || entry[1].atMs < due[1].atMs)) { + due = entry; + } + } + if (!due) { + break; + } + timers.delete(due[0]); + nowMs = due[1].atMs; + due[1].handler(); + await settle(); + } + nowMs = targetMs; + await settle(); + }, + }; + } + + /** Which of the two images a selection is. */ + function slideOf(selectionId: string | undefined): string | undefined { + return selectionId?.split("-")[1]; + } + + /** The server's answer at `atMs`: two images alternating, each play its own selection, and a + * freshly signed delivery URL on every answer — exactly as Signage's own server sends them. */ + function serverState(atMs: number): DisplayChannelStateView { + const cycle = Math.floor(atMs / SLOT_MS); + const slide = (c: number) => (c % 2 === 0 ? "a" : "b"); + const history: DisplaySelection[] = [cycle, cycle - 1, cycle - 2] + .filter((c) => c >= 0) + .map((c) => ({ + ...selection({ + id: `sel-${slide(c)}-${c}`, + itemId: `item-${slide(c)}`, + slotId: `slot-${slide(c)}`, + }), + media: { + display: { + expiresAtMs: atMs + 900_000, + height: 1080, + url: `http://files.example.test/${slide(c)}?sig=${atMs}`, + width: 1920, + }, + thumbnail: { + expiresAtMs: atMs + 900_000, + height: 270, + url: `http://files.example.test/${slide(c)}-t?sig=${atMs}`, + width: 480, + }, + }, + stale: false, + })); + const current = history[0] as DisplaySelection; + const startedAtMs = cycle * SLOT_MS; + return { + channel: { groupId: "group-1", id: "chan-1", key: "default" }, + crossfadeMs: 0, + current, + playback: { + activeTimelineId: "timeline-1", + blackout: false, + currentSelectionId: current.id, + currentSlotId: current.slotId, + deadlineAtMs: startedAtMs + SLOT_MS, + pendingTimelineId: null, + remainingMs: startedAtMs + SLOT_MS - atMs, + revision: cycle, + selectionRevision: cycle, + startedAtMs, + transport: "playing", + }, + selections: history, + serverNowMs: atMs, + staticHold: null, + timeline: { + eligibleFromMs: null, + eligibleUntilMs: null, + id: "timeline-1", + revision: 1, + slots: [ + slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }), + slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }), + ], + }, + }; + } + + it("keeps advancing through its cached images on an origin with no Cache Storage", async () => { + const network = { reachable: true }; + // Mid-slide, so every sample below lands halfway through one. + const clock = createClock(12_500); + const unreachable = () => new TypeError("Failed to fetch"); + const host = createFakeHost({ + permissions: ["signage.display.view"], + routes: [ + { + method: "POST", + path: "viewers/session", + respond: () => + jsonResponse({ + channelId: "chan-1", + channelKey: "default", + groupId: "group-1", + heartbeatIntervalMs: 12_000, + viewerId: "11111111-1111-4111-8111-111111111111", + viewerName: "Foyer", + } satisfies ViewerSessionResponse), + }, + { + method: "GET", + path: /^viewers\/[^/]+\/display-state$/u, + respond: () => { + if (!network.reachable) { + throw unreachable(); + } + return jsonResponse(serverState(clock.now())); + }, + }, + { + method: "POST", + path: /^viewers\/.*\/heartbeat$/u, + respond: () => { + if (!network.reachable) { + throw unreachable(); + } + return jsonResponse({}); + }, + }, + ], + }); + // A plain-http LAN origin: no `caches`, but `fetch` — which needs the network like anything else. + const media = createBrowserMediaCache({ + fetch: async () => { + if (!network.reachable) { + throw unreachable(); + } + return new Response(new Uint8Array([1, 2, 3]), { + headers: { "content-type": "image/webp" }, + }); + }, + location: { origin: "http://192.168.1.20:3000" }, + } as unknown as typeof globalThis); + + const controller = createDisplayController({ + clearTimer: clock.clearTimer, + context: host.context, + // What a browser does: bytes this page holds always decode, and a remote URL needs a network. + decodeImage: (image) => + (image.getAttribute("src") ?? "").startsWith("blob:") || network.reachable + ? Promise.resolve() + : Promise.reject(new Error("net::ERR_INTERNET_DISCONNECTED")), + media, + newViewerKey: () => "viewer-fixed-key-0001", + now: clock.now, + root, + setTimer: clock.setTimer, + store: createMemoryStore(), + }); + + await controller.start(); + await settle(); + // Online for a full cycle, so both images have been shown and fetched. + const online: (string | undefined)[] = [slideOf(controller.diagnostics().renderedSelectionId)]; + for (let interval = 0; interval < 2; interval += 1) { + await clock.advance(SLOT_MS); + online.push(slideOf(controller.diagnostics().renderedSelectionId)); + } + expect(online).toEqual(["a", "b", "a"]); + expect(controller.diagnostics().offline).toBe(false); + + // The network goes away for six slides. + network.reachable = false; + const offline: (string | undefined)[] = []; + for (let interval = 0; interval < 6; interval += 1) { + await clock.advance(SLOT_MS); + offline.push(slideOf(controller.diagnostics().renderedSelectionId)); + expect(controller.diagnostics().offline).toBe(true); + } + + // Still cycling, on the server's own phase, from bytes the page already held. + expect(offline).toEqual(["b", "a", "b", "a", "b", "a"]); + expect(controller.diagnostics().unusableSelectionIds).toEqual([]); + expect(root.dataset.slate).toBe("false"); + + // The network returns. The server is authoritative again with no reload. + network.reachable = true; + await clock.advance(70_000); + const diagnostics = controller.diagnostics(); + expect(diagnostics.offline).toBe(false); + expect(diagnostics.renderedSelectionId).toMatch(/^sel-[ab]-\d+$/u); + expect(Number(diagnostics.renderedSelectionId?.split("-")[2])).toBeGreaterThan(10); + await controller.stop(); + }); +});