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
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"runtime/crates/document-history",
"runtime/crates/engine",
"runtime/crates/kernel",
"runtime/crates/petname",
"runtime/crates/todo-model",
"runtime/crates/visor-model",
"visor",
Expand All @@ -20,6 +21,7 @@ license = "Apache-2.0"
publish = false

[workspace.dependencies]
polyvisor-petname = { path = "runtime/crates/petname" }
# docs/design.md "Devices": sealing is pure Rust in the kernel with the key
# as bytes. `aes-gcm` is the checkpoint/DEK-wrap AEAD, `argon2` the Argon2id
# KEK derivation behind a passphrase. Both with default features off: the
Expand Down
15 changes: 10 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ the handshake.

- **The index** (`store`) is the one unsealed record: id, local picker
petname, tier, how the device rests, timestamps. Never the member label,
shared hue/word/metadata or any key.
shared hue/metadata or any key.
The visor boots unclaimed (grey, no identity) and paints the user's
colour only after the seal opens, so a page imitating the picker cannot
paint it.
Expand All @@ -415,8 +415,8 @@ the handshake.
generation. The kernel never lists a directory: `read-directory` is one
of four sync functions left on the 0.3 track and its OPFS host answers
with a Promise (JSPI), so every path is named from the pointer and
removal reaches n+1 down to n-2 by name. A founder draws the anchor
(hue, word) from the RNG and writes it to the sealed visor document before
removal reaches n+1 down to n-2 by name. A founder draws the hue and a
random user petname from the RNG and writes them to the sealed visor document before
exposure; later devices adopt that document during pairing. The kernel's
device record only caches these fields in memory.
- **Switching devices is a reload** (`shell.switch-device`): the anchor
Expand All @@ -433,7 +433,7 @@ the handshake.

Both are Dioxus producers via `stream-dom-dioxus`. The visor runs with
no vocabulary policy (it is trusted); apps run under the frame policy
above. The visor is close to stateless: identity, hue, anchor word,
above. The visor is close to stateless: identity, hue,
trust table and boot cache are kernel state served over `device`/`apps`,
so the visor has no persistence import of its own and the same component
runs under a native shell.
Expand Down Expand Up @@ -484,7 +484,12 @@ interprets.
key by automerge's last-writer-wins, and the loser's pre-pairing
bookmarks stop opening — stated, not fixed.
The same sealed visor document is the authority for the shared hue,
recognition word, user labels and per-app labels. They are root scalar
user labels and per-app labels. User and device petnames are generated at
founding/device creation; an app petname is generated before its first
launch. Explicitly saving an empty petname generates and persists a
replacement. Draft re-rolls happen synchronously in the visor from the same
EFF generator using its own `wasi:random` import; no naming policy crosses
the internal API. They are root scalar
keys in collision-safe namespaces (`identity:`, `user:`, `app:`), so edits
to different fields do not replace a nested map. Device labels remain
member records keyed by endpoint public key in plaintext `us`; the local
Expand Down
153 changes: 137 additions & 16 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// actually serves.

import { chromium } from "playwright";
import type { Browser, BrowserContext, Page } from "playwright";
import type { Browser, BrowserContext, Locator, Page } from "playwright";
import { contentType } from "@std/media-types";
import { copy } from "@std/fs";
import { extname, join, normalize } from "@std/path";
Expand Down Expand Up @@ -472,6 +472,16 @@ async function setTextDraft(page: Page, label: RegExp, value: string): Promise<v
}
}

async function waitForInputToDiffer(input: Locator, previous: string): Promise<string> {
const deadline = performance.now() + 10_000;
while (performance.now() < deadline) {
const value = await input.inputValue();
if (value !== previous) return value;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Failure(`input stayed ${JSON.stringify(previous)}`);
}

/** Press "Install as app" and read back the manifest.
*
* Waits for the link's href to CHANGE: a second install starts with the
Expand Down Expand Up @@ -1000,7 +1010,7 @@ async function waitForMember(page: Page, peer: string): Promise<void> {
page,
`${peer} in the group`,
async () =>
await devicesSheet(page).locator(".member-row").filter({ hasText: peer })
await devicesSheet(page).locator(`.member-row[data-endpoint-id="${peer}"]`)
.count() > 0,
{ refresh: true },
);
Expand Down Expand Up @@ -1286,17 +1296,15 @@ async function converge(
const scenarios: Scenario[] = [
{
name: "boot",
async run(ctx, origin) {
async run(ctx, origin, browser) {
const page = await open(ctx, origin);
await visorReady(page);
const box = await page.locator("#visor-strip").boundingBox();
check(box !== null && box.height > 0, "#visor-strip has no visible box");
// The strip says "waking" until `device.status` answers over the
// worker port; the placeholder is the first kernel-backed pixel, and
// it is the right half — the one that speaks for this device.
await page.locator("#visor-self").getByText("this device").waitFor({
timeout: 10_000,
});
await page.locator("#visor-self .bottom .user").waitFor({ timeout: 10_000 });
},
},

Expand Down Expand Up @@ -2089,7 +2097,7 @@ const scenarios: Scenario[] = [
{ timeout: 15_000 },
);
await visorReady(page);
await strip(page).getByText("this device").waitFor({ timeout: 15_000 });
await strip(page).locator("#visor-self .bottom .user").waitFor({ timeout: 15_000 });

await openSettingsSheet(page);
await drawer(page).getByRole("button", { name: "Other devices" }).click();
Expand Down Expand Up @@ -2187,6 +2195,115 @@ const scenarios: Scenario[] = [
},
},

{
name: "petname-dice",
async run(ctx, origin, browser) {
const page = await open(ctx, origin);
await visorReady(page);
await openSettingsSheet(page);

const field = (label: RegExp) =>
drawer(page).locator("label").filter({ hasText: label }).locator("input");
const user = field(/^your petname$/);
const device = field(/^device petname$/);
const userDie = drawer(page).getByRole("button", { name: "Re-roll user petname" });
const deviceDie = drawer(page).getByRole("button", { name: "Re-roll device petname" });

const assertDieGap = async (input: Locator, die: Locator, where: string) => {
const inputBox = await input.boundingBox();
const dieBox = await die.boundingBox();
check(inputBox !== null && dieBox !== null, `${where} petname controls are not visible`);
const gap = dieBox.x - (inputBox.x + inputBox.width);
check(gap >= 0 && gap <= 12, `${where} die gap is ${gap}px, expected 0..12px`);
console.log(` ${where} die gap: ${gap}px`);
return gap;
};
await assertDieGap(user, userDie, "desktop user");
await assertDieGap(device, deviceDie, "desktop device");

for (const [input, die] of [[user, userDie], [device, deviceDie]] as const) {
const initial = await input.inputValue();
eq(await die.getAttribute("aria-disabled"), "true", "saved petname die was enabled");
await die.hover();
let tip = drawer(page).getByRole("tooltip", { name: "clear to re-roll" });
await tip.waitFor();
eq(await die.getAttribute("aria-describedby"), await tip.getAttribute("id"), "tooltip association missing");
await page.mouse.move(0, 0);
await tip.waitFor({ state: "detached" });
await die.focus();
await tip.waitFor();
await die.press("Escape");
await tip.waitFor({ state: "detached" });
await die.click({ force: true });
eq(await input.inputValue(), initial, "disabled die changed a petname");
await input.fill("");
await die.click();
const first = await waitForInputToDiffer(input, "");
await die.click();
await waitForInputToDiffer(input, first);
}
await shot(page, "desktop-petname-roll");
await saveDraft(page);
await appsButton(page).click();
await paneSettled(page);
await openSettingsSheet(page);
eq(await drawer(page).getByRole("button", { name: "Re-roll user petname" }).getAttribute("aria-disabled"), null, "saved roll lost eligibility on reopen");

await launchTodoMvc(page);
await toAppSheet(page);
const app = field(/^petname$/);
const appDie = drawer(page).getByRole("button", { name: "Re-roll app petname" });
eq(await appDie.getAttribute("aria-disabled"), "true", "generated app default was rerollable");
await app.fill("");
await appDie.click();
await page.waitForFunction(() => {
const label = [...document.querySelectorAll("label")].find((node) => node.textContent?.trim() === "petname");
return (label?.querySelector("input") as HTMLInputElement | null)?.value.length;
});
await drawer(page).getByRole("button", { name: "Revert", exact: true }).click();
await pageCleanDrawer(page);
await page.reload();
await visorReady(page);
await page.waitForSelector("#app-zone iframe[sandbox]", { timeout: 30_000 });
await page.waitForFunction(() => document.querySelector("#visor-drawer") === null);
await openSettingsSheet(page);
eq(await drawer(page).getByRole("button", { name: "Re-roll user petname" }).getAttribute("aria-disabled"), "true", "reload retained roll eligibility");
check(await drawer(page).getByText("word", { exact: true }).count() === 0, "obsolete word field is still rendered");

const reloadedUser = field(/^your petname$/);
const reloadedDevice = field(/^device petname$/);
await reloadedUser.fill("");
await reloadedDevice.fill("");
await saveDraft(page);
check((await reloadedUser.inputValue()).length > 0, "blank user save was not normalized");
check((await reloadedDevice.inputValue()).length > 0, "blank device save was not normalized");

await toAppSheet(page);
const savedApp = field(/^petname$/);
await savedApp.fill("");
await saveDraft(page);
check((await savedApp.inputValue()).length > 0, "blank app save was not normalized");

await page.setViewportSize({ width: 390, height: 844 });
await assertDieGap(field(/^petname$/), drawer(page).getByRole("button", { name: "Re-roll app petname" }), "mobile app");
await shot(page, "mobile-petname-roll");
const touch = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true });
try {
const mobile = await open(touch, origin);
await visorReady(mobile);
await openSettingsSheet(mobile);
const mobileUser = drawer(mobile).locator("label").filter({ hasText: /^your petname$/ }).locator("input");
const mobileDie = drawer(mobile).getByRole("button", { name: "Re-roll user petname" });
const before = await mobileUser.inputValue();
await mobileDie.tap({ force: true });
eq(await mobileUser.inputValue(), before, "disabled touch die changed the draft");
await drawer(mobile).getByRole("tooltip", { name: "clear to re-roll" }).waitFor();
} finally {
await touch.close();
}
},
},

{
name: "live-personalization-and-device-names",
async run(ctx, origin, browser) {
Expand Down Expand Up @@ -2219,10 +2336,19 @@ const scenarios: Scenario[] = [

// Keep one device-local field dirty while clean shared fields update.
await openSettingsSheet(b);
const remoteUser = drawer(b).locator("label").filter({ hasText: /^your petname$/ }).locator("input");
const remoteDie = drawer(b).getByRole("button", { name: "Re-roll user petname" });
await remoteUser.fill("");
await remoteDie.click();
await waitForInputToDiffer(remoteUser, "");
await saveDraft(b);
eq(await remoteDie.getAttribute("aria-disabled"), null, "saved roll lost eligibility");
const dirtyName = drawer(b).locator("label").filter({ hasText: /^device petname$/ }).locator("input");
await dirtyName.fill("unsaved beta");
await setUserPetname(a, "post-pair owner");
await waitForPageText(b, "post-pair owner");
eq(await remoteUser.inputValue(), "post-pair owner", "remote petname did not replace the clean draft");
eq(await remoteDie.getAttribute("aria-disabled"), "true", "remote replacement inherited roll eligibility");
eq(await dirtyName.inputValue(), "unsaved beta", "remote refresh replaced a dirty field");
await drawer(b).getByRole("button", { name: "Revert", exact: true }).click();
eq(await dirtyName.inputValue(), "beta device", "Revert did not use the latest synced baseline");
Expand All @@ -2239,12 +2365,6 @@ const scenarios: Scenario[] = [
await drawer(a).locator('input[type="range"]').fill("123");
await saveDraft(a);
await b.waitForFunction(() => document.querySelector("#visor-root")?.getAttribute("style")?.includes("123"), undefined, { timeout: 30_000 });
const oldWord = (await drawer(b).locator("label").filter({ hasText: /^word/ }).textContent()) ?? "";
await drawer(a).locator("button").filter({ hasText: /^Reroll$/ }).click();
await b.waitForFunction((old) => {
const label = [...document.querySelectorAll("label")].find((node) => node.textContent?.trim().startsWith("word"));
return label?.textContent !== old;
}, oldWord, { timeout: 30_000 });
await setAppPetname(a, "post-pair todos");
await waitForPageText(b, "post-pair todos");

Expand Down Expand Up @@ -2602,6 +2722,7 @@ const scenarios: Scenario[] = [
const field = drawer(page).locator("label").filter({
hasText: /^device petname$/,
}).locator("input");
const original = await field.inputValue();
const confirm = page.locator("#visor-confirm");

eq(
Expand All @@ -2619,7 +2740,7 @@ const scenarios: Scenario[] = [

await drawer(page).getByRole("button", { name: "Revert", exact: true })
.click();
eq(await field.inputValue(), "", "bar Revert kept the abandoned draft");
eq(await field.inputValue(), original, "bar Revert kept the abandoned draft");
check(
await focusIn(page, "#visor-actions") &&
await drawer(page).getByRole("button", { name: "Close", exact: true })
Expand All @@ -2642,7 +2763,7 @@ const scenarios: Scenario[] = [

// Revert means "throw it away and go": the transition happens, and
// the sheet goes back to what the kernel last said — which for a
// device nobody has named is nothing.
// device is its generated name.
await appsButton(page).click();
await confirm.waitFor({ timeout: 10_000 });
await confirm.getByRole("button", { name: "Revert", exact: true })
Expand All @@ -2653,7 +2774,7 @@ const scenarios: Scenario[] = [
"Revert did not go on to the transition it was asked about",
);
await openSettingsSheet(page);
eq(await field.inputValue(), "", "Revert kept the abandoned draft");
eq(await field.inputValue(), original, "Revert kept the abandoned draft");

// Saved, and it is the kernel that remembers it: a reload has no
// draft at all, and reads the name back off the device.
Expand Down
6 changes: 1 addition & 5 deletions runtime/component/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,6 @@ impl guest::device::Guest for Component {
petname: status.petname,
name: status.name,
hue: status.hue,
word: status.word,
endpoint_id: status.endpoint_id,
})
}
Expand All @@ -517,9 +516,6 @@ impl guest::device::Guest for Component {
async fn set_hue(hue: u16) -> Result<(), Error> {
kernel()?.set_hue(hue).await.map_err(map_error)
}
async fn reroll_word() -> Result<String, Error> {
kernel()?.reroll_word().await.map_err(map_error)
}
async fn meta(scope: guest::device::MetaScope) -> Result<Vec<(String, String)>, Error> {
let scope = match scope {
guest::device::MetaScope::User => polyvisor_kernel::MetaScope::User,
Expand Down Expand Up @@ -680,7 +676,7 @@ impl guest::apps::Guest for Component {
.collect())
}
async fn launch(app: String) -> Result<u32, Error> {
kernel()?.launch(&app).map_err(map_error)
kernel()?.launch(&app).await.map_err(map_error)
}
async fn component(session: u32) -> Result<guest::apps::ComponentArtifacts, Error> {
let artifacts = kernel()?.component(session).await.map_err(map_error)?;
Expand Down
Loading
Loading