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
12 changes: 10 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions runtime/wit/internal.wit
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
8 changes: 2 additions & 6 deletions visor/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,20 +367,16 @@ pub(crate) async fn install_fragment(app: &str) -> Result<String, String> {
/// 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<InstallOutcome, String> {
api::shell::install_app(api::shell::InstallRequest {
fragment,
title,
glyph,
hue,
})
.await
Expand Down
26 changes: 9 additions & 17 deletions visor/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down Expand Up @@ -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"
Expand Down
53 changes: 15 additions & 38 deletions web/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,6 @@ function closeFrame(session: number): void {
interface InstallRequest {
fragment: string;
title: string;
glyph: string;
hue: number;
}

Expand All @@ -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<string> {
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"> {
Expand All @@ -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 = {
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions web/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added web/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 web/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