From d25297927961b6f9fc22db4f3901d7778343fcbe Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 23:52:20 -0400 Subject: [PATCH] Install: static framework icons at https: URLs, not client-painted blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android installs are WebAPKs, minted by a server that fetches the manifest's icons by URL; a blob: icon is unreachable to it and the install degrades to "Add to Home screen". So the icons are the framework's own PNGs on the home origin, and the glyph-on-hue icon — and the `glyph` field of install-request — go. This is also the probe for the next question: whether that server re-fetches the manifest itself, which decides whether a blob: manifest can ever install on Android or a service worker has to serve one. --- docs/design.md | 12 +++++++-- e2e/run.ts | 11 ++++++-- runtime/wit/internal.wit | 8 +++--- visor/src/kernel.rs | 8 ++---- visor/src/ui.rs | 26 +++++++------------ web/boot.ts | 53 +++++++++++---------------------------- web/build.ts | 6 +++++ web/icon-192.png | Bin 0 -> 413 bytes web/icon-512.png | Bin 0 -> 1496 bytes 9 files changed, 55 insertions(+), 69 deletions(-) create mode 100644 web/icon-192.png create mode 100644 web/icon-512.png diff --git a/docs/design.md b/docs/design.md index 6b6c9a5c..49a0f3f2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -509,8 +509,16 @@ 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 user's glyph on the user's hue, composed by the visor: the one - place the trusted pixels can reach the launcher. Chromium only; iOS + 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 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 8657a97d..8531d129 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -1301,10 +1301,17 @@ const scenarios: Scenario[] = [ check( Array.isArray(manifest.icons) && manifest.icons.length === 2 && manifest.icons.every((i: { src: string }) => - i.src.startsWith("blob:") + i.src.startsWith(new URL(".", page.url()).href) && + i.src.endsWith(".png") ), - "manifest must carry two blob: icons", + "manifest must carry two https: icons under the page's base", ); + // ...and the icons must actually be there: a WebAPK server fetches + // them by URL. + for (const icon of manifest.icons as { src: string }[]) { + const res = await page.request.get(icon.src); + check(res.ok(), `icon ${icon.src} is not served`); + } // 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. diff --git a/runtime/wit/internal.wit b/runtime/wit/internal.wit index 91519338..dbc38b60 100644 --- a/runtime/wit/internal.wit +++ b/runtime/wit/internal.wit @@ -524,14 +524,14 @@ interface shell { /// What the visor asks the page to install an app as: the fields of a /// 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; `glyph` and `hue` are the user's - /// labels for the app, drawn into the icon — the one place the trusted - /// pixels can reach the launcher. + /// 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"). record install-request { /// From `apps.install-fragment`: what the installed window opens at. fragment: string, title: string, - glyph: string, hue: u16, } diff --git a/visor/src/kernel.rs b/visor/src/kernel.rs index de91a1b7..997a5ff8 100644 --- a/visor/src/kernel.rs +++ b/visor/src/kernel.rs @@ -367,20 +367,16 @@ pub(crate) async fn install_fragment(app: &str) -> Result { /// branch on. pub(crate) type InstallOutcome = api::shell::InstallOutcome; -/// Install `app` as its own installed web app. `title`/`glyph`/`hue` are -/// the user's own labels — the icon the OS launcher shows is drawn from -/// them, the one place the trusted pixels can reach the launcher -/// (docs/design.md "Routing", the `launch/` bullet). +/// Install `app` as its own installed web app (internal.wit +/// `shell.install-app`). pub(crate) async fn install_app( fragment: String, title: String, - glyph: String, hue: u16, ) -> Result { api::shell::install_app(api::shell::InstallRequest { fragment, title, - glyph, hue, }) .await diff --git a/visor/src/ui.rs b/visor/src/ui.rs index 05e17495..de025acf 100644 --- a/visor/src/ui.rs +++ b/visor/src/ui.rs @@ -787,16 +787,14 @@ pub(crate) fn Visor() -> Element { apply.call(Action::Close); }; - // Install the running app as its own OS-level app. `title`/`glyph`/ - // `hue` are the user's own labels for it — the icon the launcher shows - // is drawn from them, the one place the trusted pixels can reach the - // launcher (docs/design.md "Routing", the `launch/` bullet) — so this - // is the visor's own act and not something the app or the glue could - // do unsupervised. - let install_as_app = move |app: App, glyph: String, hue: u16| async move { + // Install the running app as its own OS-level app: the visor's own act, + // 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 { let outcome = match kernel::install_fragment(&app.id).await { Ok(fragment) => { - kernel::install_app(fragment, app.title.expose().to_string(), glyph, hue).await + kernel::install_app(fragment, app.title.expose().to_string(), hue).await } Err(e) => Err(e), }; @@ -1092,20 +1090,14 @@ pub(crate) fn Visor() -> Element { "Close app" } // Only offered for a live session: the fragment - // is `install-fragment`'s (this app's `launch/` - // route), and the icon is drawn from the - // user's own glyph and hue — the launcher shows - // a mark only this device's user chose, not a - // publisher's (docs/design.md "Routing", the - // `launch/` bullet). + // is `install-fragment`'s, this app's `launch/` + // route (docs/design.md "Routing"). button { onclick: { let live = live.clone(); - let glyph = info_glyph.clone(); move |_| { let app = live.clone().unwrap().1; - let glyph = glyph.clone(); - async move { install_as_app(app, glyph, hue).await } + async move { install_as_app(app, hue).await } } }, "Install as app" diff --git a/web/boot.ts b/web/boot.ts index ec19f5e4..48afae49 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -635,7 +635,6 @@ function closeFrame(session: number): void { interface InstallRequest { fragment: string; title: string; - glyph: string; hue: number; } @@ -644,37 +643,6 @@ interface InstallRequest { * there is none to inherit; `index.html` carries no such link. */ let manifestBlobUrl: string | undefined; -/** One 512×512 (or `size`, scaled) PNG of the app's glyph on the user's hue, - * as a `blob:` URL. This is the one place the trusted pixels reach the - * launcher (internal.wit `shell.install-app` docs) — an installed app's - * icon is not app-controlled art, it is the visor's own paint of the - * user's labels for it, exactly as the strip button beside it is. */ -function paintIcon(glyph: string, hue: number, size: number): Promise { - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d")!; - // Same formula as the strip's own hue paint (visor/src/style.rs - // `--strip: oklch(0.62 0.14 var(--hue))`), so an installed app's icon - // reads as the same colour as its button in the strip it came from. - ctx.fillStyle = `oklch(0.62 0.14 ${hue})`; - ctx.fillRect(0, 0, size, size); - ctx.fillStyle = "white"; - ctx.font = `${Math.round(size * 0.6)}px system-ui, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(glyph, size / 2, size / 2); - return new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob === null) { - reject(new Error("the browser refused to encode the app icon")); - return; - } - resolve(URL.createObjectURL(blob)); - }, "image/png"); - }); -} - async function installApp( request: InstallRequest, ): Promise<"prompted" | "manual"> { @@ -691,10 +659,8 @@ async function installApp( const scope = base.href; const id = new URL(request.fragment, base).href; - const [icon512, icon192] = await Promise.all([ - paintIcon(request.glyph, request.hue, 512), - paintIcon(request.glyph, request.hue, 192), - ]); + // Same formula as the strip's own hue paint (visor/src/style.rs + // `--strip: oklch(0.62 0.14 var(--hue))`). const themeColor = `oklch(0.62 0.14 ${request.hue})`; const manifest = { @@ -704,9 +670,20 @@ 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: icon512, sizes: "512x512", type: "image/png" }, - { src: icon192, sizes: "192x192", type: "image/png" }, + { + 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", + }, ], theme_color: themeColor, background_color: "#ffffff", diff --git a/web/build.ts b/web/build.ts index 9d14cd86..da0718b5 100644 --- a/web/build.ts +++ b/web/build.ts @@ -190,6 +190,12 @@ await bundle("boot.ts", "boot.js"); await bundle("worker.ts", "worker.js"); await bundle("frame.ts", "frame.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). +for (const icon of ["icon-512.png", "icon-192.png"]) { + await copy(join(ROOT, "web", icon), join(DIST, icon), { overwrite: true }); +} await component( "polyvisor_runtime", diff --git a/web/icon-192.png b/web/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..e182702787725a3b48dcc7da379a433c67730264 GIT binary patch literal 413 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rficC?#WAGf*4s;tybKH+M>Yhv zx!EL$aJQ6C){2-mt8KeSL!*HJ+aZSp1)dflOM-(rQGtgAC;((NLZl=(z*1~XAgPwd z10bnH1+XrJB3kH&TSBJ&c=huvVM6gGHV@!802)dMuQIKRpQCi`vgUFIAnMn8{*NtUZJx>w&JO#Pwk9qU8G?5@=)x z9#ymh{ZC%h!L?DSorkQ11Gk+D+Epk|1QewK1BL-lLk_G+0ZJB=!c;(^b}FQT0Z#-Z drlE$}tT!Ko)fUBm&;pj&44$rjF6*2UngB=LnDYPt literal 0 HcmV?d00001