From 0c327416654e292fb80c1222df4fe1c464565344 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Tue, 8 Sep 2026 00:28:42 -0400 Subject: [PATCH 1/2] Install probe: serve the saved app glyph through an icon worker --- docs/design.md | 40 +++-- e2e/run.ts | 367 +++++++++++++++++++++++++++++++++++---- runtime/wit/internal.wit | 26 ++- visor/src/kernel.rs | 7 + visor/src/ui.rs | 17 +- web/boot.ts | 185 ++++++++++++++++++-- web/build.ts | 13 +- web/icon-sw.ts | 96 ++++++++++ 8 files changed, 678 insertions(+), 73 deletions(-) create mode 100644 web/icon-sw.ts diff --git a/docs/design.md b/docs/design.md index 49a0f3f2..ba3b3fd6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -509,16 +509,36 @@ interprets. which on a project Pages site is somebody else's page), so a `blob:` manifest resolves nothing relative to itself and the same package on the same origin is the same installed app on every device. The icon is - the framework's own, at a static URL: Android installs are WebAPKs, - minted by a server that fetches the manifest's icons itself, so an icon - painted on the client (the user's glyph on the user's hue was the - plan) can never reach the launcher; the composed name is what does. - Whether that server also re-fetches the *manifest* by URL — which would - rule out a `blob:` manifest too, and leave a service worker on the home - origin as the only way to serve one per install — is the open probe; - apps distributed off the home origin will need that answer, and a - global app identity, before `launch/` can name anything but a - registry id. Chromium only; iOS + the user's saved glyph for that package, painted white on the user's + hue at 512 and 192. The PNGs are not files the build ships: the page + paints them at install time and puts them in a dedicated versioned + Cache Storage cache (`polyvisor-launcher-icons-v1`) under + `launcher-icons/.png` URLs — real `https:` URLs + under the page's base, never `blob:` — and a service worker registered + lazily on that same press answers them. The digest names an icon by + its content, so a repaint gets a new URL instead of overwriting art + something may still hold; it is not a secret, since the space of + glyph-on-hue images is small enough to enumerate. The fetch handler + intercepts exactly those URLs and passes everything else through + untouched — not an offline cache — and a URL it has no image for is a + 404, never the app shell. No saved glyph, or a worker that does not + come up in time, falls back to the framework's static icons; art never + blocks an install. The cached art is **unsealed** (plain PNG bytes + readable by anything with the browser profile, unlike everything the + kernel stores) and **evictable** (dropped under storage pressure, and + the URLs 404 until the next install repaints them). It carries one + glyph on one hue, and no app data, key material or identifier. + What this does NOT settle is Android. A remote WebAPK-minting server + cannot reach a service worker at all — its responses exist only inside + this browser — so a worker-served icon reaches the launcher only if + the install uses image bytes the browser itself fetched. Which of the + two the Android path does is the open question, and only a real device + install answers it: nothing observed in desktop Chromium is evidence + either way, and a previous install having succeeded says nothing about + why. The `blob:` manifest is untouched meanwhile — kept as the working + Android baseline rather than traded for a guess. Apps distributed off + the home origin will need that answer, and a global app identity, + before `launch/` can name anything but a registry id. Chromium only; iOS partitions storage per home-screen app, so a per-app install there would be a device of its own. Unverified and to be probed: that the fragment survives in `start_url` (a `?launch=` query is an acceptable diff --git a/e2e/run.ts b/e2e/run.ts index 8531d129..de65b1c6 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -22,6 +22,17 @@ const BUILT = new URL("../web/dist", import.meta.url).pathname; // Static server // --------------------------------------------------------------------------- +/** The extra path prefix the same site is ALSO reachable under, so a + * scenario can check that nothing the site emits is root-absolute. + * + * A GitHub Pages project site serves the whole deployment from + * `//`, where `/` is somebody else's page (docs/design.md + * "Routing"). Mounting the identical tree twice — at `/` and here — costs + * two lines and lets one scenario open the subpath copy and assert that + * everything it resolves, the service worker scope included, stays under + * it. */ +const SUBPATH = "/pages-subpath/"; + function serve(dist: string): { origin: string; stop(): Promise } { const server = Deno.serve({ port: 0, // The kernel picks; parallel checkouts must not collide. @@ -30,6 +41,8 @@ function serve(dist: string): { origin: string; stop(): Promise } { }, async (req) => { const url = new URL(req.url); let path = decodeURIComponent(url.pathname); + // The second mount of the same tree; see SUBPATH. + if (path.startsWith(SUBPATH)) path = "/" + path.slice(SUBPATH.length); if (path.endsWith("/")) path += "index.html"; // `normalize` collapses `..` before the join, so a request cannot climb // out of dist. @@ -336,6 +349,135 @@ async function saveDraft(page: Page): Promise { ); } +/** + * The running app's own glyph (`device.meta`, `meta-scope.app`), typed and + * SAVED — which is the value an install is allowed to paint with. + * + * The app sheet carries no `Save` of its own: the draft it writes into is + * the same one the settings sheet saves, so the save here is the "unsaved + * changes" dialog that guards the transition away from a dirty sheet + * (visor/src/ui.rs `#visor-confirm`). Returns once the app sheet, reopened, + * shows the value read back off the kernel (`read_app_meta`) — so a caller + * that installs next is installing against a saved map, not a draft. + */ +async function setAppGlyph(page: Page, glyph: string): Promise { + await openApps(page); + // `^glyph$`: the settings sheet's field is "your glyph", and this is the + // app sheet's. + const field = drawer(page).locator("label").filter({ hasText: /^glyph$/ }) + .locator("input"); + await field.waitFor({ timeout: 10_000 }); + // Typed until it stays typed. Unlike the settings sheet, the app sheet + // seeds its draft from a kernel read that lands AFTER the pane is on + // screen (visor/src/ui.rs: the `AppInfo` arm spawns `read_app_meta` and + // calls `seed_draft` only when it comes back), so a glyph typed the + // instant the pane settles can be wiped by that seed arriving — leaving a + // clean draft, no "unsaved changes" dialog, and nothing saved. The seed + // happens once per transition, so this converges immediately. + const deadline = performance.now() + 15_000; + for (;;) { + await field.fill(glyph); + await page.waitForTimeout(300); + if (await field.inputValue() === glyph) break; + check( + performance.now() < deadline, + "the app sheet's glyph field would not hold a value", + ); + } + await settingsButton(page).click(); + const confirm = page.locator("#visor-confirm"); + await confirm.waitFor({ timeout: 10_000 }); + await confirm.getByRole("button", { name: "Save", exact: true }).click(); + await confirm.waitFor({ state: "detached", timeout: 15_000 }); + await paneSettled(page); + await openApps(page); + await page.waitForFunction( + (want) => { + const label = Array.from( + document.querySelectorAll("#visor-drawer label"), + ).find((l) => l.querySelector("span")?.textContent === "glyph"); + const input = label?.querySelector("input") as + | HTMLInputElement + | undefined; + return input?.value === want; + }, + glyph, + { timeout: 15_000 }, + ); +} + +/** Press "Install as app" and read back the manifest. + * + * Waits for the link's href to CHANGE: a second install starts with the + * first one's link in the document, so "a link is present" would read the + * previous manifest back while this install is still painting. */ +async function installAndReadManifest( + page: Page, + // deno-lint-ignore no-explicit-any +): Promise { + await openApps(page); + const before = await page.evaluate(() => + document.querySelector("link[rel=manifest]")?.href ?? "" + ); + await page.getByRole("button", { name: "Install as app" }).click(); + await page.waitForFunction( + (was) => { + const link = document.querySelector( + "link[rel=manifest]", + ); + return link !== null && link.href !== was; + }, + before, + { timeout: 30_000 }, + ); + return await page.evaluate(async () => { + const href = + document.querySelector("link[rel=manifest]")!.href; + return await (await fetch(href)).json(); + }); +} + +/** Fetch an icon FROM THE PAGE (the only client the worker controls) and + * decode it: `ok` and a byte length would pass on an HTML error page + * wearing a `.png` URL. `ink` counts near-white pixels, `corner` the + * ground. */ +async function probeIcon(page: Page, src: string): Promise<{ + status: number; + type: string | null; + width: number; + height: number; + ink: number; + corner: [number, number, number]; +}> { + return await page.evaluate(async (url) => { + const res = await fetch(url); + const type = res.headers.get("content-type"); + const none = [0, 0, 0] as [number, number, number]; + if (!res.ok) { + return { status: res.status, type, width: 0, height: 0, ink: 0, corner: none }; + } + const bitmap = await createImageBitmap(await res.blob()); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(bitmap, 0, 0); + const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height); + let ink = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i] > 235 && data[i + 1] > 235 && data[i + 2] > 235) ink++; + } + return { + status: res.status, + type, + width: bitmap.width, + height: bitmap.height, + ink, + corner: [data[0], data[1], data[2]] as [number, number, number], + }; + }, src); +} + /** Settings → the device's user-voice petname. Typed into the draft, and * `Save` is the only thing the kernel hears. */ async function setDeviceName(page: Page, name: string): Promise { @@ -1245,13 +1387,10 @@ const scenarios: Scenario[] = [ }, { - // Playwright cannot complete an OS install (there is no chrome around - // the page to click "Install"), so this asserts the one artifact the - // glue actually controls: the manifest `shell.install-app` mints - // (internal.wit `shell.install-app`, docs/design.md "Routing"). The - // button lives on the visor track (visor/src/ui.rs `AppInfo` sheet); - // if it has not landed yet this scenario fails at the click and that - // failure names exactly what is missing. + // Playwright cannot complete an OS install, so this asserts what the + // glue does control: the manifest `shell.install-app` mints and the + // icons it paints, stores and serves (docs/design.md "Routing", which + // also records what a green run here does NOT say about Android). name: "install-app-manifest", async run(ctx, origin) { const page = await open(ctx, origin); @@ -1259,25 +1398,31 @@ const scenarios: Scenario[] = [ await keepDevice(page, "the workbench"); await launchTodoMvc(page); - await appsButton(page).click(); - await paneSettled(page); - await page.getByRole("button", { name: "Install as app" }).click(); + const base = await page.evaluate(() => new URL(".", location.href).href); - await page.waitForFunction( - () => document.querySelector("link[rel=manifest]") !== null, - undefined, - { timeout: 10_000 }, + // Baseline: no saved glyph, so the static icons — real files, no + // service worker involved. + const plain = await installAndReadManifest(page); + check( + Array.isArray(plain.icons) && plain.icons.length === 2 && + plain.icons.every((i: { src: string }) => + i.src === base + "icon-512.png" || i.src === base + "icon-192.png" + ), + "an install with no saved glyph must fall back to the static icons", ); + for (const icon of plain.icons as { src: string }[]) { + // `page.request` never goes through a service worker: the network + // is what answers here. + const res = await page.request.get(icon.src); + check(res.ok(), `static icon ${icon.src} is not served`); + } - const manifest = await page.evaluate(async () => { - const href = - document.querySelector("link[rel=manifest]")! - .href; - const res = await fetch(href); - return await res.json(); - }); + // "★" and not an emoji: a colour emoji font ignores the white fill + // the pixel checks below look for. Two typed, one drawn — the icon + // must agree with the strip's first-`char` rule. + await setAppGlyph(page, "★x"); - const base = await page.evaluate(() => new URL(".", location.href).href); + const manifest = await installAndReadManifest(page); const startUrl = await page.evaluate( (b) => new URL("#launch/todomvc", b).href, base, @@ -1298,31 +1443,181 @@ const scenarios: Scenario[] = [ typeof manifest.name === "string" && manifest.name.includes("TodoMVC"), "manifest name must carry the app's title", ); + + // The worker's now: under the page's base and named by a digest. + const icons = manifest.icons as { src: string; sizes: string }[]; check( - Array.isArray(manifest.icons) && manifest.icons.length === 2 && - manifest.icons.every((i: { src: string }) => - i.src.startsWith(new URL(".", page.url()).href) && - i.src.endsWith(".png") + Array.isArray(icons) && icons.length === 2 && + icons.every((i) => + new RegExp( + "^" + base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + + "launcher-icons/[0-9a-f]{64}\\.png$", + ).test(i.src) ), - "manifest must carry two https: icons under the page's base", + `manifest icons must be base-relative digest URLs: ${ + JSON.stringify(icons) + }`, + ); + eq( + icons.map((i) => i.sizes).sort(), + ["192x192", "512x512"], + "the manifest must name both launcher sizes", + ); + check( + !icons.some((i) => i.src.includes("★")), + "the glyph must not appear literally in an icon URL", + ); + + const bySize = new Map(icons.map((i) => [i.sizes, i.src])); + const big = await probeIcon(page, bySize.get("512x512")!); + const small = await probeIcon(page, bySize.get("192x192")!); + eq( + [big.status, big.width, big.height], + [200, 512, 512], + "the 512 icon must be served and decode at 512x512", ); - // ...and the icons must actually be there: a WebAPK server fetches - // them by URL. - for (const icon of manifest.icons as { src: string }[]) { + eq( + [small.status, small.width, small.height], + [200, 192, 192], + "the 192 icon must be served and decode at 192x192", + ); + eq(big.type, "image/png", "the launcher icon's content type"); + // A glyph really is on it: white ink present, corner still the hue + // ground. An all-ground image (a glyph that never rendered) and an + // all-white one both fail here. + check( + big.ink > 0 && big.ink < 512 * 512, + `the 512 icon must carry white glyph ink on a coloured ground ` + + `(ink=${big.ink})`, + ); + check( + !(big.corner[0] > 235 && big.corner[1] > 235 && big.corner[2] > 235), + `the icon's corner must be the hue ground, not ink: ${big.corner}`, + ); + + // NOT network-hosted: the same URL off the network is a 404, because + // no such file exists in the deployment. This is what says the icon + // came out of Cache Storage and not off disk. + for (const icon of icons) { const res = await page.request.get(icon.src); - check(res.ok(), `icon ${icon.src} is not served`); + eq( + res.status(), + 404, + `${icon.src} must not be served by the network — the worker is ` + + `the only thing that answers it`, + ); } - // The Pages rule (docs/design.md "Routing"): nothing in the manifest - // may be root-absolute, which on a project Pages site names somebody - // else's page. - const flat = JSON.stringify(manifest); + + // A miss is a 404, never the app shell. + const missing = await probeIcon( + page, + base + "launcher-icons/" + "0".repeat(64) + ".png", + ); + eq(missing.status, 404, "an unknown launcher icon must be a 404"); + + // Not an offline cache: an unrelated fetch from the controlled page + // still reaches the network. + const config = await page.evaluate(async () => + await (await fetch("./config.json")).json() + ); + check( + typeof config?.relay === "string", + "an unrelated fetch from the controlled page must reach the network", + ); + + // A different saved glyph is a different image at a different URL — + // also the check that the paint reads the saved map. + await setAppGlyph(page, "▲"); + const second = await installAndReadManifest(page); + const secondIcons = second.icons as { src: string; sizes: string }[]; + check( + secondIcons.every((i) => !icons.some((j) => j.src === i.src)), + "a different saved glyph must produce different icon URLs", + ); + const repainted = await probeIcon( + page, + secondIcons.find((i) => i.sizes === "512x512")!.src, + ); + eq( + [repainted.status, repainted.width], + [200, 512], + "the repainted 512 icon must be served and decode", + ); + check( + repainted.ink !== big.ink, + "a different glyph must paint a different amount of ink", + ); + + // The cache outlives the page, and nothing here deletes. + await page.reload(); + await visorReady(page); + await page.waitForFunction( + () => navigator.serviceWorker.controller !== null, + undefined, + { timeout: 15_000 }, + ); + const survived = await probeIcon(page, bySize.get("512x512")!); + eq( + [survived.status, survived.width], + [200, 512], + "a launcher icon must survive a page reload", + ); + + // The Pages rule: nothing root-absolute, which on a project site + // names somebody else's page. check( - !/"\/[^/]/.test(flat), + !/"\/[^/]/.test(JSON.stringify(manifest)), "no manifest field may start with a root-absolute /", ); }, }, + { + // The same install on a deployment that is not at the origin root — + // every GitHub Pages project site. Exercises what a `/`-hardcode would + // break: scope, script URL and icon URLs all derived from the base. + name: "install-icons-under-a-subpath", + async run(ctx, origin) { + const page = await ctx.newPage(); + page.on("pageerror", (e) => console.error(" page error:", e.message)); + await page.goto(origin + SUBPATH); + await visorReady(page); + await keepDevice(page, "the workbench"); + await launchTodoMvc(page); + await setAppGlyph(page, "★"); + + const manifest = await installAndReadManifest(page); + const base = origin + SUBPATH; + const icons = manifest.icons as { src: string; sizes: string }[]; + check( + icons.length === 2 && + icons.every((i) => + i.src.startsWith(base + "launcher-icons/") && i.src.endsWith(".png") + ), + `icons must resolve under the deployment subpath: ${ + JSON.stringify(icons) + }`, + ); + // The worker took the subpath as its scope, not the origin root. + const scopes = await page.evaluate(async () => + (await navigator.serviceWorker.getRegistrations()).map((r) => r.scope) + ); + eq(scopes, [base], "the icon worker's scope must be the page's base"); + + const probe = await probeIcon( + page, + icons.find((i) => i.sizes === "512x512")!.src, + ); + eq( + [probe.status, probe.width, probe.height], + [200, 512, 512], + "the subpath deployment's launcher icon must be served and decode", + ); + check(probe.ink > 0, "the subpath icon must carry glyph ink"); + await page.close(); + }, + }, + { name: "frame-violation-ends-session", async run(ctx, origin) { diff --git a/runtime/wit/internal.wit b/runtime/wit/internal.wit index dbc38b60..4aa0c7fe 100644 --- a/runtime/wit/internal.wit +++ b/runtime/wit/internal.wit @@ -525,14 +525,26 @@ interface shell { /// web app manifest the glue cannot know. `title` is app voice, shown /// by the OS launcher unplated, so the glue composes the manifest name /// from it and the framework's own; `hue` is the user's, for the - /// window's theme colour. The icon is the framework's own static one: - /// Android's WebAPK server fetches icons by URL, so nothing painted on - /// the client can be a launcher icon (docs/design.md "Routing"). + /// window's theme colour and for the icon's ground. + /// + /// `glyph` is the *saved* app glyph (`device.meta`, `meta-scope.app`, + /// the visor's `glyph` key) — never an unsaved draft: an install writes + /// into the OS's app registry, so what it paints has to be something + /// the user committed to. Empty means the user set none, and the glue + /// falls back to the framework's own static icons. + /// + /// A worker-served icon reaches an Android launcher only if the install + /// uses image bytes the browser itself fetched: a remote WebAPK-minting + /// server cannot reach a service worker, whose responses exist only + /// inside this browser. Which of the two the Android path does is the + /// open question (docs/design.md "Routing"). record install-request { /// From `apps.install-fragment`: what the installed window opens at. fragment: string, title: string, hue: u16, + /// The saved app glyph, or "" for none. + glyph: string, } /// How an install request ended on the page. @@ -550,6 +562,14 @@ interface shell { /// against the page's own base, so a `blob:` manifest resolves nothing /// relative to itself — points the document's manifest link at it, and /// calls the install prompt the browser offered earlier, if any. + /// + /// With a non-empty `glyph` the glue first paints the glyph on the + /// user's hue at 512 and 192, stores the PNGs in a dedicated Cache + /// Storage cache under opaque content-digest URLs, and names those URLs + /// in the manifest; a service worker on the home origin is what answers + /// them. Any failure along that path — no service worker, no canvas, no + /// activation in time — falls back to the framework's static icons, so + /// an install never fails for want of art. install-app: async func(request: install-request) -> result; } diff --git a/visor/src/kernel.rs b/visor/src/kernel.rs index 997a5ff8..6c1646c2 100644 --- a/visor/src/kernel.rs +++ b/visor/src/kernel.rs @@ -369,15 +369,22 @@ pub(crate) type InstallOutcome = api::shell::InstallOutcome; /// Install `app` as its own installed web app (internal.wit /// `shell.install-app`). +/// +/// `glyph` is the app's SAVED glyph (`meta-scope.app`), never the sheet's +/// unsaved draft: an install writes into the OS's app registry, so the mark +/// it carries has to be one the user committed to. "" means none, and the +/// glue falls back to the framework's static icons. pub(crate) async fn install_app( fragment: String, title: String, hue: u16, + glyph: String, ) -> Result { api::shell::install_app(api::shell::InstallRequest { fragment, title, hue, + glyph, }) .await .map_err(message) diff --git a/visor/src/ui.rs b/visor/src/ui.rs index de025acf..a172b0e5 100644 --- a/visor/src/ui.rs +++ b/visor/src/ui.rs @@ -791,10 +791,16 @@ pub(crate) fn Visor() -> Element { // since the fragment it opens at is the kernel's (`install-fragment`) // and the name the launcher shows is composed by the glue from the // app's title (docs/design.md "Routing", the `launch/` bullet). - let install_as_app = move |app: App, hue: u16| async move { + // + // `glyph` is read off `app_meta` — the map the kernel last confirmed — + // and never off `draft.app`, which is text the user is still typing. + // An install is written into the OS's app registry and replayed for + // months; seeding it from an unsaved field would mint a launcher icon + // the user could then Revert away from. + let install_as_app = move |app: App, hue: u16, glyph: String| async move { let outcome = match kernel::install_fragment(&app.id).await { Ok(fragment) => { - kernel::install_app(fragment, app.title.expose().to_string(), hue).await + kernel::install_app(fragment, app.title.expose().to_string(), hue, glyph).await } Err(e) => Err(e), }; @@ -1091,13 +1097,16 @@ pub(crate) fn Visor() -> Element { } // Only offered for a live session: the fragment // is `install-fragment`'s, this app's `launch/` - // route (docs/design.md "Routing"). + // route (docs/design.md "Routing"). The glyph + // is the saved one, read here off `app_meta` + // rather than out of the draft above. button { onclick: { let live = live.clone(); move |_| { let app = live.clone().unwrap().1; - async move { install_as_app(app, hue).await } + let glyph = glyph_of(&app_meta.read()); + async move { install_as_app(app, hue, glyph).await } } }, "Install as app" diff --git a/web/boot.ts b/web/boot.ts index 48afae49..15b2cbee 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -636,6 +636,163 @@ interface InstallRequest { fragment: string; title: string; hue: number; + /** The user's *saved* glyph for this app (`device.meta`, + * `meta-scope.app`), or "" for none. */ + glyph: string; +} + +/** Both must match web/icon-sw.ts, the cache's only reader. Versioned in + * the name: a change to what is stored gets a new cache, not a migration. */ +const ICON_CACHE = "polyvisor-launcher-icons-v1"; +const ICON_DIR = "launcher-icons/"; + +/** The framework's own icons: real files, the fallback whenever the painted + * path does not come off, so art never blocks an install. */ +function staticIcons( + base: URL, +): { src: string; sizes: string; type: string }[] { + return [ + { + src: new URL("icon-512.png", base).href, + sizes: "512x512", + type: "image/png", + }, + { + src: new URL("icon-192.png", base).href, + sizes: "192x192", + type: "image/png", + }, + ]; +} + +/** Poll `ready` to a deadline. Nothing below has a single event meaning + * "and now it would actually answer a fetch". */ +async function waitFor(ready: () => boolean, ms: number): Promise { + const deadline = performance.now() + ms; + while (performance.now() < deadline) { + if (ready()) return true; + await new Promise((r) => setTimeout(r, 50)); + } + return ready(); +} + +/** + * Register the launcher-icon worker; `true` once it would answer a fetch + * from this page. Lazy — called from `installApp` and nowhere else. + * + * `activated` and a controller that is OURS, not merely non-null: a worker + * only intercepts fetches from clients it controls, and `controller` may be + * some other worker entirely. + */ +async function iconWorker(base: URL): Promise { + if (!("serviceWorker" in navigator)) return false; + // Base scope, not `launcher-icons/` (see web/icon-sw.ts), and + // base-relative — never `/icon-sw.js`, somebody else's page on a project + // Pages site. + const scope = base.href; + const script = new URL("icon-sw.js", base).href; + const found = await navigator.serviceWorker.getRegistration(scope); + // `getRegistration` also answers with a broader-scoped registration that + // merely contains this URL; only one at this exact scope is in the way. + const here = found?.scope === scope ? found : undefined; + if (here !== undefined) { + // Some other worker owns this scope. Registering would replace it, and + // it is not ours to replace: take the static icons instead. + const owner = here.active ?? here.waiting ?? here.installing; + if (owner !== null && owner.scriptURL !== script) return false; + } + const reg = here !== undefined && here.active?.scriptURL === script + ? here + : await navigator.serviceWorker.register(script, { + type: "module", + scope, + }); + if (!await waitFor(() => reg.active?.state === "activated", 10_000)) { + return false; + } + return await waitFor( + () => navigator.serviceWorker.controller?.scriptURL === script, + 10_000, + ); +} + +/** One icon: the glyph, white, centred on the hue. Same formula as the + * strip (visor/src/style.rs `--strip: oklch(0.62 0.14 var(--hue))`). A + * colour emoji font paints its own colours and ignores the white, which is + * right — the user picked that emoji, not a silhouette of it. */ +async function paintIcon( + glyph: string, + hue: number, + size: number, +): Promise { + // The PNG is digested and stored immediately, with no repaint later, so a + // font that has not loaded would be tofu for good. + if (document.fonts !== undefined) await document.fonts.ready; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (ctx === null) throw new Error("no 2d canvas context"); + ctx.fillStyle = `oklch(0.62 0.14 ${hue})`; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = "#ffffff"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `${Math.round(size * 0.62)}px system-ui, sans-serif`; + // The first `char` only, as the strip draws it (visor/src/ui.rs + // `glyph_of`). + ctx.fillText([...glyph][0] ?? "", size / 2, size / 2); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png") + ); + if (blob === null) throw new Error("canvas produced no PNG"); + return await blob.arrayBuffer(); +} + +/** Hex SHA-256 of `bytes`, from WebCrypto. It names an icon by its content: + * the same image reuses one entry, a different one gets a different URL + * instead of overwriting art a browser may still be holding. Not a secret — + * the space of glyph-on-hue images is small enough to enumerate. */ +async function digest(bytes: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Paint, store and name this install's icons, or `undefined` for "use the + * static ones" (docs/design.md "Routing" for what this probe does and does + * not show). + * + * Everything is in place before this returns — worker controlling, both + * PNGs cached — so the manifest never names an icon nothing answers. + */ +async function paintedIcons( + glyph: string, + hue: number, + base: URL, +): Promise<{ src: string; sizes: string; type: string }[] | undefined> { + if (glyph === "") return undefined; + try { + if (!await iconWorker(base)) return undefined; + const cache = await caches.open(ICON_CACHE); + const icons: { src: string; sizes: string; type: string }[] = []; + for (const size of [512, 192]) { + const png = await paintIcon(glyph, hue, size); + const src = new URL(`${ICON_DIR}${await digest(png)}.png`, base).href; + await cache.put( + src, + new Response(png, { headers: { "content-type": "image/png" } }), + ); + icons.push({ src, sizes: `${size}x${size}`, type: "image/png" }); + } + return icons; + } catch { + // A refused canvas, a quota, a disallowed registration: static icons, + // not a failed install. + return undefined; + } } /** The blob URL our own `` currently points at, so a @@ -663,6 +820,12 @@ async function installApp( // `--strip: oklch(0.62 0.14 var(--hue))`). const themeColor = `oklch(0.62 0.14 ${request.hue})`; + // The saved glyph on the user's hue, served by the launcher-icon worker, + // or the framework's static icons when that does not come off. Both are + // real https: URLs under the page's base; a `blob:` icon is not. + const icons = await paintedIcons(request.glyph, request.hue, base) ?? + staticIcons(base); + const manifest = { name: `${request.title} — polyvisor`, short_name: request.title, @@ -670,21 +833,7 @@ async function installApp( start_url: startUrl, scope, id, - // Static, on the home origin: Android's WebAPK server fetches icons by - // URL itself, so a blob: icon is unreachable to it and the install - // degrades to a shortcut. The glyph-on-hue icon is gone with that. - icons: [ - { - src: new URL("icon-512.png", base).href, - sizes: "512x512", - type: "image/png", - }, - { - src: new URL("icon-192.png", base).href, - sizes: "192x192", - type: "image/png", - }, - ], + icons, theme_color: themeColor, background_color: "#ffffff", }; @@ -887,8 +1036,10 @@ async function main(): Promise { }), // internal.wit `shell.install-app`: mints the manifest, points the // document at it, and calls whatever install prompt the browser - // deferred earlier. Errors (icon encoding, most plausibly) surface as - // the WIT's `result<_, error>` arm, same reasoning as `open-frame`. + // deferred earlier. The icon path swallows its own failures into a + // static-icon fallback, so what surfaces on the WIT's `result<_, + // error>` arm is a manifest that could not be put in place at all — + // same reasoning as `open-frame`. installApp: (request: InstallRequest) => installApp(request).catch((err: unknown) => { throw new ComponentException({ diff --git a/web/build.ts b/web/build.ts index da0718b5..8dbabb39 100644 --- a/web/build.ts +++ b/web/build.ts @@ -189,10 +189,17 @@ await ensureDir(DIST); await bundle("boot.ts", "boot.js"); await bundle("worker.ts", "worker.js"); await bundle("frame.ts", "frame.js"); +// The launcher-icon service worker (web/icon-sw.ts). It lands beside +// `index.html` rather than inside `launcher-icons/` because a worker's +// default scope is its own directory and it needs the page's base: a +// broader scope would need a `Service-Worker-Allowed` response header, +// which a GitHub Pages deployment cannot set. It derives every path it uses +// from `registration.scope`, so a project-site subpath needs nothing here. +await bundle("icon-sw.ts", "icon-sw.js"); await copy(join(ROOT, "web", "index.html"), join(DIST, "index.html")); -// Launcher icons at real https: URLs: Android's WebAPK server fetches a -// manifest's icons itself, so a blob: icon can never mint an installed app -// (docs/design.md "Routing", the `launch/` bullet). +// The framework's own launcher icons, at real https: URLs: the fallback +// whenever an install has no saved glyph to paint or the icon worker does +// not come up (docs/design.md "Routing", the `launch/` bullet). for (const icon of ["icon-512.png", "icon-192.png"]) { await copy(join(ROOT, "web", icon), join(DIST, icon), { overwrite: true }); } diff --git a/web/icon-sw.ts b/web/icon-sw.ts new file mode 100644 index 00000000..9904701c --- /dev/null +++ b/web/icon-sw.ts @@ -0,0 +1,96 @@ +// The launcher-icon service worker (docs/design.md "Routing", the `launch/` +// bullet, which carries the reasoning and the caveats). +// +// One job: answer the icon URLs an install named in its manifest, out of +// the cache web/boot.ts wrote them into. Not an offline cache — every other +// request returns without `respondWith`, so the browser does exactly what it +// would have done had this worker never existed — and a miss is a 404, never +// the app shell, because an HTML body wearing an icon's URL looks like a +// success and is not one. + +export {}; + +// Both must match web/boot.ts, the cache's only writer: separate bundles, +// no module in common. The e2e scenario fetches an icon for real, so a name +// that drifted fails a gate rather than degrading quietly. +const ICON_CACHE = "polyvisor-launcher-icons-v1"; +const ICON_DIR = "launcher-icons/"; + +/** A stored icon's file name: the hex SHA-256 of its PNG bytes. */ +const ICON_NAME = /^[0-9a-f]{64}\.png$/; + +// Typed locally because deno.json's `lib` is `dom`, not `webworker`, so +// `ServiceWorkerGlobalScope` and friends do not exist here — and adding +// `webworker` would put a second, conflicting `self` in front of every other +// file in `web/`. These are the members used and no more. + +interface ExtendableEventLike extends Event { + waitUntil(promise: Promise): void; +} + +interface FetchEventLike extends ExtendableEventLike { + readonly request: Request; + respondWith(response: Response | Promise): void; +} + +interface ServiceWorkerScope { + readonly registration: { readonly scope: string }; + readonly clients: { claim(): Promise }; + skipWaiting(): Promise; + addEventListener( + type: "install" | "activate", + listener: (event: ExtendableEventLike) => void, + ): void; + addEventListener( + type: "fetch", + listener: (event: FetchEventLike) => void, + ): void; +} + +const sw = self as unknown as ServiceWorkerScope; + +// Derived from the registration's own scope, which is the page's base and +// never `/` — on a project Pages site the root is somebody else's page. The +// scope is the base rather than this directory because a worker intercepts +// only fetches from clients it controls, and the client fetching an icon is +// the page; the narrowing that matters is `isIconRequest`, below. +const iconDir = new URL(ICON_DIR, sw.registration.scope); + +/** Exact, because every request the page makes goes past here: own-origin + * GET, directly inside the icon directory, digest file name, no query or + * fragment. Anything else is not this worker's business. */ +function isIconRequest(request: Request): boolean { + if (request.method !== "GET") return false; + const url = new URL(request.url); + if (url.origin !== iconDir.origin) return false; + if (url.search !== "" || url.hash !== "") return false; + if (!url.pathname.startsWith(iconDir.pathname)) return false; + return ICON_NAME.test(url.pathname.slice(iconDir.pathname.length)); +} + +async function handle(request: Request): Promise { + const cache = await caches.open(ICON_CACHE); + const hit = await cache.match(request); + if (hit !== undefined) return hit; + return new Response("no such launcher icon", { + status: 404, + headers: { "content-type": "text/plain" }, + }); +} + +// The page that registers this worker is about to name icon URLs in a +// manifest, so a worker parked in `waiting` is the same as no worker, and an +// unclaimed page stays uncontrolled until a navigation. Nothing is deleted: +// the cache is versioned in its own name. +sw.addEventListener("install", (event) => { + event.waitUntil(sw.skipWaiting()); +}); + +sw.addEventListener("activate", (event) => { + event.waitUntil(sw.clients.claim()); +}); + +sw.addEventListener("fetch", (event) => { + if (!isIconRequest(event.request)) return; + event.respondWith(handle(event.request)); +}); From 8e82eb8d07ef3bd1b9f1d10d731ae14193cb25bd Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Tue, 8 Sep 2026 01:07:05 -0400 Subject: [PATCH 2/2] e2e: wait for glyph save before leaving the app sheet --- e2e/run.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/e2e/run.ts b/e2e/run.ts index de65b1c6..c5146ea3 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -349,6 +349,49 @@ async function saveDraft(page: Page): Promise { ); } +/** + * Raise the running app's own sheet, from wherever the drawer is. + * + * Not by pressing the strip's app half from another pane. With a session + * running, every pane offers "Return to app" and that is the supported way + * out (visor/src/ui.rs `dismissal`); pressing the half for the tenant + * already on screen is worse than useless, since a dirty draft parks the + * transition and the dialog that raises makes the strip `inert` — after + * which no press can land at all. + */ +async function toAppSheet(page: Page): Promise { + // Settled first. A drawer in mid-close is `inert`, and so is one under + // the dialog (visor/src/ui.rs, `flag(confirming || shutting)`): its + // buttons take no press and the scrim behind takes it instead. + await page.waitForFunction( + () => { + const d = document.querySelector("#visor-drawer"); + return d === null || !d.hasAttribute("inert"); + }, + undefined, + { timeout: 15_000 }, + ); + const back = drawer(page).getByRole("button", { + name: "Return to app", + exact: true, + }); + if (await back.count() > 0) { + await back.click(); + // `data-visor-app-inert` is the visor's own statement that the strip is + // not to be pressed, and it outlives the closing animation + // (visor/src/ui.rs `app_inert`). + await page.waitForFunction( + () => + document.querySelector("#visor-root")?.hasAttribute( + "data-visor-app-inert", + ) === false, + undefined, + { timeout: 10_000 }, + ); + } + await openApps(page); +} + /** * The running app's own glyph (`device.meta`, `meta-scope.app`), typed and * SAVED — which is the value an install is allowed to paint with. @@ -361,7 +404,7 @@ async function saveDraft(page: Page): Promise { * that installs next is installing against a saved map, not a draft. */ async function setAppGlyph(page: Page, glyph: string): Promise { - await openApps(page); + await toAppSheet(page); // `^glyph$`: the settings sheet's field is "your glyph", and this is the // app sheet's. const field = drawer(page).locator("label").filter({ hasText: /^glyph$/ }) @@ -388,9 +431,18 @@ async function setAppGlyph(page: Page, glyph: string): Promise { const confirm = page.locator("#visor-confirm"); await confirm.waitFor({ timeout: 10_000 }); await confirm.getByRole("button", { name: "Save", exact: true }).click(); + // The dialog clears the moment it is answered, but the save it asked for + // is async and the parked transition is taken only once that save lands + // (visor/src/ui.rs `save_now`). The settings pane the dialog was guarding + // is therefore the observable that says the kernel has the glyph: + // navigating on the dialog's disappearance alone races the save, finds + // the draft still dirty, and parks the next transition behind a second + // dialog that nothing can then dismiss. await confirm.waitFor({ state: "detached", timeout: 15_000 }); + await drawer(page).locator("label").filter({ hasText: /^device petname$/ }) + .waitFor({ timeout: 15_000 }); await paneSettled(page); - await openApps(page); + await toAppSheet(page); await page.waitForFunction( (want) => { const label = Array.from( @@ -415,7 +467,7 @@ async function installAndReadManifest( page: Page, // deno-lint-ignore no-explicit-any ): Promise { - await openApps(page); + await toAppSheet(page); const before = await page.evaluate(() => document.querySelector("link[rel=manifest]")?.href ?? "" );