diff --git a/e2e/run.ts b/e2e/run.ts index 6f158dd8..58dfecaa 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -221,20 +221,56 @@ async function open(ctx: BrowserContext, origin: string): Promise { // The visor, as these scenarios drive it // // Every selector and every label below is the visor's own tree (visor/src/ -// ui.rs): `#visor-strip` with `.unclaimed` until the device is open, -// `#visor-circle` carrying the hue and nothing else ever painting it, -// `#visor-drawer` holding one tenant at a time, `.sheet` / `.sheet-error` / -// `.app-row` / `.device-row`. They live in one block so a visor rename is one -// edit here rather than six. +// ui.rs): `#visor-root` carrying `.unclaimed` until the device is open and +// the `--hue` inline style when it is (nothing else ever paints it), +// `#visor-strip` with its two halves `#visor-app` and `#visor-self`, +// `#visor-drawer` holding one `.pane` per tenant, `.sheet` / `.sheet-error` +// / `.app-row` / `.device-row`. They live in one block so a visor rename is +// one edit here rather than six. +// +// The drawer is no longer a toggle: with nothing running it is pinned open +// on the app list, so "close" means "back to the app list" and pressing the +// half whose sheet is already showing does nothing at all. // --------------------------------------------------------------------------- const strip = (page: Page) => page.locator("#visor-strip"); const drawer = (page: Page) => page.locator("#visor-drawer"); +/** The strip's left half: what is running (the app list, or the running + * app's own sheet). */ +const appsButton = (page: Page) => page.locator("#visor-app"); +/** The strip's right half: who this is, and this device's settings. */ +const settingsButton = (page: Page) => page.locator("#visor-self"); -/** Press one of the strip's tenant buttons and wait for the drawer. */ -async function openTenant(page: Page, label: string): Promise { - await strip(page).getByRole("button", { name: label, exact: true }).click(); +/** Wait for the drawer to hold exactly one pane, done sliding. + * + * A tenant switch renders two panes for the length of the slide — the one + * arriving still wearing an `enter-` class — and a click into a moving + * target lands wherever the animation had got to. */ +async function paneSettled(page: Page): Promise { await drawer(page).waitFor({ timeout: 10_000 }); + await page.waitForFunction( + () => { + const panes = document.querySelectorAll("#visor-drawer .pane"); + return panes.length === 1 && + !(panes[0] as HTMLElement).className.includes("enter"); + }, + undefined, + { timeout: 10_000 }, + ); +} + +/** Press the strip's left half and wait for the pane it raises. With + * nothing running that is the app list; with a session it is that session's + * own sheet. */ +async function openApps(page: Page): Promise { + await appsButton(page).click(); + await paneSettled(page); +} + +/** Press the strip's right half: the settings sheet. */ +async function openSettingsSheet(page: Page): Promise { + await settingsButton(page).click(); + await paneSettled(page); } /** The `.sheet` whose head says `head` — the drawer stacks several. */ @@ -248,14 +284,16 @@ function sheet(page: Page, head: string | RegExp) { * `awaitFrame` is false for an app that is expected to be refused: the * hostile fixture's frame can be torn down before a `waitForSelector` on it * ever polls, and "the frame existed for a moment" is not part of any claim - * — the claim is what the strip says afterwards. + * — the claim is what the visor says afterwards. */ async function launchApp( page: Page, title: string, awaitFrame = true, ): Promise { - await openTenant(page, "Apps"); + // With nothing running the app list is already what the drawer is + // showing, so this press is usually a no-op — which is the point. + await openApps(page); const row = drawer(page).locator(".app-row").filter({ hasText: title }) .first(); await row.waitFor({ timeout: 10_000 }); @@ -271,14 +309,39 @@ async function launchTodoMvc(page: Page): Promise { await launchApp(page, "TodoMVC"); } -/** Settings → the device's user-voice name. */ +/** + * Press `Save` in the settings sheet and wait for the draft to be clean. + * + * The button disables itself exactly when `draft == seed`, and the seed + * only catches up once every kernel call the save made has come back + * (visor/src/ui.rs `save_draft`) — so this is the one observable that says + * the device, and not merely the screen, has the new value. Reloading + * without it races the checkpoint. + */ +async function saveDraft(page: Page): Promise { + await drawer(page).getByRole("button", { name: "Save", exact: true }) + .click(); + await page.waitForFunction( + () => { + const save = Array.from( + document.querySelectorAll("#visor-drawer button"), + ).find((b) => b.textContent === "Save") as HTMLButtonElement | undefined; + return save !== undefined && save.disabled; + }, + undefined, + { timeout: 15_000 }, + ); +} + +/** Settings → the device's user-voice petname. Typed into the draft, and + * `Save` is the only thing the kernel hears. */ async function setDeviceName(page: Page, name: string): Promise { - await openTenant(page, "Settings"); - const field = drawer(page).locator("label").filter({ hasText: /^name$/ }) - .locator("input"); + await openSettingsSheet(page); + const field = drawer(page).locator("label").filter({ + hasText: /^device petname$/, + }).locator("input"); await field.fill(name); - // The visor writes on `change`, not on every keystroke. - await field.blur(); + await saveDraft(page); } /** @@ -292,7 +355,7 @@ async function keepDevice( petname: string, passphrase?: string, ): Promise { - if (await drawer(page).count() === 0) await openTenant(page, "Settings"); + await openSettingsSheet(page); const keep = sheet(page, "Keep this device"); await keep.waitFor({ timeout: 10_000 }); await keep.locator("input[type=text]").fill(petname); @@ -309,15 +372,16 @@ async function keepDevice( await sheet(page, "kept as").waitFor({ timeout: 15_000 }); } -/** Is the strip painted with an identity? `.unclaimed` is the whole dress: - * one class, and `#visor-circle` gets a background only in the open arm. */ +/** Is the visor painted with an identity? One class and one inline + * variable, both on `#visor-root`: `--hue` is emitted by the open arm alone + * and every colour in the stylesheet is a function of it. */ async function claimed(page: Page): Promise { - const cls = await strip(page).getAttribute("class") ?? ""; - const style = await page.locator("#visor-circle").getAttribute("style") ?? ""; - const painted = style.includes("hsl("); - if (cls.includes("unclaimed") && painted) { + const root = page.locator("#visor-root"); + const cls = await root.getAttribute("class") ?? ""; + const style = await root.getAttribute("style") ?? ""; + if (cls.includes("unclaimed") && style.includes("--hue")) { throw new Failure( - "the strip is unclaimed and yet the anchor colour is painted — " + + "the visor is unclaimed and yet the anchor colour is painted — " + 'docs/design.md "Devices" forbids exactly that', ); } @@ -346,13 +410,10 @@ async function claimed(page: Page): Promise { const devicesSheet = (page: Page) => sheet(page, "Devices"); -const settingsButton = (page: Page) => - strip(page).getByRole("button", { name: "Settings", exact: true }); - /** Settings open, showing the Devices section. */ async function openSettings(page: Page): Promise { if (await devicesSheet(page).count() > 0) return; - await settingsButton(page).click(); + await openSettingsSheet(page); await devicesSheet(page).waitFor({ timeout: 10_000 }); } @@ -596,9 +657,11 @@ async function connectDrive(page: Page): Promise { } await new Promise((r) => setTimeout(r, 500)); // Re-read: the ceremony completes in the kernel, and this world has no - // timer. Closing and reopening Settings is the press that reads. - await settingsButton(page).click(); - await settingsButton(page).click(); + // timer. The press that opens Settings is the read — and the drawer is + // not a toggle any more, so leaving it and coming back is what makes + // that press happen again. + await openApps(page); + await openSettings(page); } } @@ -773,8 +836,11 @@ const scenarios: Scenario[] = [ check(box !== null, "#visor-strip has no box"); eq(box!.height, 56, "#visor-strip height"); // The strip says "waking" until `device.status` answers over the - // worker port; the placeholder is the first kernel-backed pixel. - await strip.getByText("this device").waitFor({ timeout: 10_000 }); + // 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, + }); }, }, @@ -900,7 +966,7 @@ const scenarios: Scenario[] = [ eq(after, before, "#visor-strip geometry moved when the app mounted"); const plated = await strip.locator("q").first().textContent(); - eq(plated, "TodoMVC", "the strip's context should plate the app title"); + eq(plated, "TodoMVC", "the strip's left half should plate the app title"); }, }, @@ -960,16 +1026,16 @@ const scenarios: Scenario[] = [ ); // Settle before measuring, and settle on facts rather than on a - // timeout: the strip's claim is about where it ends up, and launching - // went through the Apps drawer, which is part of the visor's own tree - // and legitimately moves the strip while it is open. Comparing a - // drawer-open frame against a drawer-closed baseline would fail for a - // reason that has nothing to do with the app. - const context = page.locator("#visor-context"); - await context.getByText("ended", { exact: false }).waitFor({ + // timeout: the strip's claim is about where it ends up. The drawer is + // no part of that any more — it overlays the app zone instead of + // pushing anything — so the baseline was taken with it open and the + // comparison is made with it open again, and neither is a special + // case. With nothing running the drawer is pinned on the app list, so + // the session ending puts it back there; the notice is read from it. + const notice = page.locator("#visor-notice"); + await notice.getByText("ended", { exact: false }).waitFor({ timeout: 10_000, }); - await drawer(page).waitFor({ state: "detached", timeout: 10_000 }); // The trusted pixels do not move because an app misbehaved. const after = await strip(page).boundingBox(); @@ -980,9 +1046,9 @@ const scenarios: Scenario[] = [ // Framework voice for the reason, the app's own title plated: the // publisher's text never enters the sentence unquoted. eq( - await context.locator("q").first().textContent(), + await notice.locator("q").first().textContent(), "Hostile fixture", - "the strip should plate the ended app's title", + "the notice should plate the ended app's title", ); }, }, @@ -1079,7 +1145,7 @@ const scenarios: Scenario[] = [ await visorReady(page); await strip(page).getByText("this device").waitFor({ timeout: 15_000 }); - await openTenant(page, "Settings"); + await openSettingsSheet(page); await drawer(page).getByRole("button", { name: "Other devices" }).click(); const rows = drawer(page).locator(".device-row"); await drawer(page).getByRole("button", { name: "Start fresh here" }) @@ -1451,6 +1517,80 @@ const scenarios: Scenario[] = [ }, }, + { + // Unsaved changes are the user's, and the visor is the only thing that + // holds them: a field typed into the settings sheet reaches the kernel + // on `Save` and nowhere else, and a transition away from a dirty sheet + // asks rather than dropping it (visor/src/ui.rs `Draft`). + name: "visor-drafts", + async run(ctx, origin) { + const page = await open(ctx, origin); + await visorReady(page); + await openSettingsSheet(page); + const field = drawer(page).locator("label").filter({ + hasText: /^device petname$/, + }).locator("input"); + const confirm = page.locator("#visor-confirm"); + + await field.fill("half typed"); + + // Leaving a dirty sheet asks. Cancel means "I was not done": the + // sheet stays, and so does every character of it. + await appsButton(page).click(); + await confirm.waitFor({ timeout: 10_000 }); + await confirm.getByRole("button", { name: "Cancel", exact: true }) + .click(); + await confirm.waitFor({ state: "detached", timeout: 10_000 }); + eq(await field.inputValue(), "half typed", "Cancel dropped the draft"); + + // 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. + await appsButton(page).click(); + await confirm.waitFor({ timeout: 10_000 }); + await confirm.getByRole("button", { name: "Revert", exact: true }) + .click(); + await paneSettled(page); + check( + await drawer(page).locator(".app-row").count() > 0, + "Revert did not go on to the transition it was asked about", + ); + await openSettingsSheet(page); + eq(await field.inputValue(), "", "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. + await field.fill("the workbench"); + await saveDraft(page); + await page.reload(); + await visorReady(page); + await strip(page).getByText("the workbench").waitFor({ timeout: 15_000 }); + await openSettingsSheet(page); + eq( + await field.inputValue(), + "the workbench", + "the saved device petname did not survive a reload", + ); + + // The user's own labels ride in the same draft and land on the strip: + // the petname in the right half's top line, and the glyph — of which + // only the first character is ever drawn — in the circle. + await drawer(page).locator("label").filter({ hasText: /^your petname$/ }) + .locator("input").fill("ada"); + await drawer(page).locator("label").filter({ hasText: /^your glyph$/ }) + .locator("input").fill("🜁x"); + await saveDraft(page); + await page.waitForFunction( + () => document.querySelector("#visor-circle")?.textContent === "🜁", + undefined, + { timeout: 10_000 }, + ); + await page.locator("#visor-self").getByText("ada").waitFor({ + timeout: 10_000, + }); + }, + }, + { // Both realms on this side, named: the visor on the main thread and the // runtime in the SharedWorker. The worker is worth spelling out — a diff --git a/runtime/component/src/component.rs b/runtime/component/src/component.rs index fc1f038f..1d759a92 100644 --- a/runtime/component/src/component.rs +++ b/runtime/component/src/component.rs @@ -510,6 +510,32 @@ impl guest::device::Guest for Component { async fn reroll_word() -> Result { kernel()?.reroll_word().await.map_err(map_error) } + async fn meta(scope: guest::device::MetaScope) -> Result, Error> { + let scope = match scope { + guest::device::MetaScope::User => polyvisor_kernel::MetaScope::User, + guest::device::MetaScope::Device => polyvisor_kernel::MetaScope::Device, + guest::device::MetaScope::App(id) => polyvisor_kernel::MetaScope::App(id), + }; + Ok(kernel()? + .meta(scope) + .map_err(map_error)? + .into_iter() + .collect()) + } + async fn set_meta( + scope: guest::device::MetaScope, + entries: Vec<(String, String)>, + ) -> Result<(), Error> { + let scope = match scope { + guest::device::MetaScope::User => polyvisor_kernel::MetaScope::User, + guest::device::MetaScope::Device => polyvisor_kernel::MetaScope::Device, + guest::device::MetaScope::App(id) => polyvisor_kernel::MetaScope::App(id), + }; + kernel()? + .set_meta(scope, entries.into_iter().collect()) + .await + .map_err(map_error) + } async fn keep(petname: String, passphrase: Option) -> Result<(), Error> { kernel()?.keep(petname, passphrase).await.map_err(map_error) } diff --git a/runtime/crates/kernel/src/device.rs b/runtime/crates/kernel/src/device.rs index 29ca0327..055e9738 100644 --- a/runtime/crates/kernel/src/device.rs +++ b/runtime/crates/kernel/src/device.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::LazyLock; use crate::{Error, ErrorCode}; @@ -126,6 +127,26 @@ pub struct Device { pub name: String, pub hue: u16, pub word: String, + /// The user's own labels — petname, glyph, whatever the visor settles on + /// (internal.wit `meta-scope`). Absent from checkpoints written before + /// this field existed; `serde(default)` keeps those loading. + #[serde(default)] + pub meta: Meta, +} + +/// One map per `MetaScope`; see internal.wit `meta-scope`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Meta { + pub user: BTreeMap, + pub device: BTreeMap, + pub app: BTreeMap>, +} + +/// Which map `meta`/`set_meta` reads or replaces. +pub enum MetaScope { + User, + Device, + App(String), } impl Device { @@ -143,6 +164,7 @@ impl Device { name: String::new(), hue: (draw(rng) % 360) as u16, word: word_at(draw(rng)), + meta: Meta::default(), } } diff --git a/runtime/crates/kernel/src/lib.rs b/runtime/crates/kernel/src/lib.rs index f2634ff9..c513d44b 100644 --- a/runtime/crates/kernel/src/lib.rs +++ b/runtime/crates/kernel/src/lib.rs @@ -24,7 +24,7 @@ mod store; mod sync; pub use apps::{AppInfo, AssetInfo, ComponentArtifacts}; -pub use device::{DeviceStatus, IndexRow, Rest, State, Tier}; +pub use device::{DeviceStatus, IndexRow, MetaScope, Rest, State, Tier}; pub use drive::{Binding, HttpResponse}; pub use events::Event; pub use pairing::Phase; @@ -555,6 +555,46 @@ impl Kernel { self.checkpoint().await } + /// internal.wit `device.meta`: an unknown app id answers an empty map + /// rather than an error — the visor asks before it knows whether the app + /// has ever set anything. + pub fn meta(&self, scope: MetaScope) -> Result, Error> { + self.open()?; + let state = self.state.borrow(); + let device = state.device.as_ref().expect("open implies a device"); + Ok(match scope { + MetaScope::User => device.meta.user.clone(), + MetaScope::Device => device.meta.device.clone(), + MetaScope::App(id) => device.meta.app.get(&id).cloned().unwrap_or_default(), + }) + } + + /// internal.wit `device.set-meta`: replaces the whole map for `scope`. + /// An empty map for an app scope removes that app's entry rather than + /// leaving an empty one behind. + pub async fn set_meta( + &self, + scope: MetaScope, + meta: BTreeMap, + ) -> Result<(), Error> { + self.open()?; + self.with_device(|d| { + match scope { + MetaScope::User => d.meta.user = meta, + MetaScope::Device => d.meta.device = meta, + MetaScope::App(id) => { + if meta.is_empty() { + d.meta.app.remove(&id); + } else { + d.meta.app.insert(id, meta); + } + } + } + Ok(()) + })?; + self.checkpoint().await + } + pub async fn reroll_word(&self) -> Result { self.open()?; let word = { diff --git a/runtime/crates/kernel/tests/kernel.rs b/runtime/crates/kernel/tests/kernel.rs index dd6c5a8c..c80e4386 100644 --- a/runtime/crates/kernel/tests/kernel.rs +++ b/runtime/crates/kernel/tests/kernel.rs @@ -13,8 +13,8 @@ use futures::stream::StreamExt as _; use futures::{executor::LocalPool, task::LocalSpawnExt as _}; use polyvisor_kernel::{ Accepted, BootConfig, Bound, Clock, Dialed, EngineTransport, Error, ErrorCode, Event, Fetch, - Files, HttpResponse, IndexRow, Kernel, LEASE_TTL_MS, LocalFuture, Locks, Net, NetHandle, Phase, - Platform, Rest, Rng, Seams, Spawn, State, Tier, + Files, HttpResponse, IndexRow, Kernel, LEASE_TTL_MS, LocalFuture, Locks, MetaScope, Net, + NetHandle, Phase, Platform, Rest, Rng, Seams, Spawn, State, Tier, }; // -- harness ----------------------------------------------------------------- @@ -1427,6 +1427,60 @@ fn name_and_hue_persist_across_a_reload_and_an_out_of_range_hue_is_refused() { assert_eq!(reread.hue, 200); } +#[test] +fn meta_persists_across_a_reload_and_is_unavailable_while_sealed() { + let world = World::default(); + let kernel = world.boot(); + block_on(kernel.set_meta( + MetaScope::User, + BTreeMap::from([("petname".into(), "Lann".into())]), + )) + .unwrap(); + block_on(kernel.set_meta( + MetaScope::App("app-1".into()), + BTreeMap::from([("glyph".into(), "L".into())]), + )) + .unwrap(); + assert_eq!( + kernel.meta(MetaScope::User).unwrap(), + BTreeMap::from([("petname".into(), "Lann".into())]) + ); + assert_eq!( + kernel.meta(MetaScope::App("app-1".into())).unwrap(), + BTreeMap::from([("glyph".into(), "L".into())]) + ); + // An app with no meta ever set answers empty, not an error. + assert_eq!( + kernel.meta(MetaScope::App("unknown".into())).unwrap(), + BTreeMap::new() + ); + block_on(kernel.keep("desk".into(), Some("open sesame".into()))).unwrap(); + + let reread = world.boot(); + assert_eq!( + reread.device_status().unwrap().state, + State::Sealed, + "a durable device under a passphrase boots sealed" + ); + assert_eq!( + reread.meta(MetaScope::User).unwrap_err().code, + ErrorCode::Unavailable, + "meta rides in the sealed checkpoint: unavailable while sealed" + ); + assert_eq!( + block_on(reread.set_meta(MetaScope::Device, BTreeMap::new())) + .unwrap_err() + .code, + ErrorCode::Unavailable + ); + block_on(reread.unseal("open sesame".into())).unwrap(); + assert_eq!( + reread.meta(MetaScope::User).unwrap(), + BTreeMap::from([("petname".into(), "Lann".into())]), + "meta persisted across the reload, readable once unsealed" + ); +} + #[test] fn tasks_survive_a_reload_but_sessions_do_not() { let world = World::default(); diff --git a/runtime/wit/internal.wit b/runtime/wit/internal.wit index 50886232..04c23128 100644 --- a/runtime/wit/internal.wit +++ b/runtime/wit/internal.wit @@ -193,6 +193,16 @@ interface device { set-hue: async func(hue: u16) -> result<_, error>; reroll-word: async func() -> result; + /// The user's own labels for themself, this device, and each installed + /// app — petname, glyph, whatever the visor settles on. Free-form while + /// the field set is in flux: keys are the visor's vocabulary, values are + /// user voice. Personal: rides in the sealed checkpoint with name/hue/ + /// word, so `unavailable` while sealed. + variant meta-scope { user, device, app(string) } + meta: async func(scope: meta-scope) -> result>, error>; + /// Replaces the whole map for `scope`. + set-meta: async func(scope: meta-scope, entries: list>) -> result<_, error>; + /// Promote to durable. `passphrase = none` is `rests-open`. Idempotent /// on the petname; changing the rest of a kept device is `refused` /// (reseal is a later milestone). diff --git a/visor/src/kernel.rs b/visor/src/kernel.rs index 31b2519e..fe2dcf76 100644 --- a/visor/src/kernel.rs +++ b/visor/src/kernel.rs @@ -256,6 +256,41 @@ pub(crate) async fn reroll_word() -> Result { api::device::reroll_word().await.map_err(message) } +/// Which map `meta`/`set_meta` reads or replaces (internal.wit `meta-scope`). +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum MetaScope { + User, + Device, + App(String), +} + +fn meta_scope(scope: MetaScope) -> api::device::MetaScope { + match scope { + MetaScope::User => api::device::MetaScope::User, + MetaScope::Device => api::device::MetaScope::Device, + MetaScope::App(id) => api::device::MetaScope::App(id), + } +} + +pub(crate) type Meta = std::collections::BTreeMap; + +/// The user's own labels for themself, this device, and one app +/// (internal.wit `device.meta`). +pub(crate) async fn meta(scope: MetaScope) -> Result { + Ok(api::device::meta(meta_scope(scope)) + .await + .map_err(message)? + .into_iter() + .collect()) +} + +/// Replaces the whole map for `scope` (internal.wit `device.set-meta`). +pub(crate) async fn set_meta(scope: MetaScope, meta: Meta) -> Result<(), String> { + api::device::set_meta(meta_scope(scope), meta.into_iter().collect()) + .await + .map_err(message) +} + pub(crate) async fn installed() -> Result, String> { Ok(api::apps::installed() .await diff --git a/visor/src/state.rs b/visor/src/state.rs index ec76faa1..e7c3370d 100644 --- a/visor/src/state.rs +++ b/visor/src/state.rs @@ -2,17 +2,35 @@ //! only stateful thing in the visor's chrome is testable natively. /// What the drawer is showing when it is open. A closed set, so this is an -/// enum and not an abstraction. `Apps`/`Settings` are the strip's own -/// buttons; `Unseal` and `Devices` are ceremonies the boot may raise on its -/// own, and `Devices` is additionally reachable from `Settings`. +/// enum and not an abstraction. `Apps`/`AppInfo` are what the strip's left +/// half raises and `Settings` what its right half does; `Unseal` and +/// `Devices` are ceremonies the boot may raise on its own, and `Devices` is +/// additionally reachable from `Settings`. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Tenant { Apps, + AppInfo, Settings, Unseal, Devices, } +impl Tenant { + /// Where this tenant sits on the one axis the drawer slides along, so a + /// switch has a direction: a sheet reached from the strip's left half + /// enters from the left of one reached from its right half, and + /// "Other devices" — reached from Settings — enters from the right of + /// it. Ties (`Apps`/`AppInfo`, which are the same half) slide the same + /// way as any other rightward move; only the sign is read. + pub(crate) fn ordinal(self) -> u8 { + match self { + Tenant::Apps | Tenant::AppInfo => 0, + Tenant::Settings | Tenant::Unseal => 1, + Tenant::Devices => 2, + } + } +} + /// `device.state` from internal.wit, as a plain value: the reducer decides /// what the drawer does at boot, and that decision must be testable without /// the component bindings (which exist only on the wasm target). @@ -46,6 +64,10 @@ pub(crate) enum Rest { /// brand new on an origin that already holds a kept one is far more likely /// to be a reload that lost its anchor than a deliberate second device. /// +/// `Closed` here means "no ceremony to raise", not "show nothing": the +/// caller runs the result through [`Drawer::reduce`] with the pinned flag, +/// which at boot — nothing is running yet — rests it on the app list. +/// // CONTRACT: the dispatch spells the "brand new" test two ways ("state is // fresh" and "our status.tier == ephemeral && status.petname == \"\""). The // conservative reading is the conjunction — all three must hold — so a @@ -190,22 +212,31 @@ pub(crate) enum Drawer { #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Action { - /// A strip button for a tenant was pressed. - Toggle(Tenant), - /// Something happened that the drawer must get out of the way of — - /// a session opening, or closing, or a seal opening. + /// A half of the strip was pressed, or a sheet sent the user on. Never + /// closes: showing what is already shown is identity, so the drawer is + /// not a toggle any more and a press can never leave the user looking + /// at nothing. + Show(Tenant), + /// Something happened that the drawer must get out of the way of — a + /// session opening, or closing, a seal opening, or the scrim pressed. Close, } impl Drawer { - pub(crate) fn reduce(self, action: Action) -> Drawer { + /// `pinned` is "nothing is running": with no app on screen there is + /// nothing for the drawer to be in the way of, so the app list is what + /// the visor rests at and `Closed` is not a state it can reach. With a + /// session running the app owns the screen and `Close` means it. + pub(crate) fn reduce(self, action: Action, pinned: bool) -> Drawer { + let rest = if pinned { + Drawer::Open(Tenant::Apps) + } else { + Drawer::Closed + }; match action { - // The same button again closes: a strip button is a toggle, - // never a one-way trip, so the strip is always one press from - // showing nothing but itself. - Action::Toggle(t) if self == Drawer::Open(t) => Drawer::Closed, - Action::Toggle(t) => Drawer::Open(t), - Action::Close => Drawer::Closed, + Action::Show(t) if self == Drawer::Open(t) => self, + Action::Show(t) => Drawer::Open(t), + Action::Close => rest, } } @@ -221,35 +252,70 @@ impl Drawer { mod tests { use super::*; + /// Showing what is already shown is identity: a press on the half of + /// the strip whose sheet is open must not shut it, or the drawer would + /// flicker every time a user pressed the thing they were reading. #[test] - fn same_tenant_twice_closes() { - let d = Drawer::default().reduce(Action::Toggle(Tenant::Apps)); - assert_eq!(d, Drawer::Open(Tenant::Apps)); - assert_eq!(d.reduce(Action::Toggle(Tenant::Apps)), Drawer::Closed); + fn showing_the_open_tenant_changes_nothing() { + for pinned in [true, false] { + let d = Drawer::Open(Tenant::Apps); + assert_eq!(d.reduce(Action::Show(Tenant::Apps), pinned), d); + } } #[test] fn other_tenant_replaces() { - let d = Drawer::default().reduce(Action::Toggle(Tenant::Apps)); + let d = Drawer::default().reduce(Action::Show(Tenant::Apps), true); assert_eq!( - d.reduce(Action::Toggle(Tenant::Settings)), + d.reduce(Action::Show(Tenant::Settings), true), Drawer::Open(Tenant::Settings) ); } + /// Nothing running: the app list is where the drawer rests, so a close + /// lands there and `Closed` is not reachable at all. + #[test] + fn pinned_close_opens_the_app_list() { + for from in [ + Drawer::Open(Tenant::Settings), + Drawer::Open(Tenant::Devices), + Drawer::Open(Tenant::Unseal), + Drawer::Closed, + ] { + assert_eq!( + from.reduce(Action::Close, true), + Drawer::Open(Tenant::Apps), + "pinned close from {from:?}" + ); + } + } + /// A session opening (and closing) sends `Close`: the app frame gets /// the screen, the drawer never covers it. #[test] - fn session_change_closes_the_drawer() { + fn unpinned_close_shuts_the_drawer() { for open in [ Drawer::Open(Tenant::Apps), + Drawer::Open(Tenant::AppInfo), Drawer::Open(Tenant::Settings), Drawer::Closed, ] { - assert_eq!(open.reduce(Action::Close), Drawer::Closed); + assert_eq!(open.reduce(Action::Close, false), Drawer::Closed); } } + /// The slide direction is a sign, and it has to be the one the strip + /// implies: the left half's sheets sit left of the right half's, and + /// "Other devices" sits right of Settings, which is where it is + /// reached from. + #[test] + fn ordinals_order_the_sheets_left_to_right() { + assert_eq!(Tenant::Apps.ordinal(), Tenant::AppInfo.ordinal()); + assert!(Tenant::Apps.ordinal() < Tenant::Settings.ordinal()); + assert_eq!(Tenant::Unseal.ordinal(), Tenant::Settings.ordinal()); + assert!(Tenant::Settings.ordinal() < Tenant::Devices.ordinal()); + } + #[test] fn tenant_reports_what_is_open() { assert_eq!(Drawer::Closed.tenant(), None); @@ -271,12 +337,13 @@ mod tests { } /// The seal opening is a `Close`: the ceremony is over and the strip is - /// now painted with a real identity, which is the thing to look at. + /// now painted with a real identity. Nothing is running at that moment, + /// so what the drawer rests at is the app list. #[test] - fn unseal_success_closes_the_drawer() { + fn unseal_success_rests_on_the_app_list() { assert_eq!( - Drawer::Open(Tenant::Unseal).reduce(Action::Close), - Drawer::Closed + Drawer::Open(Tenant::Unseal).reduce(Action::Close, true), + Drawer::Open(Tenant::Apps) ); } @@ -316,7 +383,7 @@ mod tests { #[test] fn devices_is_reachable_from_settings() { assert_eq!( - Drawer::Open(Tenant::Settings).reduce(Action::Toggle(Tenant::Devices)), + Drawer::Open(Tenant::Settings).reduce(Action::Show(Tenant::Devices), true), Drawer::Open(Tenant::Devices) ); } diff --git a/visor/src/style.rs b/visor/src/style.rs index 76ae771d..186f75ae 100644 --- a/visor/src/style.rs +++ b/visor/src/style.rs @@ -3,41 +3,160 @@ //! Shipped as a `