Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<deviceId>`, 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 `<base>_<deviceId>` (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 `<webview>`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 `<webview>` 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`.
Expand Down Expand Up @@ -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 → <base>_<deviceId> + 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)
Expand Down
10 changes: 9 additions & 1 deletion core/settings-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions core/settings-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
4 changes: 2 additions & 2 deletions docs/conventions/docs-discipline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down Expand Up @@ -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_
Loading