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
5 changes: 4 additions & 1 deletion servers/gateway/dashboard/shared/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,11 @@ export function renderLayout({ title, content, activePanel, panels, scripts, aft
<link rel="manifest" href="/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Crow">
<link rel="icon" type="image/svg+xml" href="/icons/crow-icon.svg">
<link rel="apple-touch-icon" href="/icons/crow-icon.svg">
<!-- iOS reads apple-touch-icon as PNG only — an SVG href lands a blank
home-screen tile (PR-F parity polish, 2026-09-13). -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
${FONT_LINKS}
${dashboardCss()}
${turboHead()}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions servers/gateway/public/icons/crow-icon-maskable.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added servers/gateway/public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added servers/gateway/public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 8 additions & 1 deletion servers/gateway/public/manifest.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
15 changes: 14 additions & 1 deletion servers/gateway/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)));
Expand Down
73 changes: 73 additions & 0 deletions tests/dashboard-pwa.test.js
Original file line number Diff line number Diff line change
@@ -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: "<p>x</p>", activePanel: "nest", panels: [], scripts: "" });
assert.ok(html.includes('<meta name="apple-mobile-web-app-title" content="Crow">'));
assert.ok(html.includes('rel="apple-touch-icon" href="/icons/apple-touch-icon.png"'));
assert.ok(html.includes('rel="manifest" href="/manifest.json"'));
});
Loading