diff --git a/servers/gateway/dashboard/shared/layout.js b/servers/gateway/dashboard/shared/layout.js index 77b8c329..35c8c48a 100644 --- a/servers/gateway/dashboard/shared/layout.js +++ b/servers/gateway/dashboard/shared/layout.js @@ -223,8 +223,11 @@ export function renderLayout({ title, content, activePanel, panels, scripts, aft + - + + ${FONT_LINKS} ${dashboardCss()} ${turboHead()} diff --git a/servers/gateway/public/icons/apple-touch-icon.png b/servers/gateway/public/icons/apple-touch-icon.png new file mode 100644 index 00000000..9dbf1f9f Binary files /dev/null and b/servers/gateway/public/icons/apple-touch-icon.png differ diff --git a/servers/gateway/public/icons/crow-icon-maskable.svg b/servers/gateway/public/icons/crow-icon-maskable.svg new file mode 100644 index 00000000..359eb8d2 --- /dev/null +++ b/servers/gateway/public/icons/crow-icon-maskable.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/servers/gateway/public/icons/icon-192.png b/servers/gateway/public/icons/icon-192.png new file mode 100644 index 00000000..cb97868d Binary files /dev/null and b/servers/gateway/public/icons/icon-192.png differ diff --git a/servers/gateway/public/icons/icon-512.png b/servers/gateway/public/icons/icon-512.png new file mode 100644 index 00000000..d804f137 Binary files /dev/null and b/servers/gateway/public/icons/icon-512.png differ diff --git a/servers/gateway/public/icons/icon-maskable-192.png b/servers/gateway/public/icons/icon-maskable-192.png new file mode 100644 index 00000000..53916205 Binary files /dev/null and b/servers/gateway/public/icons/icon-maskable-192.png differ diff --git a/servers/gateway/public/icons/icon-maskable-512.png b/servers/gateway/public/icons/icon-maskable-512.png new file mode 100644 index 00000000..f17d027c Binary files /dev/null and b/servers/gateway/public/icons/icon-maskable-512.png differ diff --git a/servers/gateway/public/manifest.json b/servers/gateway/public/manifest.json index ddb7093e..35cda290 100644 --- a/servers/gateway/public/manifest.json +++ b/servers/gateway/public/manifest.json @@ -1,13 +1,20 @@ { + "id": "/dashboard", "name": "Crow's Nest", "short_name": "Crow", "description": "AI-powered personal platform", "start_url": "/dashboard/nest", + "scope": "/dashboard", "display": "standalone", "background_color": "#1d1d1f", "theme_color": "#1d1d1f", "orientation": "any", + "categories": ["productivity", "developer tools"], "icons": [ - { "src": "/icons/crow-icon.svg", "sizes": "any", "type": "image/svg+xml" } + { "src": "/icons/crow-icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] } diff --git a/servers/gateway/public/sw.js b/servers/gateway/public/sw.js index c4023665..c870c529 100644 --- a/servers/gateway/public/sw.js +++ b/servers/gateway/public/sw.js @@ -5,7 +5,7 @@ * Handles push events and notification clicks. */ -const CACHE_NAME = "crow-v1"; +const CACHE_NAME = "crow-v2"; const SHELL_ASSETS = ["/dashboard/nest"]; self.addEventListener("install", (e) => { @@ -40,6 +40,19 @@ self.addEventListener("fetch", (e) => { try { reqOrigin = new URL(url).origin; } catch { reqOrigin = null; } if (reqOrigin && reqOrigin !== self.location.origin) return; + // PR-F explicit guard (2026-09-13): the perch interactive API is + // authenticated JSON, and its /events route is SSE. Never intercept + // either. Authed responses must not touch any cache path — and even + // respondWith(fetch()) on an event-stream route pipes the stream + // through the SW's fetch layer instead of the browser's native + // EventSource handling. No respondWith at all keeps both exactly as + // if this worker were not there. + if (url.includes("/dashboard/perch-api/")) return; + if (e.request.destination === "eventsource") return; + try { + if ((e.request.headers.get("accept") || "").includes("text/event-stream")) return; + } catch { /* header read on an odd request — let the branches below decide */ } + // Special case: /dashboard/nest is pre-cached for offline shell loading if (url.endsWith("/dashboard/nest")) { e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); diff --git a/tests/dashboard-pwa.test.js b/tests/dashboard-pwa.test.js new file mode 100644 index 00000000..c5757754 --- /dev/null +++ b/tests/dashboard-pwa.test.js @@ -0,0 +1,73 @@ +/** + * PR-F — dashboard PWA shell integrity (perch parity audit item 22 polish, + * 2026-09-13). The dashboard was already installable (bb1cb865 shipped the + * manifest + SW + push); this file pins the parity gaps closed since: + * + * 1. manifest: scoped to /dashboard, id, real 192/512 PNG icons + a + * maskable variant, every referenced icon file actually ships; + * 2. sw.js: the authenticated perch API and the SSE /events stream pass + * through with NO interception (never cache authed responses; never + * pipe an event stream through respondWith); + * 3. the rendered dashboard head carries apple-mobile-web-app-title and a + * PNG apple-touch-icon (iOS renders an SVG touch icon as a blank tile). + * + * All static-asset assertions — no server, no DB. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const REPO = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const PUB = join(REPO, "servers/gateway/public"); +const manifest = JSON.parse(readFileSync(join(PUB, "manifest.json"), "utf8")); + +test("manifest: standalone app scoped to the dashboard, start_url inside scope", () => { + assert.equal(manifest.display, "standalone"); + assert.equal(manifest.scope, "/dashboard"); + assert.equal(manifest.start_url, "/dashboard/nest"); + assert.ok(manifest.start_url.startsWith(manifest.scope), "start_url must live inside scope"); + assert.equal(manifest.id, "/dashboard"); +}); + +test("manifest: real PNG sizes plus a maskable variant (pi-lab parity)", () => { + const pngs = manifest.icons.filter((i) => i.type === "image/png"); + assert.ok(pngs.some((i) => i.sizes === "192x192"), "a 192px PNG icon"); + assert.ok(pngs.some((i) => i.sizes === "512x512"), "a 512px PNG icon"); + const maskable = pngs.filter((i) => (i.purpose || "").split(/\s+/).includes("maskable")); + assert.ok(maskable.some((i) => i.sizes === "512x512"), "a 512px maskable PNG icon"); +}); + +test("manifest: every referenced icon file ships under public/", () => { + for (const icon of manifest.icons) { + assert.ok(existsSync(join(PUB, icon.src)), icon.src + " must exist under servers/gateway/public/"); + } +}); + +test("sw.js: perch-api and SSE routes pass through, unintercepted, before any dashboard branch", () => { + const sw = readFileSync(join(PUB, "sw.js"), "utf8"); + const perch = sw.indexOf('"/dashboard/perch-api/"'); + assert.ok(perch > 0, "explicit /dashboard/perch-api/ bypass present"); + assert.ok(/destination === "eventsource"/.test(sw), "eventsource-destination bypass present"); + assert.ok(/text\/event-stream/.test(sw), "Accept: text/event-stream bypass present"); + // The perch routes live under the /dashboard/ substring too — a bypass + // ordered AFTER that branch would never run. + const dashBranch = sw.indexOf('includes("/dashboard/")'); + assert.ok(dashBranch > 0, "the dashboard network-only branch still exists"); + assert.ok(perch < dashBranch, "the bypasses precede the dashboard branch"); + // No respondWith may sit between a bypass return and the next branch — + // guard the shape, not just the strings: every bypass line ends in return. + for (const line of sw.slice(perch - 400, dashBranch).split("\n")) { + if (/perch-api|eventsource|event-stream/.test(line) && !line.trim().startsWith("//")) { + assert.match(line, /return;?\s*$/, "bypass must return without respondWith: " + line.trim()); + } + } +}); + +test("rendered head: install metas — apple title + PNG apple-touch-icon", async () => { + const { renderLayout } = await import("../servers/gateway/dashboard/shared/layout.js"); + const html = renderLayout({ title: "PWA test", content: "

x

", activePanel: "nest", panels: [], scripts: "" }); + assert.ok(html.includes('')); + assert.ok(html.includes('rel="apple-touch-icon" href="/icons/apple-touch-icon.png"')); + assert.ok(html.includes('rel="manifest" href="/manifest.json"')); +});