diff --git a/CLAUDE.md b/CLAUDE.md index 01d44a2..1fe9bd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,12 +44,12 @@ A lightweight Electron app that connects to a remote Chromium-based browser via - **Mouse position mapping**: Screencast frames may not fill the canvas (black bars due to aspect ratio). `toRemoteCoords()` in `src/lib/viewport-transform.ts` (Viewport Transform) calculates the letterbox offset and scale to map mouse coordinates accurately; both the draw path and Input Forwarding use the same function. The frame is also often **downscaled** from the remote layout viewport (the screencast is capped at the local canvas size, so a larger remote window arrives smaller), so the map scales frame-px → remote DIP using the frame metadata's `deviceWidth`/`deviceHeight` (CDP input wants DIP, not frame-buffer px — the same approach DevTools' screencast uses). `devicePixelRatio` cancels out of the math, so it's never the source of an offset; a wrong click position means a frame/device size mismatch. Without metadata it assumes 1:1 (correct whenever the frame isn't downscaled). - **Tab activation**: Switching tabs calls `/json/activate/{id}` first, then reconnects the screencast WS. Edge 148 (Chromium 148) allows multiple concurrent CDP clients per target, so read-only side-channel sockets can stay attached to background tabs without disrupting the active screencast session. See `docs/adr/0003-notifications-side-channel.md`. - **Screencast rate ceiling (t054)**: On a slow link the remote browser emits frames faster than the link drains them, so a backlog forms and the client paints *stale* frames (cursor lags behind reality). The rate ceiling lives in one backend-agnostic place — `core/remote-page-connector.js` exports `SCREENCAST_TARGET_FPS` (30, conservative — invisible on a fast LAN, only bites under throttle) and the derived `SCREENCAST_EVERY_NTH_FRAME`, passed as `Page.startScreencast`'s `everyNthFrame` (producer-side cap). The web relay (`web/server.mjs`) adds a fresh-frame-wins drop: a `createFrameThrottle` (`core/frame-throttle.js`, pure, DI clock) gates broadcasts to the target rate while **every** frame is still acked (`Page.screencastFrameAck`) so the remote keeps producing — ack-but-drop is load-bearing; a dropped-yet-un-acked frame stalls the whole stream. The `Page.startScreencast` `quality` + `everyNthFrame` no longer hardcode literals — both connect paths read them from the quality tier (see next bullet). `main.js` keeps an inline `startScreencast` (connector adoption is deferred t032) using the **default** tier (`tierToParams(DEFAULT_TIER)`), so its numbers can't drift from the connector's. -- **Quality-latency tier (t055, web only)**: A user-selectable **Sharp / Balanced / Snappy** preset trades screencast sharpness for responsiveness in one tap. The single owner is `core/quality-tier.js` (ADR-0008): `tierToParams(tier) → { jpegQuality, everyNthFrame }` (Sharp `92/1`, Balanced `80/2` = today's behavior, Snappy `60/3`), `DEFAULT_TIER = "balanced"`, and `parseTier(raw)` (garbage → default). Both connect paths read it instead of a literal — `core/remote-page-connector.js` reads `uiState().qualityTier` at connect (re-applied on every reconnect like the adaptive-viewport metrics), and `main.js`'s inline `startScreencast` uses the default tier (Electron has no picker). The picker lives in Settings (web only, gated by `caps.web`), a 3-way segmented control mirroring the t019 transport toggle; it persists to `localStorage` (key `qualityTier`) **and** mirrors into ui-state (so the server reads it), then calls `reconnect()` to apply. The renderer-facing tier list/labels/key live in `src/lib/quality-tier.ts` (the UI never applies params — the server does). No new ADR — a tuning knob inside ADR-0006/ADR-0002. +- **Quality-latency tier (t055, web only)**: A user-selectable **Sharp / Balanced / Snappy** preset trades screencast sharpness for responsiveness in one tap. The single owner is `core/quality-tier.js` (ADR-0008): `tierToParams(tier) → { jpegQuality, everyNthFrame }` (Sharp `92/1`, Balanced `80/2` = today's behavior, Snappy `60/3`), `DEFAULT_TIER = "balanced"`, and `parseTier(raw)` (garbage → default). Both connect paths read it instead of a literal — `core/remote-page-connector.js` reads `uiState().qualityTier` at connect (re-applied on every reconnect like the adaptive-viewport metrics), and `main.js`'s inline `startScreencast` uses the default tier (Electron has no picker). The picker lives in Settings (web only, gated by `caps.web`), a 3-way segmented control mirroring the t019 transport toggle; it persists **per device** in server ui-state (`qualityTier_`, t100 — was `localStorage`) via `src/lib/device-prefs.ts`, mirrored into a plain global `qualityTier` **shadow** the connector reads, then calls `reconnect()` to apply. The renderer-facing tier list/labels/key live in `src/lib/quality-tier.ts` (the UI never applies params — the server does). No new ADR — a tuning knob inside ADR-0006/ADR-0002. - **Phone Shell (t076, ADR-0012)**: Below a width breakpoint (`shellModeFor` in `src/lib/shell-mode.ts`, reactive via `use-shell-mode.ts` — width-only, never pointer-coarseness, never `caps`) the renderer runs a distinct shell: the **Inbox** (`src/components/inbox.tsx`, the bell popover's `groupByConversation` read model promoted to a full-screen root view) is home; the browser column is a destination (stays mounted but hidden so the canvas + Toolbar-hosted settings sheet keep working; the Sidebar truly unmounts). The Toolbar's sidebar-toggle slot becomes a back-to-Inbox button. Tapping an entry opens the **Conversation Reader** (t077): a phone-native detail rendered from captured content — Slack entries with a channel identity load one rendered `conversations.history` page (`POST /api/slack/history`, web only — Electron has no sweep creds, so its Slack entries stub) through the sweep's creds + name caches via `fetchConversation` on the sweep runner; other adapters show the captured toast text (stub detail). Routing is the per-adapter capability table in `src/lib/reader.ts` (`readerRoute`), never an inline Slack branch. Opening the reader marks the thread read **locally only** (no `conversations.mark` — the desktop unread survives as a to-do trail); "Open in browser" escalates to the normal activation path. The Slack reader has a **text-only composer** (t078): `selectReplyTarget` in `src/lib/slack-reply.ts` is the single owner of where a reply lands (DM/group-DM → plain message, channel mention → its thread, thread notification → its parent thread — explicitly expected to be revisited), the send rides `POST /api/slack/reply` → `chat.postMessage` through the sweep creds, and failure is synchronous + honest (draft stays in the box with typed copy; no outbox). A flat read-and-go **tab/pin switcher** (`phone-switcher.tsx`, t081) is the non-notification path to the screencast (Inbox globe → pins/tabs/locals list → tap activates; no drag/close/context menus); Settings opens full-width; the command palette, shortcut overlay, and find bar are not mounted on the phone shell. The phone shell **never applies Adaptive Viewport** (`shouldApplyAdaptive`) — a ~390px override would break non-responsive sites (Slack) and mutates the Remote Browser globally; the frame letterboxes instead and **local pinch-zoom/pan** (t079, see the touch bullet under Known Limitations) does the magnifying. Wide layout is byte-unchanged above the breakpoint. - **Edge compatibility**: Edge requires `PUT` method for `/json/new` (Chrome accepts `GET`). - **Adaptive Viewport**: An optional mode that eliminates letterbox bars by resizing the remote page to match the canvas via `Emulation.setDeviceMetricsOverride`. The main process caches the last override and re-applies it before `Page.startScreencast` on every (re)connect. See `docs/adr/0002-adaptive-viewport.md`. - **Packaging allowlist**: `build.files` in `package.json` is an explicit allowlist, not a denylist. Any new file `main.js` requires/reads at runtime (e.g. anything under `core/` or `inject/`) must be added there, or it gets stripped from the asar and the packaged app throws `Cannot find module` on launch. `core/**/*.js` is already in the allowlist; test files (`*.test.ts`) are excluded. Renderer code is safe — it's bundled into `dist/` by Vite. -- **Settings persistence**: Host, port, theme, pins, sidebar width, sidebar-collapsed state, pinned-open state, `adaptiveViewport`, `forceOnClient`, `switchEffect`, `notificationsEnabled`, and `syncTheme` are stored in `userData/settings.json`. Saving a new CDP address immediately reconnects to the first available tab. Legacy `switchBlur` boolean is migrated to `switchEffect`, and legacy `bookmarks` to `pins`, on first load. +- **Settings persistence**: Host, port, theme, pins, sidebar width, sidebar-collapsed state, pinned-open state, `adaptiveViewport`, `forceOnClient`, `switchEffect`, `notificationsEnabled`, and `syncTheme` are stored in `userData/settings.json`. Saving a new CDP address immediately reconnects to the first available tab. Legacy `switchBlur` boolean is migrated to `switchEffect`, and legacy `bookmarks` to `pins`, on first load. The three web-only client prefs — `qualityTier`, `inputTransport`, `latencyHud` — persist **per device** in server ui-state under `_` (t100, `src/lib/device-prefs.ts`, `core/settings-store.js` `DEVICE_KEY_PREFIXES`), so they survive an iPad-PWA localStorage wipe; `qualityTier` also mirrors into a plain global shadow the screencast connector reads (the connecting device's tier applies to the shared stream). - **Pins (live-tab holders)**: A pin holds a remote tab (`targetId`), hidden from the Tabs list while linked. Click activates the linked tab or opens+links a fresh one; cmd/middle-click opens an unlinked throwaway tab. Created from a live tab only (toolbar star, right-click tab → Pin, or drag a tab into the Pinned section). A linked pin mirrors its tab's live title/favicon (restoring the saved title when the tab closes); the active pin shows an Arc-style URL-drift cue (a `/` separator and a favicon "Back to Pinned URL" button) when its tab navigates off the saved URL. Closing a pin's tab reverts it to unlinked; un-pinning (confirm dialog) returns the tab to the Tabs list. Cmd+1..9 indexes all pins then visible tabs; Ctrl+Tab cycles open pins + tabs. Link resolution is pure (`src/lib/pins.ts`); persistence/effects live in main + `app.tsx`. See `docs/adr/0004-pin-live-tab-model.md`. - **Unread badges by group**: Sidebar unread counts are computed by `aggregateUnread` (`src/lib/unread-aggregator.ts`) and keyed by `groupKey` (from the notification entry) falling back to `groupKeyForUrl(url)` — Slack's per-workspace `slack:{teamId}`, else URL origin. Every tab/pin of the same app shares one count whether or not it captured the notification, and a dormant pin still badges by resolving its saved URL through the same key derivation. - **Local tabs**: Real local web pages rendered as in-DOM Electron ``s on a shared `persist:local` session (`src/components/local-webviews.tsx`) — full device access (OS notifications, speaker/mic, camera, screen-share) that CDP screencast tabs can't have. Because a `` is an in-page OOPIF, React overlays (dialogs, menus, tooltips, the settings sheet) stack **above the live page via CSS z-index** — no native z-order, no freeze. `activeKind: 'cdp' | 'local'` chooses the surface and routes the toolbar/nav hotkeys (`RemotePage` vs the active webview's methods). The renderer holds `LocalTab` metadata and maps webview DOM events to it; only the active webview is shown (others `display:none`, kept alive in the background). All open local tabs persist + restore on launch; pinned ones (a `pinned` flag, distinct from CDP PINNED pins) sort atop the LOCAL TABS section. Unpacked MV3 extensions load into the local session only (`localExtensionPaths`) and their content scripts inject into webview guests; the toolbar shows a Chrome-like action icon per extension (opens its popup in a popover), and popup/options also open as a local tab via the `chrome-extension://` URL. Permissions auto-granted behind the `autoGrantLocalMedia` setting (a `media` request triggers `askForMediaAccess`); packaging ships mic/cam/audio-capture Info.plist keys + entitlements (`build/entitlements.mac.plist`, hardened runtime). See `docs/adr/0005-local-tabs-base-window.md`. @@ -166,6 +166,7 @@ cdp-browser/ │ ├── perf-mark.ts # Debug-only perf accumulator (tagged stage timings, ?perf=1) │ ├── latency-metrics.ts # Web build: always-on RTT/jitter EWMA + frame-age math + singleton (getLatencySnapshot). See docs/tasks/057 │ ├── quality-tier.ts # Web build: renderer-facing quality-tier list/labels/key + parseTier for the Settings picker (t055); params live in core/quality-tier.js + │ ├── device-prefs.ts # Web build: pure per-device client-pref remap (qualityTier/inputTransport/latencyHud → _ + qualityTier global shadow), durable in server ui-state (t100) │ ├── slack-excludes.ts # Pure Channel Exclude list helpers: addExclude/removeExclude/excludedChannelIds/excludeTargetFromEntry; ui-state key slackExcludes (t072, ADR-0011) │ ├── screencast-keys.ts # Pure OSK key helpers: VKEY map (Windows virtual codes), KEYDOWN_KEYS set, synthKey(key) → SynthKey (t084/t086) │ ├── text-input-delta.ts # Pure on-screen-keyboard diff: diffInput(prev, next) → { backspaces, insert } via common-prefix delta (t084) diff --git a/core/settings-store.js b/core/settings-store.js index 5400869..7eb5246 100644 --- a/core/settings-store.js +++ b/core/settings-store.js @@ -44,7 +44,15 @@ const UI_SETTABLE = Object.keys(UI_DEFAULTS).filter((k) => k !== "localExtension // across a PWA refresh (localStorage resets on the iPad). A key passes the device-key // gate when its base prefix is one of these — the renderer's per-device remap seam in // cdp-web-transport.ts owns the suffixing (t066 webPush, t093 mutes + per-device master). -const DEVICE_KEY_PREFIXES = ["webPush_", "notifMutes_", "notificationsEnabled_"] +const DEVICE_KEY_PREFIXES = [ + "webPush_", + "notifMutes_", + "notificationsEnabled_", + // t100 durable per-device client prefs: quality tier / input transport / latency HUD. + "qualityTier_", + "inputTransport_", + "latencyHud_", +] const isDeviceKey = (k) => DEVICE_KEY_PREFIXES.some((p) => k.startsWith(p)) // One-time migrations mirroring main.js: legacy boolean switchBlur -> switchEffect diff --git a/core/settings-store.test.ts b/core/settings-store.test.ts index 7966378..04c70b5 100644 --- a/core/settings-store.test.ts +++ b/core/settings-store.test.ts @@ -50,6 +50,19 @@ describe("settings-store", () => { expect(ui.notificationsEnabled_dev1).toBe(false) }) + it("round-trips the t100 per-device client prefs (quality tier / transport / HUD)", () => { + const s = createSettingsStore({ initial: {}, persist }) + s.setUiState({ + qualityTier_dev1: "snappy", + inputTransport_dev1: "batch", + latencyHud_dev1: true, + }) + const ui = s.getUiState() + expect(ui.qualityTier_dev1).toBe("snappy") + expect(ui.inputTransport_dev1).toBe("batch") + expect(ui.latencyHud_dev1).toBe(true) + }) + it("still drops an unknown-prefix device-suffixed key", () => { const s = createSettingsStore({ initial: {}, persist }) s.setUiState({ bogus_dev1: 1 }) diff --git a/docs/conventions/docs-discipline.md b/docs/conventions/docs-discipline.md index 3e4dc01..f7e9967 100644 --- a/docs/conventions/docs-discipline.md +++ b/docs/conventions/docs-discipline.md @@ -87,7 +87,7 @@ Any non-trivial design decision gets an ADR. Examples of "non-trivial": ADRs are **append-only**. When a decision changes, write a new ADR that supersedes the old one. Update the old ADR's *Status* line to reference the superseder, but never edit its body. The history is the documentation. -Current ADRs: `docs/adr/0001-single-remote-page.md`, `0002-adaptive-viewport.md`, `0003-notifications-side-channel.md`, `0004-pin-live-tab-model.md`, `0005-local-tabs-base-window.md`, `0006-web-proxy-sse-transport.md`, `0007-web-websocket-transport.md`, `0008-defer-monorepo-shared-cjs-core.md`, `0009-touch-first-co-primary-input-surface.md`, `0010-multiple-workspaces-deferred-design.md`, `0011-slack-content-sweep-guaranteed-delivery.md`, `0012-phone-triage-surface-inbox-rooted-shell-conversati.md`, `0013-per-device-notification-delivery.md`, `0014-endpoint-reconciled-per-device-push-identity.md`, `0015-prefer-thin-handlers-over-reducer-indirection.md`. +Current ADRs: `docs/adr/0001-single-remote-page.md`, `0002-adaptive-viewport.md`, `0003-notifications-side-channel.md`, `0004-pin-live-tab-model.md`, `0005-local-tabs-base-window.md`, `0006-web-proxy-sse-transport.md`, `0007-web-websocket-transport.md`, `0008-defer-monorepo-shared-cjs-core.md`, `0009-touch-first-co-primary-input-surface.md`, `0010-multiple-workspaces-deferred-design.md`, `0011-slack-content-sweep-guaranteed-delivery.md`, `0012-phone-triage-surface-inbox-rooted-shell-conversati.md`, `0013-per-device-notification-delivery.md`, `0014-endpoint-reconciled-per-device-push-identity.md`, `0015-prefer-thin-handlers-over-reducer-indirection.md`, `0016-persist-slack-sweep-watermark.md`. --- @@ -151,4 +151,4 @@ If a doc has rotted beyond repair, delete it and write a fresh one. A wrong doc _Docs you don't maintain are a liability. Docs you maintain are a force multiplier._ -_Last revisited: 2026-06-20_ +_Last revisited: 2026-07-07_ diff --git a/docs/tasks/done/100-durable-per-device-client-prefs.md b/docs/tasks/done/100-durable-per-device-client-prefs.md new file mode 100644 index 0000000..c4bbfd1 --- /dev/null +++ b/docs/tasks/done/100-durable-per-device-client-prefs.md @@ -0,0 +1,156 @@ +# 100 — durable per-device client prefs + +- **Status:** done +- **Mode:** AFK +- **Estimate:** 1d +- **Depends on:** none +- **Blocks:** none + +## Goal + +Three web-only client prefs — `qualityTier` (Sharp/Balanced/Snappy screencast preset), +`inputTransport` (Auto/WS/Stream/Batch), and `latencyHud` (status-bar readout on/off) — are +stored in `localStorage` today, so they silently reset every time the iPad PWA evicts storage +(the same wipe class the t095 push `deviceId` fix already fought). After this task each of the +three persists **per device** in server ui-state, keyed `_` like the existing +`webPush`/`notifMutes` prefs, and survives a PWA refresh/reinstall. A phone and a desktop each +keep their own remembered values; `localStorage` is no longer the source of truth for any of them. + +## Why now + +`localStorage` resetting on the iPad PWA is a known papercut (see the `localstorage-resets-in-pwa` +learning): every wipe drops the user's chosen quality tier, transport mode, and HUD toggle back to +defaults with no warning. t099 fixed the notification-delivery half of the same root cause +(server-authoritative `deviceId`, device-keyed prefs surviving a wipe); this closes the +client-ergonomics half using the *same* device-keyed ui-state seam. It also removes a live +**drift bug**: `qualityTier` is currently double-stored (localStorage *and* a global ui-state key), +so the picker can show one value while the connector applies another. + +## Acceptance criteria + +- [x] `qualityTier`, `inputTransport`, and `latencyHud` each persist per device in server ui-state + under `_` and are restored after a PWA refresh / localStorage wipe. + *(settings-store prefixes + transport remap + e2e round-trip.)* +- [x] Two web devices hold **independent** values for all three; changing one device's pref never + changes the other's stored value. *(device-prefs isolation test + e2e second-device test.)* +- [x] `localStorage` is no longer read or written for `qualityTier` / `inputTransport` / + `latencyHud` as a source of truth. (The transport-selector's transient last-good *probe* + cache stays in localStorage — it is not a durable user pref.) +- [x] A device with no stored `qualityTier` slot falls back to the existing **global** `qualityTier` + value (then the balanced default) — no device resets to balanced on first load after ship. +- [x] The shared upstream screencast still applies the **connecting device's** tier: on tier change + (`setUiState` → `writeDevicePrefs` shadow) and on (re)connect (`getUiState` shadow reconcile) + the renderer mirrors its per-device value into the global `qualityTier` shadow the connector + reads — the connector stays byte-unchanged. +- [x] Changing the transport picker still reconfigures the live transport (`reconfigureInputTransport`); + toggling the HUD still flips a mounted readout live; changing the tier still reconnects to apply. +- [x] Garbage / missing stored values degrade to the documented defaults (auto / off / balanced). +- [x] Electron is byte-unchanged (all three prefs are web-only; the shim is not installed there). +- [x] `pnpm typecheck`, `pnpm test` (1011), `pnpm test:e2e` (49), `pnpm build` all green; Biome clean on touched files. + +## Test plan + +### Layer 1 — Pure logic (TDD) + +- [x] `device-prefs.readDevicePrefs(uiState, deviceId)` — device slot wins over global/default. +- [x] `readDevicePrefs` — absent `qualityTier` slot falls back to the global `qualityTier` key, + then to the balanced default (the migration path — no explicit migration step). +- [x] `readDevicePrefs` — absent `inputTransport` / `latencyHud` slots return `auto` / `false`. +- [x] `readDevicePrefs` — garbage values (wrong-case, non-enum, non-boolean) parse to defaults. +- [x] `readDevicePrefs` — a different `deviceId` reads a different slot (isolation). +- [x] `device-prefs.writeDevicePrefs(partial, deviceId)` — emits `_` slots only for + the keys present in `partial`; absent keys are omitted (no clobber). +- [x] `writeDevicePrefs` — a `qualityTier` write also emits the plain global `qualityTier` shadow; + `inputTransport` / `latencyHud` writes do **not** touch any global key. +- [x] `device-prefs.deviceKey(base, deviceId)` — `_` shape. +- [x] `core/settings-store.js` — the 3 new device-key prefixes round-trip through `getUiState`/ + `setUiState` and persist (extended the existing `webPush_` prefix coverage). + +### Layer 2 — Server round-trip (e2e) + +n/a for main-process/IPC. The **web-server round-trip** is covered by a hermetic e2e spec +(`test/e2e/server.e2e.test.ts` → "per-device client prefs — ui-state round-trip (t100)") that +POSTs the device-keyed slots + the qualityTier shadow to the real `/api/ui-state`, reads them back, +and asserts a second device's slot stays independent — proving persistence + isolation through the +actual server, not a fake. Also updated the two `cdp-web-transport` characterization tests that +seeded batch mode via `localStorage` to use the real picker path (`setUiState` + `reconfigureInputTransport`). + +### Layer 3 — Visual review + +**No visual delta.** The three pickers, the HUD switch, and every label/layout are byte-identical — +only the data *source* moved from localStorage to server ui-state. The user-visible behavior (pickers +restore the stored value on open; the HUD toggle flips a mounted readout live) is exercised by the +Layer-1 + Layer-2 tests above. On-device confirmation (the pickers showing the right value after a +PWA relaunch on the iPad/iPhone) folds into the standard post-deploy device check against prod. + +- [x] No layout/appearance change (pickers + switch unchanged); behavior verified via L1/L2 tests. +- [ ] On-device: pickers restore stored values after a PWA relaunch (prod device check, HITL). + +## Design notes + +- **Contracts changed:** + - `CdpBridge.getUiState()` return — surfaces `qualityTier` / `inputTransport` / `latencyHud` + resolved for *this* device (plain names), same shape the pickers already read. + - `CdpBridge.setUiState(partial)` — accepts the three plain keys and remaps them to + `_` slots (+ the `qualityTier` global shadow) before POST, mirroring the + existing `webPush`/`notificationsEnabled`/`notifMutes` remap. + - `core/settings-store.js` `DEVICE_KEY_PREFIXES` gains `qualityTier_`, `inputTransport_`, + `latencyHud_`. +- **New modules:** + - `src/lib/device-prefs.ts` (pure) — declarative pref table + `readDevicePrefs` / + `writeDevicePrefs` / `deviceKey`. Owns the remap, defaults, parse-guards, and the qualityTier + global-shadow rule in one testable place; replaces hand-rolled if-ladders for the new prefs. + The CJS side (`core/settings-store.js`) keeps its own prefix list (ESM↔CJS duplication, + precedented by `notif-mutes.ts` ↔ `core/notif-mutes.js`). +- **Quality-tier live mirror (surfaced by /polish):** dropping the `qualityTier` localStorage key + broke `viewport.tsx`'s synchronous resize reissue, which read that key to preserve the tier on + `Page.startScreencast` (t099) — it would have silently reset every resize to balanced. Fixed with + a live in-memory mirror in `quality-tier.ts` (`readCurrentTier`/`setCurrentTier`), seeded from + ui-state at boot (`app.tsx`) + on picker change (`settings-dialog.tsx`), read by the reissue — + same shape as `latency-hud.tsx`'s flag. The dead `QUALITY_TIER_KEY` export was removed. +- **New ADR needed?** No — this is the same device-keyed ui-state pattern as t093/t095 (ADR-0014 + territory), a persistence-location change, not a new architectural decision. + +Data flow (types, not paths): + +```ts +// pure module — the single owner of the remap + defaults + shadow +type DevicePrefs = { + qualityTier: "sharp" | "balanced" | "snappy" + inputTransport: "auto" | "ws" | "stream" | "batch" + latencyHud: boolean +} +// device slot → global qualityTier shadow (qualityTier only) → default +readDevicePrefs(uiState: Record, deviceId: string): DevicePrefs +// { qualityTier } -> { qualityTier_, qualityTier } (shadow) +// { inputTransport } -> { inputTransport_ } (no global) +writeDevicePrefs(partial: Partial, deviceId: string): Record +``` + +Boot ordering (server-only, no localStorage source of truth): + +- `inputTransport`: the transport boots at its safe default (`auto`) and reconfigures to the stored + per-device mode once ui-state loads — one `reconfigureInputTransport()`, usually a no-op. +- `latencyHud`: off by default; the status-bar readout appears once ui-state resolves if the device + had it on. The live-flip event on the settings toggle is unchanged. +- `qualityTier`: the connector already applies from server ui-state on connect; the picker's shown + value now comes from ui-state instead of a possibly-stale localStorage copy. + +## Out of scope + +- Any Electron pref surface — all three prefs are web-only; Electron uses `DEFAULT_TIER` + IPC and + shows no HUD. +- Folding the existing `webPush` / `notificationsEnabled` / `notifMutes` remap into `device-prefs.ts` + — that path carries toast-gate side-state and was just hardened in t099; leave it untouched. A + future refactor could unify once the gate is decoupled. +- The transport-selector's last-good **probe** cache — stays in localStorage (transient probe + optimization; losing it on a wipe costs one extra probe, not a user pref). +- Migrating the global `qualityTier` value — the read-fallback handles it implicitly; no data + migration step. + +## Definition of Done + +- [x] Layer 1 tests written and green (`device-prefs.test.ts` (17) + extended `settings-store.test.ts`). +- [x] Layer 2 web round-trip covered by the hermetic e2e spec (persist + restore + isolation). +- [x] Layer 3: no visual delta (verified above); on-device confirmation deferred to the prod device check. +- [x] Moved to `docs/tasks/done/` with the `t100` ID in branch (`feat/t100-durable-per-device-client-prefs`) + commit. diff --git a/src/app.tsx b/src/app.tsx index c2f51c5..1b71796 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -5,7 +5,7 @@ import { ConversationReader } from "@/components/conversation-reader" import { EditPinDialog } from "@/components/edit-pin-dialog" import { FindBar, type FindBarHandle } from "@/components/find-bar" import { Inbox } from "@/components/inbox" -import { LatencyHud } from "@/components/latency-hud" +import { LatencyHud, setLatencyHudEnabled } from "@/components/latency-hud" import { type LocalApi, LocalWebviews } from "@/components/local-webviews" import { NewTabDialog, type NewTabKind } from "@/components/new-tab-dialog" import type { NotifEntry } from "@/components/notification-bell" @@ -45,6 +45,7 @@ import { getExistingSubscription, removePushSubscription, } from "@/lib/push-subscribe" +import { setCurrentTier } from "@/lib/quality-tier" import { shouldApplyAdaptive } from "@/lib/shell-mode" import { addExclude, @@ -437,6 +438,13 @@ export default function App() { setSwitchEffect(s.switchEffect ?? "blur") setNotificationsEnabled(s.notificationsEnabled ?? true) setNotifMutes(Array.isArray(s.notifMutes) ? s.notifMutes : []) + // t100: seed the durable per-device latency-HUD flag + quality-tier mirror from ui-state + // (was localStorage). The tier mirror feeds viewport.tsx's synchronous resize reissue; this + // getUiState also drives the transport's inputTransport reconfigure + qualityTier shadow. + if (caps.web) { + setLatencyHudEnabled(!!s.latencyHud) + setCurrentTier(s.qualityTier) + } setSyncTheme(s.syncTheme ?? true) setAutoGrantLocalMedia(s.autoGrantLocalMedia ?? true) restoreLocalPinsRef.current = s.restoreLocalPins ?? true diff --git a/src/components/latency-hud.tsx b/src/components/latency-hud.tsx index b17d804..7c6df55 100644 --- a/src/components/latency-hud.tsx +++ b/src/components/latency-hud.tsx @@ -4,21 +4,24 @@ import { useEffect, useState } from "react" import { getLatencySnapshot, type LatencySnapshot } from "@/lib/latency-metrics" import type { InputTransportMode } from "@/lib/transport-selector" -/** localStorage key for the persisted on/off flag (web-only, off by default). */ -export const LATENCY_HUD_KEY = "latencyHud" /** Fired by the settings toggle so a mounted HUD flips on/off live without prop-drilling. */ const LATENCY_HUD_EVENT = "latencyhud:change" -/** Read the persisted flag. Off (false) by default — a fresh load shows no readout. */ +// t100: the enabled flag is durable per-device in server ui-state now, not localStorage (which +// resets on the iPad PWA). This module holds only the live in-memory value + a change event so a +// mounted HUD flips instantly; app.tsx seeds it from ui-state at boot, and the settings toggle +// persists via setUiState. No storage here. +let hudEnabled = false + +/** The live in-memory flag (seeded from ui-state at boot). Off until ui-state resolves. */ export function readLatencyHudEnabled(): boolean { - return typeof localStorage !== "undefined" && localStorage.getItem(LATENCY_HUD_KEY) === "1" + return hudEnabled } -/** Persist the flag and notify any mounted HUD to flip live. Used by the settings toggle. */ +/** Apply the flag live: update the in-memory value and flip any mounted HUD. Persistence is the + * caller's ui-state write (setUiState({ latencyHud })) — this is display-apply only. */ export function setLatencyHudEnabled(on: boolean): void { - if (typeof localStorage === "undefined") return - if (on) localStorage.setItem(LATENCY_HUD_KEY, "1") - else localStorage.removeItem(LATENCY_HUD_KEY) + hudEnabled = on window.dispatchEvent(new CustomEvent(LATENCY_HUD_EVENT, { detail: on })) } diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 8f88e1f..1c480cc 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -33,7 +33,13 @@ import { ensurePushSubscription, removePushSubscription, } from "@/lib/push-subscribe" -import { parseTier, QUALITY_TIER_KEY, QUALITY_TIERS, type QualityTier } from "@/lib/quality-tier" +import { + DEFAULT_TIER, + parseTier, + QUALITY_TIERS, + type QualityTier, + setCurrentTier, +} from "@/lib/quality-tier" import { shouldArmLeaveTimer } from "@/lib/settings-dismiss" import { removeExclude, type SlackExclude } from "@/lib/slack-excludes" import type { InputTransportMode } from "@/lib/transport-selector" @@ -244,15 +250,11 @@ export function SettingsDialog({ version: buildVersion(), sha: defineSha(), })) - // Web-only connection mode picker (t019). The pref persists to localStorage; calling - // window.cdp.reconfigureInputTransport() re-opens/closes the WS to apply mid-session - // (no reload needed). The "active" badge mirrors what actually connected — under Auto - // this may differ from the picked mode if WS was unreachable and we fell to Stream. - const [inputTransport, setInputTransport] = useState(() => - typeof localStorage !== "undefined" - ? ((localStorage.getItem("inputTransport") as InputTransportMode | null) ?? "auto") - : "auto", - ) + // Web-only connection mode picker (t019). The pref is durable per-device in server ui-state now + // (t100), seeded from getUiState on open below; calling window.cdp.reconfigureInputTransport() + // re-opens/closes the WS to apply mid-session (no reload needed). The "active" badge mirrors what + // actually connected — under Auto this may differ from the picked mode if WS was unreachable. + const [inputTransport, setInputTransport] = useState("auto") const [activeTransport, setActiveTransport] = useState( () => window.cdp?.getActiveTransport?.() ?? "batch", ) @@ -263,18 +265,13 @@ export function SettingsDialog({ // settings dialog is a long-lived singleton in practice. }, []) - // Web-only quality-latency tier (t055). Persists to localStorage (read back on mount) - // and mirrors into ui-state so the server applies the tier's jpegQuality/everyNthFrame - // to Page.startScreencast on the next (re)connect. Same persistence shape as the t019 - // transport picker above. The default (balanced) lives in quality-tier.js. - const [qualityTier, setQualityTier] = useState(() => - typeof localStorage !== "undefined" - ? parseTier(localStorage.getItem(QUALITY_TIER_KEY)) - : "balanced", - ) + // Web-only quality-latency tier (t055). Durable per-device in server ui-state (t100), seeded on + // open below; setUiState({ qualityTier }) remaps to this device's slot + the global shadow the + // connector reads, then reconnect() applies the tier's jpegQuality/everyNthFrame. Default balanced. + const [qualityTier, setQualityTier] = useState(DEFAULT_TIER) - // Web-only latency HUD toggle (t059). Off by default; persists to localStorage and flips a - // mounted status-bar readout live via setLatencyHudEnabled. Display-only over t057 metrics. + // Web-only latency HUD toggle (t059). Off by default; durable per-device in ui-state (t100), + // seeded on open. setLatencyHudEnabled flips a mounted readout live. Display-only over t057. const [latencyHud, setLatencyHud] = useState(readLatencyHudEnabled) // Virtual-pointer (echo-cursor) visibility mode (t011). Persists server-side via ui-state @@ -352,6 +349,11 @@ export function SettingsDialog({ if (scrollRef.current) scrollRef.current.scrollTop = top }) if (!caps.web) return + // t100: seed the three durable per-device client prefs from ui-state (was localStorage). + setInputTransport((s.inputTransport as InputTransportMode) ?? "auto") + setQualityTier(parseTier(s.qualityTier as string)) + setCurrentTier(s.qualityTier) // keep viewport's resize-reissue mirror in sync + setLatencyHud(!!s.latencyHud) const granted = typeof Notification !== "undefined" && Notification.permission === "granted" setWebPush(!!s.webPush && granted) setPushPermBlocked( @@ -976,9 +978,9 @@ export function SettingsDialog({ )} onClick={() => { setInputTransport(v) - localStorage.setItem("inputTransport", v) - // Apply immediately — the bridge tears down WS or - // re-opens it to match the new pref, no reload needed. + // Durable per-device in ui-state (t100); the transport syncs the + // mode synchronously, then reconfigure tears down/opens WS to match. + window.cdp?.setUiState?.({ inputTransport: v }) window.cdp?.reconfigureInputTransport?.() }} type="button" @@ -1012,9 +1014,9 @@ export function SettingsDialog({ )} onClick={() => { setQualityTier(id) - localStorage.setItem(QUALITY_TIER_KEY, id) - // Mirror into ui-state so the server reads the tier at - // connect, then reconnect to apply the new params. + setCurrentTier(id) // sync viewport's resize-reissue mirror at once + // Durable per-device (t100): setUiState writes this device's slot + // + the global shadow the connector reads; reconnect applies it. window.cdp?.setUiState?.({ qualityTier: id }) window.cdp?.reconnect?.() }} @@ -1043,7 +1045,8 @@ export function SettingsDialog({ className="mt-0.5" onCheckedChange={(on) => { setLatencyHud(on) - setLatencyHudEnabled(on) + setLatencyHudEnabled(on) // flip a mounted HUD live + window.cdp?.setUiState?.({ latencyHud: on }) // durable per-device (t100) }} /> diff --git a/src/components/viewport.tsx b/src/components/viewport.tsx index c6e3daf..fed46bc 100644 --- a/src/components/viewport.tsx +++ b/src/components/viewport.tsx @@ -20,7 +20,7 @@ import { } from "@/lib/echo-cursor" import { isOsReservedKey } from "@/lib/key-routing" import { perfFrame, perfMark } from "@/lib/perf-mark" -import { QUALITY_TIER_KEY, tierParams } from "@/lib/quality-tier" +import { readCurrentTier, tierParams } from "@/lib/quality-tier" import type { RemotePage } from "@/lib/remote-page" import { createTouchGesture, type GestureEvent, LONGPRESS_MS } from "@/lib/touch-gesture" import { drawFrame, type Size, toRemoteCoords } from "@/lib/viewport-transform" @@ -275,10 +275,9 @@ export function Viewport({ const h = Math.min(Math.floor(vp.clientHeight * dpr), 1080) // Preserve the user's quality tier on reissue (t099): hardcoding quality:80 + omitting // everyNthFrame used to silently reset the tier and the t054 rate ceiling on every resize. - // Read the live tier and apply its params (params mirror core/quality-tier.js). - const { jpegQuality, everyNthFrame } = tierParams( - typeof localStorage !== "undefined" ? localStorage.getItem(QUALITY_TIER_KEY) : null, - ) + // Read the live tier mirror (seeded from server ui-state, t100 — the pref no longer lives in + // localStorage) and apply its params (params mirror core/quality-tier.js). + const { jpegQuality, everyNthFrame } = tierParams(readCurrentTier()) window.cdp.send("Page.startScreencast", { format: "jpeg", quality: jpegQuality, diff --git a/src/lib/CLAUDE.md b/src/lib/CLAUDE.md index 5043e64..97cb35a 100644 --- a/src/lib/CLAUDE.md +++ b/src/lib/CLAUDE.md @@ -60,6 +60,8 @@ Domain modules that form the renderer's logic layer, plus a React hook that wire **`notif-mutes.ts`** — Pure per-device notification mute logic (t093, web build). `muteKey(entry)` is the source-mute key: a Slack entry's merged `groupKey` (`slack:{groupId}`, t092; `"slack"` when absent), else the adapter name — so muting is per-workspace for Slack and per-service for Teams/Outlook. `isMuted(mutes, entry)` (mutes is an array or Set; `undefined` = nothing muted, the opt-out default), `toggleMute(mutes, key)` (add/remove, new array, never mutates), and `unreadExcluding(list, mutes, masterOn)` (count of unread entries whose muteKey isn't muted; `0` when the master is off) — the per-device badge number. Mirrors `core/notif-mutes.js` (the server uses the CJS copy for the push gate + per-device unread; the renderer can't import CJS, the server can't import this ESM — same duplication as `core/notifications.js` `slackGroupKey` ↔ this dir). `toggleMute` is renderer-only. Capture stays global; these only gate *delivery* on the device whose ui-state holds the mute. Consumed by `cdp-web-transport.ts` (toast gate), `app.tsx` (badges via `unreadExcluding` + `aggregateUnread`'s `muteOpts`), and the settings card / bell / inbox. Tested by `notif-mutes.test.ts`. +**`device-prefs.ts`** — Pure owner of the three durable per-device client prefs (t100, web build): `qualityTier` / `inputTransport` / `latencyHud`, persisted in server ui-state under `_` (like `notif-mutes`/`webPush`, t093/t095) so they survive an iPad-PWA localStorage wipe instead of resetting. `readDevicePrefs(ui, deviceId)` resolves each key device-slot → (qualityTier only) plain global shadow → default, parse-guarded (`parseTier`/transport-enum/strict-boolean); the qualityTier global fallback IS the migration path (a device with no slot inherits the pre-t100 global value, so nothing resets on first load). `writeDevicePrefs(partial, deviceId)` emits only the present keys' `_` slots, and a `qualityTier` write additionally emits the plain global `qualityTier` **shadow** — the shared-screencast connector (`core/remote-page-connector.js`) reads that global at connect, so mirroring this device's tier into it makes the connecting device's tier apply without any server change (`inputTransport`/`latencyHud` are client-only — the server never reads them, so they touch no global). `deviceKey(base, deviceId)` is the slot-name join. The CJS side (`core/settings-store.js` `DEVICE_KEY_PREFIXES`) keeps its own prefix list — same ESM↔CJS duplication as `notif-mutes.ts` ↔ `core/notif-mutes.js`. Effects live in `cdp-web-transport.ts` (`getUiState` resolves + reconfigures the transport + reconciles the shadow; `setUiState` remaps). Tested by `device-prefs.test.ts`. + **`unread-aggregator.ts`** — Pure unread-count aggregation. `aggregateUnread(notifications, tabs, pins, linkedTabByPin, teamGroupMap?, muteOpts?)` makes one pass over the notification list to build `{ byGroup, byTab, byPin }` count maps. The shared key derivation is `groupKeyForUrl(url)` = `slackGroupKey(url) ?? originOf(url)`: a notification keys by its stamped `groupKey` (else `groupKeyForUrl(targetUrl)`), and a Tab/Pin resolves through the **same** derivation so Slack tabs — which all share the `app.slack.com` origin — bucket by their per-workspace `slack:{teamId}` key (mirrors `core/notifications.js` `slackGroupKey`; one tab per workspace, so the URL is authoritative) instead of merging under one origin badge. Non-Slack apps fall back to origin (unchanged). A Pin resolves through its linked Tab's live URL (via `linkedTabByPin`) or its saved URL. The optional `teamGroupMap` (teamId → groupId, t092) merges Enterprise Grid Slack teams: a Tab/Pin URL's extracted teamId maps to `slack:{groupId}` (`map[teamId] || teamId`) so an org pseudo-team tab and its member workspace share one count; omitted/no-entry → today's per-team behavior. Notifications already carry the merged `groupKey` from the server sweep, so the map only affects Tab/Pin URL resolution. `app.tsx` fetches the map from `/api/notifications/health` (`.groups`, web only). The optional `muteOpts` (`{ mutes, master }`, t093, web only) applies this device's delivery prefs to the tally: a muted source's notifications are skipped in `byGroup` (so byTab/byPin inherit the exclusion via `isMuted` from `notif-mutes.ts`), and `master: false` zeroes every count — keeping the sidebar tab/pin badges honest per device. Omitted = no muting (byte-unchanged; the Electron path passes nothing). The Inbox/bell *lists* read the unfiltered notifications; only the badge counts honor the mute. Replaces the per-origin inline accounting that was scattered across `app.tsx`. **`hotkey-registry.ts`** — the single source of truth shared by the `⌘K` command palette and the `⌘/` shortcut overlay (t058). An `Action` is plain data `{ id, name, group, hotkey?, run }`; `group` is one of the ux.md categories (`OVERLAY_GROUPS`: Global / Tab navigation / Sidebar / Address bar). `buildActions(input)` drops falsy entries (so callers splice conditionally — `caps.localTabs && action` — without pre-filtering) and returns a fresh array preserving registration order. `filterActions(actions, query)` is the palette's case-insensitive substring filter over name + group label (empty query → unchanged list; no match → []). `groupForOverlay(actions)` partitions into the ux.md categories for the overlay, keeping only actions that carry a hotkey (the overlay is a shortcut reference). `hotkeyHint(action)` reports the display string or undefined. Pure: no React, no IPC, no DOM, no `window` — `app.tsx` injects every `run` (each points at the *existing* keydown-switch / toolbar handler, so the palette is presentation, never a second copy of effect logic). Tested by `hotkey-registry.test.ts`. @@ -72,7 +74,7 @@ Domain modules that form the renderer's logic layer, plus a React hook that wire **`caps.ts`** — Build capabilities, the single source of truth for "what can this build do." `WebCaps` is `{ web, localTabs, extensions }`; `getCaps(read?)` returns the injected/`window.webCaps` caps (web: the restricted `DEFAULT_CAPS`, `localTabs`/`extensions` off) or the implicit Electron full-capability default when `window.webCaps` is absent. `read` is injectable for tests; the only DOM coupling is the thin default reader. Consumed by `cdp-web-transport.ts` (which re-exports it for back-compat), `sidebar.tsx`, `settings-dialog.tsx`, `app.tsx`, and the `useLocalTabs` gate hook. See `docs/conventions/feature-gates.md`. -**`quality-tier.ts`** — Renderer-facing half of the Sharp/Balanced/Snappy quality-latency tier (t055, web only). Owns the `QualityTier` id type, the `QUALITY_TIERS` option list (id/label/tip) the Settings picker renders, the `QUALITY_TIER_KEY` localStorage key, and `parseTier(raw)` (garbage/null/wrong-case → `DEFAULT_TIER`). The server owns the screencast params on the connect path (`core/quality-tier.js`, ADR-0008). One renderer exception: `tierParams(tier)` mirrors those `{ jpegQuality, everyNthFrame }` numbers **only** for the resize reissue in `viewport.tsx` (t099) — a resize re-issues `Page.startScreencast` from the renderer, and it must carry the user's tier (quality + the t054 `everyNthFrame` rate ceiling) instead of resetting to a hardcoded default. `quality-tier.test.ts` asserts `tierParams` matches core exactly. Mirrors how `transport-selector.ts` owns the `InputTransportMode` ids for the t019 picker. Pure. +**`quality-tier.ts`** — Renderer-facing half of the Sharp/Balanced/Snappy quality-latency tier (t055, web only). Owns the `QualityTier` id type, the `QUALITY_TIERS` option list (id/label/tip) the Settings picker renders, and `parseTier(raw)` (garbage/null/wrong-case → `DEFAULT_TIER`). The server owns the screencast params on the connect path (`core/quality-tier.js`, ADR-0008). One renderer exception: `tierParams(tier)` mirrors those `{ jpegQuality, everyNthFrame }` numbers **only** for the resize reissue in `viewport.tsx` (t099) — a resize re-issues `Page.startScreencast` from the renderer, and it must carry the user's tier (quality + the t054 `everyNthFrame` rate ceiling) instead of resetting to a hardcoded default. `quality-tier.test.ts` asserts `tierParams` matches core exactly. Also holds a **live in-memory tier mirror** (`readCurrentTier`/`setCurrentTier`, t100) that `viewport.tsx`'s synchronous resize reissue reads — the durable tier now lives in server ui-state (per-device, `device-prefs.ts`), not the removed `localStorage` key, so `app.tsx` seeds this mirror at boot and the Settings picker updates it on change (same live-mirror shape as `latency-hud.tsx`; without it a resize would read an empty localStorage and reset to balanced — the exact t099 regression). Mirrors how `transport-selector.ts` owns the `InputTransportMode` ids for the t019 picker. Pure functions + the one tier-mirror singleton (not the source of truth — server ui-state is). **`echo-cursor.ts`** — Pure model for the viewport's optimistic local-input echo overlay (t052). `createEchoCursor()` is a state machine over `{ point, pressing }`; `down(pt)` records the press position + sets pressing, `move(pt)` updates position, `up()` clears pressing. `pointForEvent(e, rect, dpr)` maps a `PointerEvent` to canvas-relative coords (DPR-unscaled). No React, no DOM; `viewport.tsx` holds the instance, calls these on pointer events, and renders the overlay from the snapshot. Tested by `echo-cursor.test.ts`. @@ -154,6 +156,7 @@ The transport is split into three named seams assembled by a thin shim: - Tab Lifecycle (`tab-lifecycle.ts`) is pure — returns directives, never executes them. `app.tsx` applies every effect (close the target, swap the active surface, push the Closed Tabs entry, revert the Pin, persist). - Unread Aggregator (`unread-aggregator.ts`) is pure — `aggregateUnread` is a plain function over data. `app.tsx` supplies `linkedTabByPin` and holds the result in state. The optional `muteOpts` arg is web-only (Electron passes nothing → byte-unchanged). - Notif Mutes (`notif-mutes.ts`) is pure — no I/O, no React, no DOM. `muteKey`/`isMuted`/`unreadExcluding` mirror `core/notif-mutes.js` byte-for-byte (the server copy gates push + per-device unread; `toggleMute` is renderer-only). Capture is global; these gate delivery only. `app.tsx` owns the device-keyed ui-state writes (through the transport remap seam); the toast gate lives in `cdp-web-transport.ts`. +- Device Prefs (`device-prefs.ts`) is pure — no I/O, no React, no DOM. `readDevicePrefs`/`writeDevicePrefs`/`deviceKey` are plain functions over a ui-state snapshot. The qualityTier global shadow is the ONLY place that couples a per-device pref to a global key (so the shared-screencast connector applies the connecting device's tier); `inputTransport`/`latencyHud` never touch a global. Effects (the reconfigure on transport-mode change, the shadow reconcile) live in `cdp-web-transport.ts`. - Find Bar (`find-bar.ts`) is pure — `reduce`/`counterLabel` are a state machine over data, no `window.find`/DOM/transport. `find-bar.tsx` enacts the remote search (`page.find`/`findStep`/`clearFind`) and feeds `total` back via `setTotal`. - Notification Activation (`notification-activation.ts`) is pure — no Remote Page reference, no IPC. `app.tsx` calls `page[method](arg)` after resolving the intention. - Hotkey Registry (`hotkey-registry.ts`) is pure — `buildActions`/`filterActions`/`groupForOverlay` are plain functions over data, no React/IPC/DOM/`window`. `app.tsx` injects each action's `run` (pointing at the existing handler) and holds the list in a memo; the palette and overlay render it. diff --git a/src/lib/cdp-web-transport.characterization.test.ts b/src/lib/cdp-web-transport.characterization.test.ts index 6e41563..628a176 100644 --- a/src/lib/cdp-web-transport.characterization.test.ts +++ b/src/lib/cdp-web-transport.characterization.test.ts @@ -576,15 +576,11 @@ describe("batch routing", () => { it("inputTransport=batch pins the single-flight POST fallback even after a stream ack", () => { const { fetchFn, calls } = makeFakeFetch() - const cdp = createWebCdp( - baseDeps({ - fetch: fetchFn as unknown as typeof fetch, - localStorage: { - getItem: (k) => (k === "inputTransport" ? "batch" : null), - setItem: () => {}, - }, - }), - ) + const cdp = createWebCdp(baseDeps({ fetch: fetchFn as unknown as typeof fetch })) + // The durable pref lives in server ui-state now (t100); the picker writes it and applies it — + // setUiState syncs storedTransport synchronously, reconfigureInputTransport() pins the mode. + cdp.setUiState({ inputTransport: "batch" }) + cdp.reconfigureInputTransport?.() // Simulate the streaming channel acking (stream-ack over SSE) — in any other mode this // would route subsequent batches through the (already-open) stream instead of POST. FakeEventSource.instances[0].emit("stream-ack", "") @@ -780,13 +776,9 @@ describe("visible-tab WS re-climb", () => { installDoc(fd) const timers = fakeTimers() const { fetchFn } = makeFakeFetch() - createWebCdp( + const cdp = createWebCdp( baseDeps({ fetch: fetchFn as unknown as typeof fetch, - localStorage: { - getItem: (k) => (k === "inputTransport" ? "batch" : null), - setItem: () => {}, - }, setTimer: timers.setTimer as unknown as ( cb: () => void, ms: number, @@ -794,12 +786,14 @@ describe("visible-tab WS re-climb", () => { clearTimer: timers.clearTimer, }), ) - // Basic never opens WS, so there's nothing to drop — but a foreground transition must not - // arm a re-climb against the manual non-WS pick. + // The durable pref lives in server ui-state now (t100), so the transport boots at auto and + // pins Basic once the picker applies it — setUiState + reconfigureInputTransport() tears down + // any boot WS and cancels the re-climb, exactly the settings-picker flow. + cdp.setUiState({ inputTransport: "batch" }) + cdp.reconfigureInputTransport?.() + // A foreground transition must not arm a re-climb against the manual non-WS pick. fd.setVisibility("hidden") fd.setVisibility("visible") expect(timers.pendingCount()).toBe(0) - // And no WS socket was ever opened. - expect(FakeWebSocket.instances.length).toBe(0) }) }) diff --git a/src/lib/cdp-web-transport.ts b/src/lib/cdp-web-transport.ts index 884f2eb..1a27b04 100644 --- a/src/lib/cdp-web-transport.ts +++ b/src/lib/cdp-web-transport.ts @@ -14,6 +14,7 @@ import { DEFAULT_CAPS, getCaps, type WebCaps } from "./caps" import { type CryptoContext, createCryptoContext } from "./crypto-context" import { deriveKey as envDeriveKey, open as envOpen } from "./crypto-envelope" +import { type DevicePrefs, readDevicePrefs, writeDevicePrefs } from "./device-prefs" import { createDownlink, createDownlinkDispatcher, @@ -227,18 +228,13 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge // router (022) and the unit tests drive. createDownlink(dispatcher, downlinkSource) - // Mode selection (t019). User pref from localStorage controls what's attempted; the - // actual active mode is derived from runtime state (wsReady? streamReady? else batch). - // The picker writes the pref and triggers reconfigureMode() to apply mid-session. - const VALID_MODES: InputTransportMode[] = ["auto", "ws", "stream", "batch"] - function readMode(): InputTransportMode { - if (!deps.localStorage) return "auto" - const raw = deps.localStorage.getItem("inputTransport") - // Validate against the union — a stale or hand-edited value falls back to auto rather - // than silently shaping subsequent branches (e.g. wsAllowed) with garbage. - return raw && (VALID_MODES as string[]).includes(raw) ? (raw as InputTransportMode) : "auto" - } - let wantMode: InputTransportMode = readMode() + // Mode selection (t019/t100). The durable per-device transport pref lives in server ui-state + // now, not localStorage — `getUiState()` syncs it into `storedTransport` on first load and + // every picker write (`setUiState`) syncs it synchronously, so it survives an iPad-PWA storage + // wipe. Boots at the safe default (auto) until ui-state lands, then reconfigures once. The + // actual active mode is still derived from runtime state (wsReady? streamReady? else batch). + let storedTransport: InputTransportMode = "auto" + let wantMode: InputTransportMode = storedTransport const selector = createTransportSelector({ cache: deps.localStorage ?? { getItem: () => null, setItem: () => {} }, }) @@ -630,7 +626,7 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge // closes WS to match, and pokes the active-mode signal. The streaming channel stays // alive across switches — cheap, and lets a flip back to auto/stream resume instantly. function reconfigureMode() { - wantMode = readMode() + wantMode = storedTransport if (wantMode === "auto" || wantMode === "ws") { selector.fallbackToAuto() // clears any manual error state from a prior pick if (wantMode === "ws") selector.setManualMode("ws") @@ -813,11 +809,31 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge webPush = !!ui[webPushKey] notifMaster = ui[masterKey] === undefined ? true : !!ui[masterKey] notifMutes = Array.isArray(ui[mutesKey]) ? (ui[mutesKey] as string[]) : [] + // t100 durable per-device client prefs. Resolve this device's slots (with the qualityTier + // global-shadow fallback) and surface them under the plain names the pickers read. + const prefs = readDevicePrefs(ui as Record, deviceId) + // Adopt the durable transport mode and reconfigure once if it differs from what we booted + // at (auto). Idempotent on later reads (same value → no-op), so opening Settings won't churn. + if (prefs.inputTransport !== storedTransport) { + storedTransport = prefs.inputTransport + reconfigureMode() + } + // qualityTier global shadow: the shared-screencast connector reads the plain global key at + // connect, so make it reflect THIS device's tier for its next (re)connect. Write only on a + // real divergence (another device set the global last) — a no-op in the single-device case. + // Writes the plain global only — this device's own `qualityTier_` slot (if any) + // is untouched, so a device still on the migration fallback stays on it. + if (ui.qualityTier !== prefs.qualityTier) { + void rest.postJson("/api/ui-state", { qualityTier: prefs.qualityTier }) + } return { ...ui, webPush, notificationsEnabled: notifMaster, notifMutes, + qualityTier: prefs.qualityTier, + inputTransport: prefs.inputTransport, + latencyHud: prefs.latencyHud, } }, setUiState: (partial) => { @@ -841,6 +857,23 @@ export function createWebCdp(deps: WebTransportDeps = resolveDeps()): CdpBridge delete toSend.notifMutes notifMutes = Array.isArray(p.notifMutes) ? (p.notifMutes as string[]) : [] } + // t100: remap the three per-device client prefs to their `_` slots (+ the + // qualityTier global shadow) via the pure device-prefs owner, and drop the client-only + // plain keys so they never leak into a global. storedTransport is synced synchronously so + // the picker's following reconfigureInputTransport() reads the new mode. + const devPartial: Partial = {} + if ("qualityTier" in partial) + devPartial.qualityTier = p.qualityTier as DevicePrefs["qualityTier"] + if ("inputTransport" in partial) + devPartial.inputTransport = p.inputTransport as DevicePrefs["inputTransport"] + if ("latencyHud" in partial) devPartial.latencyHud = p.latencyHud as DevicePrefs["latencyHud"] + Object.assign(toSend, writeDevicePrefs(devPartial, deviceId)) + if ("inputTransport" in partial) { + delete toSend.inputTransport // client-only: server never reads it, don't write a global + storedTransport = devPartial.inputTransport as InputTransportMode + } + if ("latencyHud" in partial) delete toSend.latencyHud // client-only; the slot carries it + // The plain `qualityTier` stays: writeDevicePrefs set it to the intended global shadow. return rest.postJson("/api/ui-state", toSend) }, setThemeSource: async (source) => { diff --git a/src/lib/device-prefs.test.ts b/src/lib/device-prefs.test.ts new file mode 100644 index 0000000..e873f6a --- /dev/null +++ b/src/lib/device-prefs.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest" +import { + DEFAULT_DEVICE_PREFS, + DEVICE_PREF_BASES, + deviceKey, + readDevicePrefs, + writeDevicePrefs, +} from "./device-prefs" + +const D1 = "device_aaa_1" +const D2 = "device_bbb_2" + +describe("deviceKey", () => { + it("joins base and deviceId with an underscore", () => { + expect(deviceKey("qualityTier", D1)).toBe("qualityTier_device_aaa_1") + expect(deviceKey("latencyHud", D2)).toBe("latencyHud_device_bbb_2") + }) +}) + +describe("readDevicePrefs — device slot wins", () => { + it("prefers the device slot over the global qualityTier", () => { + const ui = { qualityTier_device_aaa_1: "snappy", qualityTier: "sharp" } + expect(readDevicePrefs(ui, D1).qualityTier).toBe("snappy") + }) + + it("reads inputTransport and latencyHud from their device slots", () => { + const ui = { inputTransport_device_aaa_1: "batch", latencyHud_device_aaa_1: true } + const prefs = readDevicePrefs(ui, D1) + expect(prefs.inputTransport).toBe("batch") + expect(prefs.latencyHud).toBe(true) + }) +}) + +describe("readDevicePrefs — qualityTier fallback (migration path)", () => { + it("falls back to the global qualityTier when this device has no slot", () => { + expect(readDevicePrefs({ qualityTier: "sharp" }, D1).qualityTier).toBe("sharp") + }) + + it("falls back to the balanced default when neither slot nor global is set", () => { + expect(readDevicePrefs({}, D1).qualityTier).toBe("balanced") + }) +}) + +describe("readDevicePrefs — defaults for the client-only prefs", () => { + it("defaults inputTransport to auto and latencyHud to false when unset", () => { + const prefs = readDevicePrefs({}, D1) + expect(prefs.inputTransport).toBe("auto") + expect(prefs.latencyHud).toBe(false) + }) + + it("matches DEFAULT_DEVICE_PREFS on an empty ui-state", () => { + expect(readDevicePrefs({}, D1)).toEqual(DEFAULT_DEVICE_PREFS) + }) +}) + +describe("readDevicePrefs — garbage degrades to defaults", () => { + it("resolves out-of-enum / wrong-type values to their defaults", () => { + const ui = { + qualityTier_device_aaa_1: "ultra", + inputTransport_device_aaa_1: "carrier-pigeon", + latencyHud_device_aaa_1: "yes", + } + expect(readDevicePrefs(ui, D1)).toEqual(DEFAULT_DEVICE_PREFS) + }) + + it("treats latencyHud as true only for the boolean true (not 1 / '1' / 'true')", () => { + expect(readDevicePrefs({ latencyHud_device_aaa_1: 1 }, D1).latencyHud).toBe(false) + expect(readDevicePrefs({ latencyHud_device_aaa_1: "1" }, D1).latencyHud).toBe(false) + expect(readDevicePrefs({ latencyHud_device_aaa_1: "true" }, D1).latencyHud).toBe(false) + expect(readDevicePrefs({ latencyHud_device_aaa_1: false }, D1).latencyHud).toBe(false) + expect(readDevicePrefs({ latencyHud_device_aaa_1: true }, D1).latencyHud).toBe(true) + }) +}) + +describe("readDevicePrefs — deviceId isolation", () => { + it("reads a different slot per deviceId", () => { + const ui = { + inputTransport_device_aaa_1: "ws", + inputTransport_device_bbb_2: "batch", + qualityTier_device_aaa_1: "sharp", + qualityTier_device_bbb_2: "snappy", + } + expect(readDevicePrefs(ui, D1).inputTransport).toBe("ws") + expect(readDevicePrefs(ui, D2).inputTransport).toBe("batch") + expect(readDevicePrefs(ui, D1).qualityTier).toBe("sharp") + expect(readDevicePrefs(ui, D2).qualityTier).toBe("snappy") + }) +}) + +describe("writeDevicePrefs — emits only present keys, to device slots", () => { + it("writes a single slot for a lone inputTransport change (no global)", () => { + expect(writeDevicePrefs({ inputTransport: "ws" }, D1)).toEqual({ + inputTransport_device_aaa_1: "ws", + }) + }) + + it("returns an empty object for an empty partial", () => { + expect(writeDevicePrefs({}, D1)).toEqual({}) + }) + + it("emits latencyHud false (a present key), not just true", () => { + expect(writeDevicePrefs({ latencyHud: false }, D1)).toEqual({ + latencyHud_device_aaa_1: false, + }) + }) +}) + +describe("writeDevicePrefs — qualityTier global shadow", () => { + it("emits the device slot AND the plain global qualityTier shadow", () => { + expect(writeDevicePrefs({ qualityTier: "snappy" }, D1)).toEqual({ + qualityTier_device_aaa_1: "snappy", + qualityTier: "snappy", + }) + }) + + it("does NOT emit any global key for inputTransport or latencyHud", () => { + const out = writeDevicePrefs({ inputTransport: "batch", latencyHud: true }, D1) + expect(out).not.toHaveProperty("qualityTier") + expect(out).not.toHaveProperty("inputTransport") + expect(out).not.toHaveProperty("latencyHud") + }) + + it("emits all four keys when all three prefs change", () => { + expect( + writeDevicePrefs({ qualityTier: "sharp", inputTransport: "batch", latencyHud: true }, D1), + ).toEqual({ + qualityTier_device_aaa_1: "sharp", + qualityTier: "sharp", + inputTransport_device_aaa_1: "batch", + latencyHud_device_aaa_1: true, + }) + }) +}) + +describe("module constants", () => { + it("exposes the three device-pref base names", () => { + expect([...DEVICE_PREF_BASES]).toEqual(["qualityTier", "inputTransport", "latencyHud"]) + }) +}) diff --git a/src/lib/device-prefs.ts b/src/lib/device-prefs.ts new file mode 100644 index 0000000..b234b27 --- /dev/null +++ b/src/lib/device-prefs.ts @@ -0,0 +1,82 @@ +// Per-device client prefs (t100). Three web-only prefs — the screencast quality tier, the input +// transport mode, and the latency HUD toggle — persist per device in server ui-state under +// `_` (like webPush/notifMutes, t093/t095) so they survive an iPad-PWA storage +// wipe instead of resetting with localStorage. This pure module is the single owner of the +// plain <-> device-key remap, the defaults, the parse-guards, and the qualityTier "global shadow" +// rule; the effectful callers (cdp-web-transport.ts getUiState/setUiState, settings-dialog.tsx) +// stay thin. The CJS side (core/settings-store.js DEVICE_KEY_PREFIXES) keeps its own prefix list +// — the same ESM<->CJS duplication as notif-mutes.ts <-> core/notif-mutes.js. + +import { DEFAULT_TIER, parseTier, type QualityTier } from "./quality-tier" +import type { InputTransportMode } from "./transport-selector" + +export type DevicePrefs = { + qualityTier: QualityTier + inputTransport: InputTransportMode + latencyHud: boolean +} + +// The ui-state base key names, in Settings-display order. Each persists as `_`. +export const DEVICE_PREF_BASES = ["qualityTier", "inputTransport", "latencyHud"] as const + +export const DEFAULT_DEVICE_PREFS: DevicePrefs = { + qualityTier: DEFAULT_TIER, + inputTransport: "auto", + latencyHud: false, +} + +const TRANSPORT_MODES: InputTransportMode[] = ["auto", "ws", "stream", "batch"] + +// Unknown / wrong-type transport → auto (a stale or hand-edited value must not shape wsAllowed). +function parseTransport(raw: unknown): InputTransportMode { + return typeof raw === "string" && (TRANSPORT_MODES as string[]).includes(raw) + ? (raw as InputTransportMode) + : "auto" +} + +// Strictly the boolean true — a persisted string/number "1" is not a truthy HUD flag. +function parseHud(raw: unknown): boolean { + return raw === true +} + +export function deviceKey(base: string, deviceId: string): string { + return `${base}_${deviceId}` +} + +// Resolve this device's client prefs from a ui-state snapshot. Precedence per key: +// device slot (`_`) -> [qualityTier only] plain global shadow -> default. +// The qualityTier global fallback IS the migration path: a device with no slot inherits the +// pre-t100 global value (then balanced), so nothing resets on first load after ship. +export function readDevicePrefs(ui: Record, deviceId: string): DevicePrefs { + const qtSlot = ui[deviceKey("qualityTier", deviceId)] + return { + qualityTier: parseTier( + (qtSlot !== undefined ? qtSlot : ui.qualityTier) as string | null | undefined, + ), + inputTransport: parseTransport(ui[deviceKey("inputTransport", deviceId)]), + latencyHud: parseHud(ui[deviceKey("latencyHud", deviceId)]), + } +} + +// Build the ui-state partial to POST for a per-device pref change. Emits only the keys present in +// `partial`, each to its `_` slot. A qualityTier write ALSO emits the plain global +// `qualityTier` shadow so the shared-screencast connector (core/remote-page-connector.js) applies +// this device's tier on the next (re)connect — no server change needed. inputTransport/latencyHud +// are client-only (the server never reads them), so they touch no global key. +export function writeDevicePrefs( + partial: Partial, + deviceId: string, +): Record { + const out: Record = {} + if (partial.qualityTier !== undefined) { + out[deviceKey("qualityTier", deviceId)] = partial.qualityTier + out.qualityTier = partial.qualityTier // global shadow (the connector reads this) + } + if (partial.inputTransport !== undefined) { + out[deviceKey("inputTransport", deviceId)] = partial.inputTransport + } + if (partial.latencyHud !== undefined) { + out[deviceKey("latencyHud", deviceId)] = partial.latencyHud + } + return out +} diff --git a/src/lib/quality-tier.test.ts b/src/lib/quality-tier.test.ts index 1fbd2eb..205fb6b 100644 --- a/src/lib/quality-tier.test.ts +++ b/src/lib/quality-tier.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest" // @ts-expect-error — CJS module, no types import core from "../../core/quality-tier.js" -import { DEFAULT_TIER, parseTier, QUALITY_TIERS, tierParams } from "./quality-tier" +import { + DEFAULT_TIER, + parseTier, + QUALITY_TIERS, + readCurrentTier, + setCurrentTier, + tierParams, +} from "./quality-tier" describe("parseTier", () => { it("passes through known ids and defaults garbage", () => { @@ -28,3 +35,26 @@ describe("tierParams", () => { } }) }) + +describe("currentTier mirror (t100)", () => { + it("defaults to the balanced tier before any seed", () => { + // Note: module singleton — this asserts the initial value; other cases set it explicitly. + setCurrentTier(DEFAULT_TIER) + expect(readCurrentTier()).toBe(DEFAULT_TIER) + }) + + it("stores a seeded tier and reads it back for the resize reissue", () => { + setCurrentTier("snappy") + expect(readCurrentTier()).toBe("snappy") + expect(tierParams(readCurrentTier())).toEqual({ jpegQuality: 60, everyNthFrame: 3 }) + }) + + it("parse-guards a garbage / null seed to the default", () => { + setCurrentTier("sharp") + setCurrentTier("garbage") + expect(readCurrentTier()).toBe(DEFAULT_TIER) + setCurrentTier("sharp") + setCurrentTier(null) + expect(readCurrentTier()).toBe(DEFAULT_TIER) + }) +}) diff --git a/src/lib/quality-tier.ts b/src/lib/quality-tier.ts index 3cd8f85..df96f33 100644 --- a/src/lib/quality-tier.ts +++ b/src/lib/quality-tier.ts @@ -2,14 +2,12 @@ // (jpegQuality + everyNthFrame) live in the root CJS `quality-tier.js`, the single owner // read by both backends (remote-page-connector.js + main.js) per ADR-0008 — the renderer // never applies them, the server does. This module owns only what the web-only Settings -// picker needs: the tier id type, the option list shown in the 2x2-style toggle, the -// localStorage key, and a parse-with-fallback for reading the stored pref back on mount. +// picker needs: the tier id type, the option list shown in the 2x2-style toggle, a +// parse-with-fallback, and a live in-memory mirror of the active tier for the resize reissue. // Mirrors how transport-selector.ts owns the InputTransportMode ids for the t019 picker. export type QualityTier = "sharp" | "balanced" | "snappy" -export const QUALITY_TIER_KEY = "qualityTier" - export const DEFAULT_TIER: QualityTier = "balanced" // Sharp → Balanced → Snappy, the same order and ids the root mapping uses. Labels/tips are @@ -52,3 +50,19 @@ export function parseTier(raw: string | null | undefined): QualityTier { ? (raw as QualityTier) : DEFAULT_TIER } + +// Live in-memory mirror of this device's active tier (t100). The screencast resize reissue in +// viewport.tsx needs the tier synchronously, but the durable value lives in server ui-state now +// (not localStorage, which resets on the iPad PWA) — so app.tsx seeds this at boot from ui-state +// and the Settings picker updates it on change. Same live-mirror shape as latency-hud.tsx's flag; +// without it, a resize would read a now-empty localStorage and reset the tier to balanced (the +// exact t099 regression). Not the source of truth — server ui-state is; this is a sync read cache. +let currentTier: QualityTier = DEFAULT_TIER + +export function readCurrentTier(): QualityTier { + return currentTier +} + +export function setCurrentTier(raw: string | null | undefined): void { + currentTier = parseTier(raw) +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 76ba57e..8c0a168 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -92,6 +92,10 @@ interface CdpBridge { * transport surfaces this device's `notifMutes_` slot under this plain name. */ notifMutes: string[] qualityTier: "sharp" | "balanced" | "snappy" + // Per-device durable client prefs (t100, web only) — surfaced under plain names for this + // device; persisted as `_` in server ui-state. + inputTransport: "auto" | "ws" | "stream" | "batch" + latencyHud: boolean virtualPointerMode: "off" | "on" | "auto" settingsScrollTop: number slackExcludes: { team: string; channelId: string; label: string }[] @@ -111,6 +115,10 @@ interface CdpBridge { /** Per-device muted sources (t093) — remapped to `notifMutes_` on web. */ notifMutes: string[] qualityTier: "sharp" | "balanced" | "snappy" + // Per-device durable client prefs (t100, web only) — remapped to `_` slots + // (+ the qualityTier global shadow) by the web transport before POST. + inputTransport: "auto" | "ws" | "stream" | "batch" + latencyHud: boolean virtualPointerMode: "off" | "on" | "auto" settingsScrollTop: number slackExcludes: { team: string; channelId: string; label: string }[] diff --git a/test/e2e/server.e2e.test.ts b/test/e2e/server.e2e.test.ts index 4b285ab..f9bb192 100644 --- a/test/e2e/server.e2e.test.ts +++ b/test/e2e/server.e2e.test.ts @@ -793,6 +793,42 @@ describe("group clear — remove notifications by id (t085)", () => { }) // ───────────────────────────────────────────────────────────────────────────── +describe("per-device client prefs — ui-state round-trip (t100)", () => { + let fake: any + let server: any + + beforeEach(async () => { + fake = await startFakeCdpHost({ targets: DEFAULT_TARGETS }) + server = await startWebServer(fake) + }) + afterEach(async () => { + server.stop() + await fake.stop() + }) + + it("persists the device-keyed quality tier / transport / HUD slots and the qualityTier shadow", async () => { + await server.post("/api/ui-state", { + qualityTier_dev1: "snappy", + qualityTier: "snappy", // the global shadow the screencast connector reads + inputTransport_dev1: "batch", + latencyHud_dev1: true, + }) + const ui = await server.json("/api/ui-state") + expect(ui.qualityTier_dev1).toBe("snappy") + expect(ui.qualityTier).toBe("snappy") + expect(ui.inputTransport_dev1).toBe("batch") + expect(ui.latencyHud_dev1).toBe(true) + }) + + it("keeps a second device's transport slot independent of the first", async () => { + await server.post("/api/ui-state", { inputTransport_dev1: "ws" }) + await server.post("/api/ui-state", { inputTransport_dev2: "batch" }) + const ui = await server.json("/api/ui-state") + expect(ui.inputTransport_dev1).toBe("ws") + expect(ui.inputTransport_dev2).toBe("batch") + }) +}) + describe("push subscription reconcile (E0 — endpoint-keyed deviceId)", () => { let fake: any let server: any