diff --git a/docs/design.md b/docs/design.md index 5f38d47..6b6c9a5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -489,6 +489,32 @@ interprets. devices hold the key; another device answers "this link is not one this device can open". A future shareable form is a different `kind` with its own envelope and its own policy, not a relaxation of `app/`. +- **The second kind, `launch/`, is for installed apps.** A web + app manifest's `start_url` is written once into the OS's app registry + and replayed unchanged for months, and it names an app the launcher + displays by name and icon anyway. The `app/` token fits that badly + twice over: its opacity hides what the taskbar shows, and its key + binding turns a route-key convergence — an engine event the user never + sees — into an installed app that opens to a refusal. So an install + opens at `launch/`: plaintext, keyless, resolved by + `route-decode` to this user's install of the package at route "". It + carries no app-controlled data, so the residual channel of `app/` does + not exist for it, and it *is* shareable — another user's visor opens + their own copy — which is the correct meaning of "open this app" and + the sharing `app/` refuses. Once the frame is up the bar switches to + `app/` as for any launch. Each package installs as its own app + (`shell.install-app`): the manifest's `id`, `start_url` and `scope` + are written absolute against the page's base (`new URL(".", + location.href)`, the same base the OAuth return uses — never `/`, + 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 + 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 + fallback for this kind exactly because the package id is public). - **Struck: pairing codes in the fragment.** A code is either short enough to type or key material that does not belong in a URL at all; the fragment was a transport looking for a route. diff --git a/e2e/run.ts b/e2e/run.ts index e912cbc..8657a97 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -1201,6 +1201,121 @@ const scenarios: Scenario[] = [ }, }, + { + // The other route kind (docs/design.md "Routing", the `launch/` bullet): + // plaintext, keyless, resolved by `route-decode` to this user's install + // of the package. Opening the fragment cold — no click, the visor + // decodes it itself at boot (visor/src/ui.rs) — is the whole point: a + // launcher replays `start_url` unattended. + name: "launch-fragment-opens-app", + async run(ctx, origin) { + const page = await open(ctx, origin); + await visorReady(page); + await keepDevice(page, "the workbench"); + + const tab = await ctx.newPage(); + await tab.goto(origin + "/#launch/todomvc"); + await visorReady(tab); + await tab.waitForSelector("#app-zone iframe[sandbox]", { + timeout: 30_000, + }); + // Once the frame is up the bar switches to `app/` (docs/design.md: + // "Once the frame is up the bar switches to `app/` as for any + // launch"), same mechanism as any other open (`encodeFragment` in + // web/boot.ts). + await tab.waitForFunction( + () => /^#app\//.test(location.hash), + undefined, + { timeout: 10_000 }, + ); + + // A bogus package: `unknown-app`, in a fresh tab so nothing from the + // first device's session lingers. + const bad = await ctx.newPage(); + await bad.goto(origin + "/#launch/nope"); + await visorReady(bad); + await bad.waitForTimeout(2_000); + check( + await bad.locator("#app-zone iframe").count() === 0, + "a bogus launch/ fragment must not open a frame", + ); + const notice = bad.locator("#visor-notice"); + await notice.getByText(/installed/i).waitFor({ timeout: 10_000 }); + }, + }, + + { + // 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. + name: "install-app-manifest", + async run(ctx, origin) { + const page = await open(ctx, origin); + await visorReady(page); + await keepDevice(page, "the workbench"); + await launchTodoMvc(page); + + await appsButton(page).click(); + await paneSettled(page); + await page.getByRole("button", { name: "Install as app" }).click(); + + await page.waitForFunction( + () => document.querySelector("link[rel=manifest]") !== null, + undefined, + { timeout: 10_000 }, + ); + + const manifest = await page.evaluate(async () => { + const href = + document.querySelector("link[rel=manifest]")! + .href; + const res = await fetch(href); + return await res.json(); + }); + + const base = await page.evaluate(() => new URL(".", location.href).href); + const startUrl = await page.evaluate( + (b) => new URL("#launch/todomvc", b).href, + base, + ); + + eq( + manifest.start_url, + startUrl, + "manifest start_url must be launch/todomvc absolute against the page base", + ); + eq(manifest.scope, base, "manifest scope must be the page base"); + check( + typeof manifest.id === "string" && + manifest.id.includes("launch/todomvc"), + "manifest id must be absolute and name launch/todomvc", + ); + check( + typeof manifest.name === "string" && manifest.name.includes("TodoMVC"), + "manifest name must carry the app's title", + ); + check( + Array.isArray(manifest.icons) && manifest.icons.length === 2 && + manifest.icons.every((i: { src: string }) => + i.src.startsWith("blob:") + ), + "manifest must carry two blob: icons", + ); + // 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); + check( + !/"\/[^/]/.test(flat), + "no manifest field may start with a root-absolute /", + ); + }, + }, + { name: "frame-violation-ends-session", async run(ctx, origin) { diff --git a/runtime/component/src/component.rs b/runtime/component/src/component.rs index a495203..723cbbf 100644 --- a/runtime/component/src/component.rs +++ b/runtime/component/src/component.rs @@ -716,6 +716,9 @@ impl guest::apps::Guest for Component { route, }) } + async fn install_fragment(app: String) -> Result { + kernel()?.install_fragment(&app).map_err(map_error) + } async fn close(session: u32) -> Result<(), Error> { kernel()?.close(session); Ok(()) diff --git a/runtime/crates/kernel/src/lib.rs b/runtime/crates/kernel/src/lib.rs index 24cf953..1b820cd 100644 --- a/runtime/crates/kernel/src/lib.rs +++ b/runtime/crates/kernel/src/lib.rs @@ -1070,6 +1070,26 @@ impl Kernel { /// another user, another key or a flipped bit is the same (`crate::route`). pub async fn route_decode(&self, fragment: String) -> Result<(AppInfo, String), Error> { self.open()?; + // The second kind first (`crate::route` module docs): plaintext, + // keyless, resolved by a registry lookup rather than the sealed + // path below. + if let Some(app) = route::launch_app(&fragment) { + let info = self.registry.info(app).ok_or_else(|| { + Error::new( + ErrorCode::UnknownApp, + "that link names an app that is not installed", + ) + })?; + let engine = self.engine()?; + // Minted here so a later `route-encode` for this session's + // window agrees with this install id (same reason `route_encode` + // mints one). + let (_, wrote) = engine.visor_install(app).await.map_err(engine_failed)?; + if wrote { + self.checkpoint().await?; + } + return Ok((info, String::new())); + } let engine = self.engine()?; let (key, wrote) = engine.visor_route_key().await.map_err(engine_failed)?; if wrote { @@ -1094,6 +1114,20 @@ impl Kernel { Ok((info, route)) } + /// The fragment an installed app's window opens at (internal.wit + /// `apps.install-fragment`): `launch/`, plaintext and keyless + /// (`crate::route` module docs on the second kind). + pub fn install_fragment(&self, app: &str) -> Result { + self.open()?; + if self.registry.info(app).is_none() { + return Err(Error::new( + ErrorCode::UnknownApp, + format!("no app named {app} is installed"), + )); + } + Ok(route::launch_fragment(app)) + } + // -- app services -------------------------------------------------------- pub async fn tasks_revision(&self, session: SessionId) -> Result { diff --git a/runtime/crates/kernel/src/route.rs b/runtime/crates/kernel/src/route.rs index 2629756..8e3c06e 100644 --- a/runtime/crates/kernel/src/route.rs +++ b/runtime/crates/kernel/src/route.rs @@ -45,6 +45,14 @@ //! flipped bit, a foreign prefix and a truncated token are not told apart, //! because the answer to all four is the same: this device cannot open this //! link. +//! +//! **The second kind, `launch/`.** docs/design.md "Routing" explains +//! why an installed app's `start_url` cannot be an `app/` token: it is +//! written once into the OS's app registry and replayed for months, so it +//! must outlive route-key convergence, and it names a package the launcher +//! already displays, so there is nothing to hide. `launch/` is plaintext and +//! keyless — the app id verbatim, nothing sealed — and decodes to this +//! user's install of that package at route `""`. use aes_gcm::aead::{Aead, KeyInit, Payload}; use aes_gcm::{Aes256Gcm, Nonce}; @@ -160,6 +168,21 @@ pub fn decode(key: &[u8; 32], fragment: &str) -> Result<([u8; INSTALL_LEN], Stri Ok((install, route.to_string())) } +/// The second kind's prefix (module docs): plaintext, keyless, no token. +pub const LAUNCH_PREFIX: &str = "launch/"; + +/// The fragment an installed app's window opens at: `launch/`. +pub fn launch_fragment(app: &str) -> String { + format!("{LAUNCH_PREFIX}{app}") +} + +/// The app id a `launch/` fragment names, or `None` if `fragment` is not one +/// (wrong prefix, or an empty id — `launch/` alone names nothing). +pub fn launch_app(fragment: &str) -> Option<&str> { + let app = fragment.strip_prefix(LAUNCH_PREFIX)?; + if app.is_empty() { None } else { Some(app) } +} + /// The two subkeys, so the SIV computation and the encryption never share a /// key: `k_enc = HMAC(route-key, "polyvisor:route:enc")`, `k_siv` likewise. fn subkeys(key: &[u8; 32]) -> ([u8; 32], [u8; 32]) { @@ -284,4 +307,25 @@ mod tests { let long = encode(&KEY, INSTALL, &"r".repeat(MAX_ROUTE)).unwrap(); assert_eq!(short.len(), long.len()); } + + #[test] + fn launch_round_trips() { + let fragment = launch_fragment("todomvc"); + assert_eq!(fragment, "launch/todomvc"); + assert_eq!(launch_app(&fragment), Some("todomvc")); + } + + /// An `app/` token is not a launch, even though both share the `/` + /// separator — the kind prefixes must not be confused. + #[test] + fn app_fragment_is_not_a_launch() { + let fragment = encode(&KEY, INSTALL, "todo/active").unwrap(); + assert_eq!(launch_app(&fragment), None); + } + + #[test] + fn empty_launch_remainder_is_none() { + assert_eq!(launch_app("launch/"), None); + assert_eq!(launch_app("launch"), None); + } } diff --git a/runtime/wit/internal.wit b/runtime/wit/internal.wit index 6fce531..9151933 100644 --- a/runtime/wit/internal.wit +++ b/runtime/wit/internal.wit @@ -394,6 +394,15 @@ interface apps { /// user's device; `unknown-app` when it names an app no longer /// installed. route-decode: async func(fragment: string) -> result; + /// The fragment an installed app's window opens at: `launch/`, + /// the second kind (docs/design.md "Routing"). Plaintext and keyless + /// on purpose — a `start_url` is written once into the OS's app + /// registry and replayed for months, so it must outlive route-key + /// convergence; and it names a package the launcher already displays, + /// so there is nothing to hide. `route-decode` resolves it to this + /// user's install of the package, at route "". `unknown-app` if the + /// package is not installed. + install-fragment: async func(app: app-id) -> result; /// Start an instance. The glue binds a port to the returned session; /// the visor opens a frame for it through `shell.open-frame`, and the /// frame fetches `component`/`assets`/`asset` over that port, which @@ -511,6 +520,37 @@ interface shell { /// the ceremony's browser half lives here and the kernel never sees a /// window. open-popup: async func(url: string) -> option>; + + /// 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. + record install-request { + /// From `apps.install-fragment`: what the installed window opens at. + fragment: string, + title: string, + glyph: string, + hue: u16, + } + + /// How an install request ended on the page. + enum install-outcome { + /// The browser's install prompt was shown; whatever the user chose + /// there is the browser's business. + prompted, + /// The manifest is in place but the browser offered no prompt to + /// call: the user installs from the browser's own menu. + manual, + } + + /// Install `request`'s app as its own installed web app: the glue mints + /// a manifest for it — `id`, `start_url` and `scope` written absolute + /// 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. + install-app: async func(request: install-request) -> result; } // --------------------------------------------------------------------------- diff --git a/visor/src/kernel.rs b/visor/src/kernel.rs index 635c4fd..de91a1b 100644 --- a/visor/src/kernel.rs +++ b/visor/src/kernel.rs @@ -351,6 +351,42 @@ pub(crate) async fn route_decode(fragment: &str) -> Result<(App, String), String }) } +/// The fragment an installed app's window opens at (internal.wit +/// `apps.install-fragment`): `launch/`, plaintext and keyless +/// (docs/design.md "Routing", the `launch/` bullet — it must outlive +/// route-key convergence, and names a package the launcher already shows). +pub(crate) async fn install_fragment(app: &str) -> Result { + api::apps::install_fragment(app.to_string()) + .await + .map_err(message) +} + +/// How an install request ended on the page (internal.wit +/// `shell.install-outcome`). Re-exported rather than wrapped in a crate +/// enum: the two variants are already the whole shape the UI needs to +/// 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). +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 + .map_err(message) +} + pub(crate) async fn close_frame(session: SessionId) -> Result<(), String> { api::shell::close_frame(session).await.map_err(message) } diff --git a/visor/src/ui.rs b/visor/src/ui.rs index 27aa8de..05e1749 100644 --- a/visor/src/ui.rs +++ b/visor/src/ui.rs @@ -29,7 +29,8 @@ use dioxus::prelude::*; use crate::kernel::{ - self, App, Binding, Entry, Event, Member, Meta, MetaScope, Peer, SessionId, Status, + self, App, Binding, Entry, Event, InstallOutcome, Member, Meta, MetaScope, Peer, SessionId, + Status, }; use crate::state::{ Action, Drawer, Gate, Phase, Rest, Tenant, Tier, boot_drawer, claim_code, grouped, @@ -786,6 +787,28 @@ 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 { + let outcome = match kernel::install_fragment(&app.id).await { + Ok(fragment) => { + kernel::install_app(fragment, app.title.expose().to_string(), glyph, hue).await + } + Err(e) => Err(e), + }; + notice.set(Some(Notice::Plain(match outcome { + Ok(InstallOutcome::Prompted) => "the browser is asking whether to install it".into(), + Ok(InstallOutcome::Manual) => { + "install it from the browser's menu — the app's manifest is in place".into() + } + Err(e) => e, + }))); + }; + // "Other devices": the index is cheap and the ages on it go stale, so // the press that shows the sheet is also the read. let show_devices = use_callback(move |()| { @@ -956,7 +979,7 @@ pub(crate) fn Visor() -> Element { // the two this is: only the pane that is staying carries the ids, since // two elements with one id is a tree nobody can query. let sheet_for = move |t: Tenant, current: bool| -> Element { - let (self_id, tier, rest, petname, endpoint_id, word) = match status.read().as_ref() { + let (self_id, tier, rest, petname, endpoint_id, word, hue) = match status.read().as_ref() { Some(s) => ( s.id.clone(), s.tier, @@ -964,6 +987,7 @@ pub(crate) fn Visor() -> Element { s.petname.clone(), s.endpoint_id.clone(), s.word.clone(), + s.hue, ), None => ( String::new(), @@ -972,6 +996,7 @@ pub(crate) fn Visor() -> Element { String::new(), String::new(), String::new(), + 0, ), }; let live = session.read().as_ref().map(|(id, app)| (*id, app.clone())); @@ -1066,6 +1091,25 @@ pub(crate) fn Visor() -> Element { onclick: move |_| async move { close_session(id).await }, "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). + 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 } + } + }, + "Install as app" + } } } }, diff --git a/web/boot.ts b/web/boot.ts index 5de2dae..ec19f5e 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -41,6 +41,32 @@ import { proxyInterfaces } from "./rpc.ts"; const CEREMONY_CHANNEL = "polyvisor.oauth"; +// --------------------------------------------------------------------------- +// The browser's own install prompt (internal.wit `shell.install-app`) +// +// Chromium fires `beforeinstallprompt` once, early, and only if the page is +// still listening synchronously when it does — a handler added later (e.g. +// from inside `installApp`, once the user has actually asked to install) +// can simply miss it, and there is no way to ask the browser to fire it +// again. So it is captured here, at module top, before anything else runs +// (including the returning-popup and framed-window early-outs above this +// comment, which is fine: those windows never call `installApp` and the +// listener is inert if they park or refuse). `preventDefault` defers the +// browser's own mini-infobar so `installApp` decides when to call +// `.prompt()` instead of the browser deciding on its own schedule. +// --------------------------------------------------------------------------- + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; +} + +let deferredInstall: BeforeInstallPromptEvent | undefined; + +addEventListener("beforeinstallprompt", (e: Event) => { + e.preventDefault(); + deferredInstall = e as BeforeInstallPromptEvent; +}); + const returned = popupReturn(location.search); if (returned !== undefined) { const channel = new BroadcastChannel(CEREMONY_CHANNEL); @@ -601,6 +627,134 @@ function closeFrame(session: number): void { clearFragment(session); } +// --------------------------------------------------------------------------- +// Installing a launch (internal.wit `shell.install-app`, docs/design.md +// "Routing", the `launch/` bullet) +// --------------------------------------------------------------------------- + +interface InstallRequest { + fragment: string; + title: string; + glyph: string; + hue: number; +} + +/** The blob URL our own `` currently points at, so a + * later install can revoke it. Never revokes a URL this glue did not mint — + * 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"> { + // Absolute against the page's own base — never `/`, which on a GitHub + // Pages project site names somebody else's page (docs/design.md + // "Routing"). `base` is the directory this page is served from, the same + // one the OAuth return and the worker's `homeOrigin` use. `id` is derived + // from the fragment alone (not `start_url`, which a future kind's fragment + // grammar might vary in ways that should not mint a new installed app for + // the same package) so the same package on the same origin is the same + // installed app on every device, per the design's `launch/` bullet. + const base = new URL(".", location.href); + const startUrl = new URL("#" + request.fragment, base).href; + 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), + ]); + const themeColor = `oklch(0.62 0.14 ${request.hue})`; + + const manifest = { + name: `${request.title} — polyvisor`, + short_name: request.title, + display: "standalone", + start_url: startUrl, + scope, + id, + icons: [ + { src: icon512, sizes: "512x512", type: "image/png" }, + { src: icon192, sizes: "192x192", type: "image/png" }, + ], + theme_color: themeColor, + background_color: "#ffffff", + }; + + const manifestUrl = URL.createObjectURL( + new Blob([JSON.stringify(manifest)], { type: "application/manifest+json" }), + ); + + let link = document.querySelector("link[rel=manifest]"); + if (link === null) { + link = document.createElement("link"); + link.rel = "manifest"; + document.head.append(link); + } + // Revoke only a URL this glue minted — a previous install's blob, not + // whatever (nothing, today) `index.html` shipped the link pointing at. + if (manifestBlobUrl !== undefined) URL.revokeObjectURL(manifestBlobUrl); + manifestBlobUrl = manifestUrl; + // A prompt the browser offered earlier was offered for the manifest that + // was in place then — another app's, if this is the second install of the + // visit — so it goes with that manifest, and the one that counts is + // whatever the browser offers for this one. Chromium re-evaluates + // installability when the link changes and fires a fresh + // `beforeinstallprompt`; a bounded wait catches it, and none in time means + // the browser is not offering one (already installed, or no such event), + // which is `manual`. + deferredInstall = undefined; + link.href = manifestUrl; + const offered = await new Promise( + (resolve) => { + const poll = setInterval(() => { + if (deferredInstall === undefined) return; + clearInterval(poll); + resolve(deferredInstall); + }, 50); + setTimeout(() => { + clearInterval(poll); + resolve(undefined); + }, 2_000); + }, + ); + if (offered === undefined) return "manual"; + deferredInstall = undefined; + await offered.prompt(); + return "prompted"; +} + // --------------------------------------------------------------------------- // Bring-up // --------------------------------------------------------------------------- @@ -754,6 +908,17 @@ async function main(): Promise { // it. globalThis.open(url, "_blank", "popup,noopener,width=520,height=640"); }), + // 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`. + installApp: (request: InstallRequest) => + installApp(request).catch((err: unknown) => { + throw new ComponentException({ + code: "failed", + message: String((err as Error)?.message ?? err), + }); + }), }; // A fatal that arrived while the artifacts were being fetched: the worker diff --git a/web/worker.ts b/web/worker.ts index 718a22b..aee6d9b 100644 --- a/web/worker.ts +++ b/web/worker.ts @@ -472,6 +472,13 @@ self.onconnect = (ev: MessageEvent) => { (await ready)[I.apps].routeEncode(s, route), routeDecode: async (fragment: string) => (await ready)[I.apps].routeDecode(fragment), + // Control port only, same reasoning: `install-fragment` is asked by + // the visor's own AppInfo sheet to compose a manifest's `start_url` + // (internal.wit `apps.install-fragment`, `shell.install-app`) — a + // session port has no business minting the fragment a launcher would + // open it at. + installFragment: async (app: string) => + (await ready)[I.apps].installFragment(app), sessionApp: async (s: number) => (await ready)[I.apps].sessionApp(s), component: async (s: number) => (await ready)[I.apps].component(s), assets: async (s: number) => (await ready)[I.apps].assets(s),