From 2d0b929cf81f2ec32b09b57afd31d5961b923738 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 22:44:43 -0400 Subject: [PATCH] Devices: a tab with no anchor adopts the last kept device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anchor is per tab, so a typed URL, a bookmark or an installed app's window minted a fresh device every time — "new tab, new stranger" — and a user's own bookmark opened in a new tab of the same profile was refused, because the fresh device held no route key. The glue now keeps one more pointer in localStorage: the last device `device.status` reported as durable. A tab with no anchor adopts it; only a profile with no kept device, or an explicit `switch-device(none)`, mints. e2e: bookmark-in-a-new-tab; two-devices-entry distinguishes a lost anchor (re-adopts) from an explicit new device (picker). --- docs/design.md | 14 +++++++++ e2e/run.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++----- web/boot.ts | 55 ++++++++++++++++++++++++++++++---- 3 files changed, 137 insertions(+), 13 deletions(-) diff --git a/docs/design.md b/docs/design.md index d28936c..5f38d47 100644 --- a/docs/design.md +++ b/docs/design.md @@ -400,6 +400,20 @@ the handshake. - **Switching devices is a reload** (`shell.switch-device`): the anchor changes and the page restarts against another worker. Erase destroys the namespace and the index row, then switches to a fresh device. +- **A tab with no anchor adopts the last kept device.** The anchor is + per tab, and a fresh tab — a typed URL, a bookmark, an installed app's + window — has none; minting a new device there made "new tab, new + stranger" the default, and which tabs shared a device turned on how + the tab had been opened (Chromium copies `sessionStorage` on duplicate + and `window.open`, on nothing else). So the glue keeps one more + pointer, in `localStorage`: the id of the last device this profile saw + as *durable*, written whenever `device.status` reports that tier and + never for an ephemeral (they are swept). A tab with no anchor takes it; + only a profile with no kept device — or an explicit + `switch-device(none)`, which clears the pointer too — mints. The + pointer names a worker and grants nothing: a passphrase device still + opens sealed, and a rests-open one was already the profile's. Several + devices in one profile remain possible; they stop being an accident. ## Visor and apps render through stream-dom diff --git a/e2e/run.ts b/e2e/run.ts index c11f8e1..e912cbc 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -207,6 +207,9 @@ async function visorReady(page: Page): Promise { /** The tab's device anchor, as `web/boot.ts` spells it. */ const ANCHOR = "polyvisor.device"; +/** The profile's last kept device, as `web/boot.ts` spells it. */ +const LAST = "polyvisor.last-device"; + async function open(ctx: BrowserContext, origin: string): Promise { const page = await ctx.newPage(); page.on("pageerror", (e) => console.error(" page error:", e.message)); @@ -1160,6 +1163,44 @@ const scenarios: Scenario[] = [ }, }, + { + // The case the per-tab anchor used to break (docs/design.md "Devices", + // last bullet): a bookmark opened in a genuinely fresh tab — no + // sessionStorage anchor, because Chromium only copies that on + // duplicate/`window.open`, not on a plain navigation to a URL typed or + // clicked elsewhere. Before LAST, this minted a brand-new device with + // no route key, so the token could never decrypt. With LAST, the fresh + // tab adopts the profile's kept device and the bookmark just opens. + name: "bookmark-in-a-new-tab", + async run(ctx, origin) { + const page = await open(ctx, origin); + await visorReady(page); + await keepDevice(page, "the workbench"); + await launchTodoMvc(page); + // `route.set` is debounced (docs/design.md "Routing"), so the + // fragment is not necessarily there the instant the frame opens. + await page.waitForFunction(() => location.hash !== "", undefined, { + timeout: 10_000, + }); + const h = await page.evaluate(() => location.hash); + + // `ctx.newPage()`, not a second window off `page`: a genuinely new + // tab shares the profile's localStorage but starts with an empty + // sessionStorage of its own — nothing here copies the anchor. + const tab = await ctx.newPage(); + await tab.goto(origin + "/" + h); + await visorReady(tab); + await tab.waitForSelector("#app-zone iframe[sandbox]", { + timeout: 30_000, + }); + eq( + await tab.evaluate(() => location.hash), + h, + "the adopted-device tab did not open at the bookmarked fragment", + ); + }, + }, + { name: "frame-violation-ends-session", async run(ctx, origin) { @@ -1313,11 +1354,29 @@ const scenarios: Scenario[] = [ await setDeviceName(page, "the workbench"); await keepDevice(page, "laptop"); - // Dropping the anchor is exactly what `shell.switch-device(none)` - // does; doing it here rather than through a button keeps this - // scenario to what the WIT guarantees, so it does not depend on which - // control the visor happens to offer for a second device. + // Dropping only the tab's own anchor is a lost anchor, not a new + // device: `device.status` wrote LAST when this device was kept above + // (docs/design.md "Devices", last bullet), and it is still there. + // `deviceId` adopts it, so this reload is the SAME device and the + // picker never appears at all. + await page.evaluate((key) => sessionStorage.removeItem(key), ANCHOR); + await page.reload(); + await visorReady(page); + await strip(page).getByText("the workbench").waitFor({ timeout: 15_000 }); + eq( + await drawer(page).locator(".device-row").count(), + 0, + "a lost anchor with a kept LAST device showed the picker anyway", + ); + + // Now drop LAST too: an explicit new device, per the design's "only a + // profile with no kept device ... mints". This is exactly what + // `shell.switch-device(none)` does; doing it here rather than through + // a button keeps this scenario to what the WIT guarantees, so it does + // not depend on which control the visor happens to offer for a + // second device. await page.evaluate((key) => sessionStorage.removeItem(key), ANCHOR); + await page.evaluate((key) => localStorage.removeItem(key), LAST); await page.reload(); await visorReady(page); @@ -1619,10 +1678,16 @@ const scenarios: Scenario[] = [ // — the OPFS and the sealed tokens are the context's. // // NAVIGATED, not closed, and this is the whole reason: the device - // anchor is `sessionStorage` (web/boot.ts), which is per TAB. A - // second tab in the same context is a second DEVICE — no group, no - // tokens, nothing of B's — so closing this one would not put B to - // sleep, it would replace it. + // anchor is `sessionStorage` (web/boot.ts), which is per TAB — but + // a second tab is no longer reliably a second device (docs/design.md + // "Devices", last bullet): B here is ephemeral, so its LAST pointer + // was never written and a second tab would still mint fresh, but a + // KEPT device's second tab would instead ADOPT it — same group, + // same tokens, B's own worker still up. Closing this tab is + // therefore not a dependable way to take B offline for either case; + // navigating it away is, because it is the same client dropping + // its hold on B's SharedWorker (a worker lives while a client holds + // it) regardless of what any other tab would resolve to. await b.goto("about:blank"); // Written while B could not be listening, so the store is the only diff --git a/web/boot.ts b/web/boot.ts index 1371047..5de2dae 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -175,11 +175,26 @@ function fatal(message: string): void { * `shell.switch-device` is a reload of one of them. */ const ANCHOR = "polyvisor.device"; -/** The anchored device id, minting and anchoring a fresh one if this tab has - * none. 16 random bytes as hex: the id is only ever an opaque name. */ +/** The last device this profile saw promoted to durable (docs/design.md + * "Devices", last bullet). localStorage, not sessionStorage: it is shared by + * every tab of the origin, which is the point — a fresh tab with no anchor + * of its own adopts it instead of minting "new tab, new stranger". Written + * from `device.status`, read here, cleared only by an explicit + * `switch-device(none)`. */ +const LAST = "polyvisor.last-device"; + +/** The anchored device id: this tab's own anchor if it has one, else the + * profile's last kept device (adopted and anchored here so the rest of this + * tab's life reads the same sessionStorage path), else a fresh mint. 16 + * random bytes as hex: the id is only ever an opaque name. */ function deviceId(): string { const anchored = sessionStorage.getItem(ANCHOR); if (anchored !== null && anchored !== "") return anchored; + const last = localStorage.getItem(LAST); + if (last !== null && last !== "") { + sessionStorage.setItem(ANCHOR, last); + return last; + } const bytes = crypto.getRandomValues(new Uint8Array(16)); const fresh = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")) .join(""); @@ -312,6 +327,28 @@ const kernel = proxyInterfaces(control, [ I.storage, I.events, ]); + +// The other end of the LAST pointer: `device.status`'s `tier` is the kernel's +// only word on whether this device has been kept (runtime/wit internal.wit +// "Lifecycle": `enum tier { ephemeral, durable }`, and on the wire an enum is +// its kebab-case case name as a plain string — bindgen's codegen.rs, "enum = +// string literal union of kebab-case case names"). `ephemeral` devices are +// swept, so only `durable` is worth remembering across tabs; every other +// `device` method passes straight through the proxy underneath. +const rawDevice = kernel[I.device] as Record; +kernel[I.device] = new Proxy(rawDevice, { + get(target, key, receiver) { + if (key !== "status") return Reflect.get(target, key, receiver); + return async (...args: unknown[]) => { + const result = await (target.status as (...a: unknown[]) => Promise< + { tier?: string } + >)(...args); + if (result?.tier === "durable") localStorage.setItem(LAST, device); + return result; + }; + }, +}); + const apps = kernel[I.apps] as { component(session: number): Promise; abort(session: number, reason: string): Promise; @@ -624,10 +661,18 @@ async function main(): Promise { // Also sync. Re-anchoring is all this does — the worker is named after // the anchor, so the reload is what actually moves the tab to the other // device (docs/design.md "Devices": "Switching devices is a reload"). - // `none` drops the anchor, and the next boot mints a fresh id. + // `none` drops the anchor AND the LAST pointer (an explicit new device, + // per the design's "or an explicit switch-device(none), which clears the + // pointer too") so the next boot mints a fresh id rather than adopting + // the one just left; switching TO a named target leaves LAST alone — + // `device.status` after the reload rewrites it if that target is + // durable, and if it isn't, LAST still names whatever this profile's + // last durable device was. switchDevice: (target: string | undefined) => { - if (target === undefined) sessionStorage.removeItem(ANCHOR); - else sessionStorage.setItem(ANCHOR, target); + if (target === undefined) { + sessionStorage.removeItem(ANCHOR); + localStorage.removeItem(LAST); + } else sessionStorage.setItem(ANCHOR, target); location.reload(); }, // A window capability, asked at the moment a device is kept. `false` is