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
26 changes: 26 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app-id>`, 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/<package>`: 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.
Expand Down
115 changes: 115 additions & 0 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLLinkElement>("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) {
Expand Down
3 changes: 3 additions & 0 deletions runtime/component/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,9 @@ impl guest::apps::Guest for Component {
route,
})
}
async fn install_fragment(app: String) -> Result<String, Error> {
kernel()?.install_fragment(&app).map_err(map_error)
}
async fn close(session: u32) -> Result<(), Error> {
kernel()?.close(session);
Ok(())
Expand Down
34 changes: 34 additions & 0 deletions runtime/crates/kernel/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -1094,6 +1114,20 @@ impl Kernel {
Ok((info, route))
}

/// The fragment an installed app's window opens at (internal.wit
/// `apps.install-fragment`): `launch/<app>`, plaintext and keyless
/// (`crate::route` module docs on the second kind).
pub fn install_fragment(&self, app: &str) -> Result<String, Error> {
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<u64, String> {
Expand Down
44 changes: 44 additions & 0 deletions runtime/crates/kernel/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app-id>`.** 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};
Expand Down Expand Up @@ -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/<app>`.
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]) {
Expand Down Expand Up @@ -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);
}
}
40 changes: 40 additions & 0 deletions runtime/wit/internal.wit
Original file line number Diff line number Diff line change
Expand Up @@ -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<route-target, error>;
/// The fragment an installed app's window opens at: `launch/<app-id>`,
/// 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<string, error>;
/// 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
Expand Down Expand Up @@ -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<tuple<string, string>>;

/// 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<install-outcome, error>;
}

// ---------------------------------------------------------------------------
Expand Down
36 changes: 36 additions & 0 deletions visor/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app-id>`, 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<String, String> {
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<InstallOutcome, String> {
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)
}
Expand Down
Loading
Loading