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
14 changes: 14 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
81 changes: 73 additions & 8 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ async function visorReady(page: Page): Promise<void> {
/** 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<Page> {
const page = await ctx.newPage();
page.on("pageerror", (e) => console.error(" page error:", e.message));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down
55 changes: 50 additions & 5 deletions web/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -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<string, unknown>;
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<ComponentArtifacts>;
abort(session: number, reason: string): Promise<void>;
Expand Down Expand Up @@ -624,10 +661,18 @@ async function main(): Promise<void> {
// 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
Expand Down
Loading