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 00000000..e1827027 Binary files /dev/null and b/web/icon-192.png differ diff --git a/web/icon-512.png b/web/icon-512.png new file mode 100644 index 00000000..fc93e87c Binary files /dev/null and b/web/icon-512.png differ