diff --git a/branding/hyperhive-maskable.svg b/branding/hyperhive-maskable.svg new file mode 100644 index 00000000..699f4ab6 --- /dev/null +++ b/branding/hyperhive-maskable.svg @@ -0,0 +1,13 @@ + + HyperHive (maskable) + Padded variant of hyperhive.svg for Android adaptive/maskable icon masks — + the OS crops a maskable icon to an arbitrary shape (circle, squircle, ...) and + only guarantees the inner ~80% "safe zone" survives; hyperhive.svg's own + artwork already fills nearly its whole canvas (a ~5% margin, meant for a + plain square/rounded-square favicon, not an aggressive crop), so used + directly here it would lose its outer ring and corner brackets on a circular + mask. This wrapper embeds the original at 80% scale, centered, over a + matching solid background instead of duplicating its markup. + + + diff --git a/frontend/packages/swarm-ui/build.mjs b/frontend/packages/swarm-ui/build.mjs index 1a7d0c40..41f8a1c1 100644 --- a/frontend/packages/swarm-ui/build.mjs +++ b/frontend/packages/swarm-ui/build.mjs @@ -16,6 +16,17 @@ // dist/static/swarm-ui.css served at /static/swarm-ui.css — just the // shared base reset now (@import resolved // from @hive/shared), NOT component styles +// dist/manifest.webmanifest served at /manifest.webmanifest (PWA) +// dist/sw.js served at /sw.js (PWA) — plain JS, no +// esbuild pass needed (see its own +// top comment for why); copied as-is. +// +// PWA icons (icon-192.png/icon-512.png/icon-512-maskable.png) are NOT +// produced here — like favicon.svg, they're rasterized from +// branding/*.svg and copied in by nix/packages/swarm-ui.nix's +// installPhase, outside the npm tree this script builds. A plain `npm +// run build` here has no `/icon-*.png`, same as it's always had no +// `/favicon.svg`. // // Not yet wired into any Rust binary's `ServeDir` — swarm-controller // only serves `/health` today (see swarm-controller/README.md); this @@ -62,5 +73,7 @@ for (const entry of ["colors.css", "theme.css", "swarm-ui.css"]) { } copyFileSync(src("index.html"), dist("index.html")); +copyFileSync(src("manifest.webmanifest"), dist("manifest.webmanifest")); +copyFileSync(src("sw.js"), dist("sw.js")); console.log("swarm-ui build ok →", dist("")); diff --git a/frontend/packages/swarm-ui/src/index.html b/frontend/packages/swarm-ui/src/index.html index 0959cfea..dfd9d672 100644 --- a/frontend/packages/swarm-ui/src/index.html +++ b/frontend/packages/swarm-ui/src/index.html @@ -5,6 +5,19 @@ hyperhive swarm + + + + + + + diff --git a/frontend/packages/swarm-ui/src/main.tsx b/frontend/packages/swarm-ui/src/main.tsx index 5068ce59..6294fc42 100644 --- a/frontend/packages/swarm-ui/src/main.tsx +++ b/frontend/packages/swarm-ui/src/main.tsx @@ -5,3 +5,17 @@ const root = document.getElementById("root"); if (root) { render(, root); } + +// PWA installability — see sw.js's own top comment for +// what it does and does not cache. Feature-detected: older browsers +// without service-worker support just don't get the install affordance, +// nothing here depends on it existing. +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch((err: unknown) => { + // Never surfaces to the operator — a failed SW registration means + // "not installable this session," not "the app is broken." + console.error("swarm-ui: service worker registration failed", err); + }); + }); +} diff --git a/frontend/packages/swarm-ui/src/manifest.webmanifest b/frontend/packages/swarm-ui/src/manifest.webmanifest new file mode 100644 index 00000000..83a6bfc8 --- /dev/null +++ b/frontend/packages/swarm-ui/src/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "hyperhive swarm", + "short_name": "swarm", + "description": "Swarm-level operator UI — agent roster, hives, jobs, issues across the swarm.", + "start_url": "/", + "display": "standalone", + "background_color": "#1e1e2e", + "theme_color": "#cba6f7", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/frontend/packages/swarm-ui/src/sw.js b/frontend/packages/swarm-ui/src/sw.js new file mode 100644 index 00000000..4728640f --- /dev/null +++ b/frontend/packages/swarm-ui/src/sw.js @@ -0,0 +1,109 @@ +// Minimal service worker — satisfies PWA installability so swarm-ui can +// actually be "Add to Home Screen"d (design-guide.md's "Layout & +// viewport" section already asserted this as fact before this service +// worker existed to make it true). NOT a data cache: scoped to the app +// shell only (the SPA's own HTML/JS/CSS/manifest), and explicitly never +// touches `/api/*` — an ops dashboard silently showing stale status from +// a cached fetch would be worse than showing nothing (mara: "scope looks +// good" — approving that as a hard rule, not a short-TTL middle ground). +// +// Network-first for the shell, not cache-first: `main.js`/`main.css` are +// unhashed filenames, so a cache-first strategy would risk wedging an +// operator on yesterday's JS after a deploy until they manually cleared +// it (a deploy changes the nix store path serving these but not the URL +// the browser cached against — a separate, already-filed fix). This SW +// only ever serves its cache on a FAILED fetch (offline/flaky network), +// never in preference to a successful network response. +// +// Plain JS, not TypeScript, deliberately — the DOM lib swarm-ui's own +// tsconfig uses and the WebWorker lib a service worker's globals +// (`self`, `ExtendableEvent`, `FetchEvent`, `caches`, ...) need are +// mutually exclusive in one tsc program, and this file is small/ +// self-contained enough that a second tsconfig just to typecheck it +// isn't worth the config surface. Same treatment `build.mjs` already +// gets in this package. +// +// CACHE_VERSION is bumped by hand for now — once the filenames above are +// content-hashed, the cache name can derive from the build itself and +// this manual step goes away. +const CACHE_VERSION = "v1"; +const CACHE_NAME = `swarm-ui-shell-${CACHE_VERSION}`; + +// Every navigation (any client-side route wouter handles — /agents, +// /hives, ...) falls back to this single cached document when offline, +// same as nginx's own `try_files $uri /index.html` does online: the SPA +// only ever has one real HTML document regardless of path. +const SHELL_DOCUMENT = "/"; +const SHELL_ASSETS = [ + SHELL_DOCUMENT, + "/static/main.js", + "/static/main.css", + "/static/colors.css", + "/static/theme.css", + "/static/swarm-ui.css", + "/manifest.webmanifest", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)), + ); + // Take over from any previous SW as soon as this one's installed — an + // operator installing the PWA for the first time (or after a SW + // update) shouldn't need a second reload before it's in control. + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((names) => + Promise.all( + names + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + // Same-origin only, and never `/api/*` — that's swarm-controller's + // live data, not the static shell this SW is scoped to. Letting the + // event fall through (no `respondWith`) means the browser handles it + // exactly as if this SW didn't exist. + if (url.origin !== self.location.origin || url.pathname.startsWith("/api/")) { + return; + } + + // Any navigation (full-page load or wouter's client-side route change + // reaching the network) — network-first, falling back to the cached + // shell document on failure regardless of which path was requested, + // since every route renders the same SPA shell. + if (event.request.mode === "navigate") { + event.respondWith( + fetch(event.request).catch(() => caches.match(SHELL_DOCUMENT)), + ); + return; + } + + // A specific shell asset — network-first, cache the fresh response for + // next time, fall back to whatever's cached on failure. Anything not + // in this list (a future asset this SW doesn't know about yet) passes + // straight through untouched rather than silently going uncached. + if (!SHELL_ASSETS.includes(url.pathname)) { + return; + } + event.respondWith( + fetch(event.request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); + return response; + }) + .catch(() => caches.match(event.request)), + ); +}); diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 38d41522..c0a534a8 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -197,6 +197,7 @@ in # `packages.default`'s closure. swarm-ui = pkgs.callPackage ./swarm-ui.nix { branding-svg = ../../branding/hyperhive.svg; + branding-svg-maskable = ../../branding/hyperhive-maskable.svg; }; # Bundled browser assets — see ./frontend.nix. Output is diff --git a/nix/packages/swarm-ui.nix b/nix/packages/swarm-ui.nix index 035dc904..6d1e5d52 100644 --- a/nix/packages/swarm-ui.nix +++ b/nix/packages/swarm-ui.nix @@ -1,4 +1,9 @@ -{ buildNpmPackage, branding-svg }: +{ + buildNpmPackage, + branding-svg, + branding-svg-maskable, + librsvg, +}: # Static build of the swarm-level UI shell (project-bootstrap scope: # empty start page, no functionality yet — see @@ -31,6 +36,14 @@ # `./frontend.nix` copies for the dashboard) is likewise copied in # during install — it lives outside the npm tree. +# PWA icons: rasterized from the branding SVGs at build time via +# `librsvg`'s `rsvg-convert` rather than checking in static PNGs, so the +# SVG stays the one thing to update if the mark ever changes. +# `branding-svg-maskable` is a separate, padded source (see +# `branding/hyperhive-maskable.svg`'s own comment) — `hyperhive.svg`'s +# artwork already fills nearly its whole canvas, so a maskable icon needs +# real margin or an OS mask crops into it. + buildNpmPackage { pname = "hyperhive-swarm-ui"; version = "0.0.0"; @@ -39,6 +52,8 @@ buildNpmPackage { # See `./frontend.nix`'s comment on the same line. npmDepsHash = builtins.readFile ../../frontend/npm-deps-hash; + nativeBuildInputs = [ librsvg ]; + buildPhase = '' runHook preBuild npm run build --workspace=packages/swarm-ui @@ -51,6 +66,18 @@ buildNpmPackage { mkdir -p $out cp -r packages/swarm-ui/dist/. $out/ cp ${branding-svg} $out/favicon.svg + rsvg-convert -w 192 -h 192 ${branding-svg} -o $out/icon-192.png + rsvg-convert -w 512 -h 512 ${branding-svg} -o $out/icon-512.png + + # `branding-svg-maskable` embeds `branding-svg` via a relative + # `` reference (see that file's own comment) — each is passed + # in as its own single-file store path, so the sibling reference + # only resolves once both live together in one directory again. + mkdir -p "$TMPDIR/branding" + cp ${branding-svg} "$TMPDIR/branding/hyperhive.svg" + cp ${branding-svg-maskable} "$TMPDIR/branding/hyperhive-maskable.svg" + rsvg-convert -w 512 -h 512 "$TMPDIR/branding/hyperhive-maskable.svg" \ + -o $out/icon-512-maskable.png runHook postInstall '';