diff --git a/.agents/skills/verify-deepseekbot/features/README.md b/.agents/skills/verify-deepseekbot/features/README.md index 9e5121f7e9..0190c66142 100644 --- a/.agents/skills/verify-deepseekbot/features/README.md +++ b/.agents/skills/verify-deepseekbot/features/README.md @@ -4,7 +4,7 @@ | --- | --- | --- | --- | | Bot Marketplace cards, filters, remembered sorting and upload/import | no | `verify-app` Bot mode path | [marketplace.md](marketplace.md) | | Connect encrypted pairing, resources and native private input | yes for DM/tool/private request | `verify-app` isolated Host and shared native QA | [connect-channel.md](connect-channel.md) | -| Browser Remote Host pairing, icon strip, Host Feed and Host-level settings | only for a reply | `verify-app` two-Host path | [remote-hosts.md](remote-hosts.md) | +| Browser Remote Host pairing, icon strip, local appearance/notifications and Host-level settings | only for a reply | `verify-app` two-Host path | [remote-hosts.md](remote-hosts.md) | | VRM companion Panel | yes for speaking | `scripts/e2e-vrm-companion.mjs` | [vrm-companion.md](vrm-companion.md) | | Desktop whole-window zoom (controlled S1 / Windows candidate) | no | `scripts/e2e-desktop-zoom-launch.mjs`, `scripts/e2e-desktop-zoom-windows.mjs`, `scripts/e2e-dock-zoom.mjs`, `scripts/e2e-workspace-chrome.mjs` | [desktop-window-zoom.md](desktop-window-zoom.md) | @@ -100,4 +100,3 @@ Historical failures from 2026-10-14 (not requalified by this audit): | Three-action tool approval and coherent Full Access | yes | `scripts/e2e-tool-approval-simple.mjs` | [tool-approval-simple.md](tool-approval-simple.md) | | Workspace request read/write approval and original-task continuation | yes | `scripts/e2e-workspace-request-write.mjs`, `scripts/e2e-mobile-folder-browse-web.mjs` | [workspace-request-write.md](workspace-request-write.md) | - diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 722b5bc8f8..c26bcb78ab 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -16,8 +16,9 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu - `secret-route`: pinned Connect pairings carry private input, resources and logical streams through Noise; legacy pairings fail closed. The browser must be served by its own trusted Host. Loopback/Tailscale retain their contract. - `viewers`: a remote Bot DM opens Browser or Computer when that Bot has access. The shared Host viewer modules read frames and send input to that Host only; closing, switching source or losing the connection tears down the viewer and releases requested control. - `viewer-control`: queued input belongs to one takeover. Resume or frame loss invalidates it; frame recovery remains read-only even when the Host Feed never disconnected. Remove closes viewers and waits up to five seconds for takeover/release cleanup before revoking the pairing. -- `settings`: right-click a Remote Host icon → Remote settings opens that Host's models, usage and storage. Default model and preset changes use its shared Host client. Usage includes Memory activity, without a link to a This Host Bot page. Storage retains preview, acknowledgement and destructive confirmation. -- `notifications`: right-click a Remote Host icon and choose Remote notifications; enable browser permission explicitly and choose Tool approvals, Questions, Private input requests, Mentions & replies. Choices survive reload in this browser only. Verify the initial Inbox and reconnect backlog are silent, a new permitted Host attention item alerts once, disabled kinds stay silent and removing the Host closes its alerts. Private input text is never shown. Capture the new entry and dialog in light/dark; page-open API evidence does not qualify closed-page push or Desktop. +- `host-appearance`: Host settings → Appearance edits the device-local name, symbol/emoji and icon colour through the shared HostLooks owner. Enter or blur saves the name; choices apply immediately. Tabs, conversation/Activity headings and new notification titles use the saved display name. Empty name restores the original. +- `settings`: right-click a Remote Host icon → Host settings opens Appearance, Notifications, Models, Usage and Storage with the Host icon and display name. Default model and preset changes use its shared Host client. Usage includes Memory activity, without a link to a This Host Bot page. Storage retains preview, acknowledgement and destructive confirmation. +- `notifications`: open Host settings → Notifications; enable browser permission explicitly and choose Tool approvals, Questions, Private input requests, Mentions & replies. Choices survive reload in this browser only. Verify the initial Inbox and reconnect backlog are silent, a new permitted Host attention item alerts once, disabled kinds stay silent and removing the Host closes its alerts. Private input text is never shown. Capture the new entry and dialog in light/dark; page-open API evidence does not qualify closed-page push or Desktop. Discovery uses the Host's notification query, not the Activity display window. Keep more than 30 old mentions and produce a new ordinary DM reply and a non-mention group reply; both must alert once when enabled. Traverse `deepseekbot inbox --notifications` cursors and compare the Host-decided items. Verify stable same-timestamp pagination, a live invalidation during paging, a partial-page failure followed by recovery, and no historical replay. A refused Notification constructor records a bounded redacted `remote-notification-show-*` diagnostic through the existing Client diagnostics surface. Settings apply immediately and close through the normal close control. @@ -41,7 +42,7 @@ For control races, delay an input response, queue text, Resume, then take contro Keep the remote screen stationary during frame recovery. The Host must keep its sequence unchanged and answer matching `after` polls with 204 after ten seconds; the viewer recovers by fetching the current frame without that stale cursor, not by requiring a screen change. In both UI languages, inject a frame/input failure: the Web panel must show localized recovery guidance and a reconnect action, while the viewer snapshot retains the original diagnostic notice rather than rendering it as product copy. -For Remote settings, use two disposable Hosts with different home basenames and distinguishable model presets and chat data. Open Host B's icon menu in Host A's browser. Capture the menu and all three settings sections in light and dark. Change B's default model or a preset, then read both Hosts with `model-catalog` and `model-presets`; A must be unchanged. Compare Usage and Memory with B's `overview-usage` and `overview-memory`. In Storage, select a disposable chat, preview media or whole messages, verify Clear/Delete is disabled before acknowledgement, then confirm. Read B's storage and timeline and verify A's data is unchanged. Exercise both cleanup targets; use their matching CLI preview/confirm commands against B as well. Disconnect B and revoke its browser pairing in separate runs: settings must show an unavailable state or refusal, with no local data fallback or replayed write. Never clean up real conversations for this journey. +For Remote settings, use two disposable Hosts with different home basenames and distinguishable model presets and chat data. Open Host B's icon menu in Host A's browser. Capture the menu and all five settings sections in light and dark. Change B's default model or a preset, then read both Hosts with `model-catalog` and `model-presets`; A must be unchanged. Compare Usage and Memory with B's `overview-usage` and `overview-memory`. In Storage, select a disposable chat, preview media or whole messages, verify Clear/Delete is disabled before acknowledgement, then confirm. Read B's storage and timeline and verify A's data is unchanged. Exercise both cleanup targets; use their matching CLI preview/confirm commands against B as well. Disconnect B and revoke its browser pairing in separate runs: settings must show an unavailable state or refusal, with no local data fallback or replayed write. Never clean up real conversations for this journey. The default-model proof must change the model value on a Profile whose native settings owner can save it; a same-value no-op is insufficient. Keep a page's observed revision, change the default from another client, and verify the old page refuses without overwriting it. Explicit refresh must permit a subsequent save. Omit the catalog revision at the test transport boundary to verify the shared client refuses before any write; label that case as fault injection. For existing presets, verify Enter plus blur increments the revision once, a later blur saves independently, composition Enter does not submit, and a delayed write remains on its original Host/preset after closing and switching Hosts. Preserve the draft/error on a rejected save. New presets retain an explicit create action. Reject a remote storage preview token on the other Host, and verify neither cleanup command proceeds without its confirmation flag. @@ -72,3 +73,12 @@ Read-failure recovery: keep the same Host connected with the same credential aft Permanent Remove regression (F01): enable a Remote Host and change its notification kinds, then remove it through the icon context menu. Verify the Host revokes this browser, the local pairing disappears, open notification handles close and `botharness.remoteNotifications.v1` no longer contains that Host. Reload and pair the same Host again: notifications start off with default kinds. Repeat while activation is waiting; a late permission or notification-page response cannot restore the forgotten choice. Other Hosts retain their choices; temporary disconnection, reconnect and page disposal retain the existing choice. The phone-global preference is outside this browser operation. The historical T4 driver assertion `persistedChoiceUnchanged` was incorrect for permanent Remove; retain that historical artifact but do not count it as F01 proof. Storage-refusal regression: selectively reject deletion of the notification key (removeItem for the last choice, setItem when another choice remains), while pairing storage stays writable. Permanent Remove must still stop the connection/feed, close handles, cancel late activation and revoke the pairing. The existing Client diagnostic stream records only the fixed remote-notification-forget-persistence-failed code. In-memory removal is complete but persisted removal is not; do not promise reload/re-pair will forget a choice the browser refused to delete. Pairing-storage failures are outside this regression. + + +Unified Host settings regression: use two isolated Hosts with distinct home basenames. In both themes and locales, capture the old separate notification/icon/settings entry points as Before and the unified dialog as After, including a narrow viewport. Verify all five sections, Host identity in the sidebar, and notification help only inside the notification content. Rename, choose a symbol/emoji and colour, close and reload; compare the visible Host labels with `botharness.hostLooks.v1` and `host-look` from the explicitly selected `appearance-clients` target. Confirm pairing metadata and both operating-system hostnames are unchanged. Interleave a UI edit with a stale CLI revision and verify refusal. Inject browser storage refusal and verify a visible save error plus `host-look-storage-failed`, with no unsaved in-memory value. Delay Client delivery beyond the relay deadline, dispose the Client and remove the source: no late look write or acknowledgement may occur. + +Stop only Host B: Appearance and notification-kind choices remain usable, while Models/Usage/Storage show the source unavailable. Enabling notifications still requires the existing complete source baseline and may refuse offline. While activation waits, leave Notifications for Appearance and separately close the dialog; late results must not enable notifications. Repeat F01 Remove/re-pair and T7 viewer release regression as prerequisites allow. Existing T8 model revision, usage and destructive storage confirmations remain intact; no model request is needed for this settings UI journey. Record missing Docker/native/tailnet prerequisites explicitly. + +Host look concurrency and recovery: open two actual same-origin tabs, select each appearance Client ID explicitly, and use the same paired Hosts. Read B's revision, save a name from A, then submit B's old revision and require `host-look-revision-stale`. Concurrent edits of different fields and different Hosts must preserve both; record actual Web Lock names and storage values. A mocked lock manager or one-instance Promise queue is not cross-tab runtime evidence. Deny reads after a known look is loaded and on initial hydration: CLI reads/writes must return `host-look-storage-read-failed`, preserve known state and leave storage untouched. Restore access, explicitly refresh in UI/CLI, then save successfully. Missing Web Locks must return `host-look-lock-unavailable` without fallback. Keep native synchronous owner/migration regressions in the focused test set. + +Host look expiry: cover a CLI timeout shorter than eight seconds, delayed hydration, waiting for another tab's lock, late command delivery, and wall time past the deadline while timeout callbacks have not run. Observe storage before and after release; overdue writes must not begin. Also prove a write inside the deadline succeeds. Early CLI kill or HTTP disconnect before expiry is **unknown outcome**, not a cancellation pass: read back the same Client/Host. Do not extend this Host-look deadline contract to the unchanged notification relay. diff --git a/.github/pr-screenshots/2500-hs/README.md b/.github/pr-screenshots/2500-hs/README.md new file mode 100644 index 0000000000..9b341df25c --- /dev/null +++ b/.github/pr-screenshots/2500-hs/README.md @@ -0,0 +1,127 @@ +# Host settings evidence — #2500 + +Captured on 2026-10-22 in two isolated DSH Hosts, using real UI pairing and one +task-owned Chromium profile. Screenshots are unmodified captures at 1440 × 900, +English, with light/dark themes and disposable Bot fixtures. No model calls were +made. Pairing offers, login URLs, credentials and private Profile files are excluded. + +## Revision and image map + +- Before source: `8f4e650db551ae8458211634872ed291ff4e5a4f`. +- After source: `08290c7192ad87a6f557a582d03bd2dce6d45aeb`. +- Owner/CLI runtime source: `01c0f4009472a5552ee6a5619619096c1e09acff`. +- The only production delta from 01c to 08290 makes the existing Appearance and + Notifications hints visible through Note (two files, four additions/two removals). +- Filename: `b` = before, `a` = after; middle `a/n/m/s` = Appearance, + Notifications, Models, Storage; final `l/d` = light/dark. + +Appearance's predecessor is the separate icon dialog. Notifications previously +put its long hint in the sidebar header. After captures show Host icon/name in the +header and five navigation items. Both original hints are visible in the body. +Models and Storage retain their existing content. The background Home fixture +and local Host icon differ slightly across captures; the remote Host under test +uses the same default name/icon/color. The before light Notifications capture +contains slight background bleed during the dialog transition, but its text and +controls are readable; the failed later recapture is excluded. + +## Executed checks + +Actual toolchain: Node 24.21.0, pnpm 12.4.2, process GOMAXPROCS=2 and Vitest +maxWorkers=1. Checks ran serially. + +| Check | Source | Actual result | +| ----------------------------------------- | ------------------------ | ------------------------------------------------ | +| Frozen install | da6fb748b9a2 | exit 0 | +| Three behavioral regression cases | original 1ab2b33a7afe | exit 1; all three fail for the intended behavior | +| Same three cases | da6fb748b9a2 | exit 0 | +| 13 focused files / 107 tests | da6fb748b9a2 | exit 0 | +| Root/mobile typecheck, lint, format check | da6fb → format-only 01c0 | exit 0 | +| Baseline complete build | 8f4e650db551 | exit 0 | +| Candidate complete build | 01c0f4009472 | exit 0 | +| Final complete build | 08290c7192ad | exit 0 | +| Final root/mobile typecheck, root lint | 08290c7192ad | exit 0 | +| Final two changed files format check | 08290c7192ad | exit 0 | + +Builds produced the Client and natural bundles. The four tracked Browser/Computer +client JS/map artifacts remained unchanged. The 107 focused tests were not rerun +for the two hint placements. The full repository suite was not run. Local CI-plan +preview selects **full** because the old settings file was removed/renamed and its +importers cannot be traced; mobile checks are selected too. This is a plan, not +a CI result. An earlier automatic install was cancelled with exit 1 and ran no +tests; it is separate from the successful frozen install. + +## Runtime facts and limits + +At 01c, two real same-origin tabs had distinct Client IDs and shared actual Web +Locks. A stale second-tab revision was refused after the first tab saved a name. +Subsequent field updates preserved the name and other Host records. CLI readback, +reload persistence, a successful pre-deadline write, invalid input and unknown +Host refusals were observed. Local display-name changes left Host metadata alone. + +The real lock test held the shared lock in one tab while another tab's CLI request +expired. Releasing it did not write the expired change. A separate synchronous +storage-read delay crossed wall-clock expiry while Client timers could not run; +the stored look was unchanged after the delayed operation. Both CLI timeout +results were **outcome unknown**, followed by readback; neither is cancellation +proof. Early process death or HTTP disconnect before expiry remains unknown. + +Explicit, restored fault injection covered storage-read failure, blocked writes, +continued refusal after repair until explicit refresh, successful refresh, +distinct storage-write failure, and missing-WebLocks refusal. These are controlled +failure-path checks, not naturally occurring browser quota or permission failures. + +At 08290, both hints and the Appearance/Notifications/Models/Storage views were +visually checked in light/dark. Remote Models, Usage and Storage CLI reads exited 0. Token usage opened using the actual dialog button and showed zero tokens, +with Memory still loading: this is partial UI proof. Earlier name-only selectors +hit another heading, so those mislabelled Usage captures are excluded. There is +no qualified matching Before Usage image. + +Notification kind changes persisted. An explicitly delayed permission Promise +resolved after navigating away without writing; this is owner lifecycle evidence, +not proof of OS-granted notification delivery. Remove/re-pair cleared the saved +notification choices and restored disabled/default choices. With the remote Host +stopped, appearance/name and notification kind edits still saved; Models showed +the remote-unavailable state without serving-Host fallback. + +## Preserved failures and outstanding acceptance + +- An earlier heavy run was reported cancelled; cause unknown. Its baseline + background-tab driver timeout and observe/doctor results were retained. +- The first candidate browser exited during capture: `Page.captureScreenshot: +Target closed`, followed by browser connection/session closure. Host doctor + remained healthy. No cause was established. The one authorized browser rebuild + succeeded; it does not erase the original failure. +- The supplementary Chinese tab later became blank after the remote Host was + offline. Console reported `locale subscriber crashed`, + `uiConversation.binding: unknown session`, and a released Session reference. + Observe exited 1 while Host doctor exited 0. Attribution is unresolved. + Chinese settings screenshots and the supplementary This Host UI capture are + **not passed**. No production fix was made for this finding. +- At 560 px the existing mobile shell unmounted the dialog. No narrow settings + acceptance is claimed. Re-run this journey in its supported shell before + claiming responsive acceptance. +- Background Client-unavailable refusals, a CLI argument-driver mistake, and + name-only ARIA selector mistakes are retained separately from product results. +- Native/device UI, live model calls, OS alert delivery, destructive storage + cleanup, model-preset mutation and live Browser/Computer viewer removal were + not tested here. Shared native-owner contracts and T7/T8 paths have focused + source-test coverage only. Human QA and CI remain separate gates. + +Review history: R1 Standards **0 hard + 2 P3 heuristics**, Spec **3 P2**. +R2 at da6fb: Standards **0 hard / 0 new heuristics**, Spec **0**; original findings +statically closed. The parent directly reviewed the tiny 08290 hint delta with +no new finding. Static review does not imply runtime acceptance. + +## Follow-up path and release + +Keep the PR draft pending diagnosis of the Chinese session/locale failure and +Human QA. In a separately scheduled window, reuse isolated profiles, activate the +target tab, capture console/ARIA and observe/doctor before retrying the Chinese +remote settings journey. Check This Host UI and remaining Usage/narrow states; +native compatibility goes through the shared Android testing queue. + +All task-owned Hosts/browser/build processes were stopped and exact recorded PIDs +were checked absent at **2026-10-22T05:13:32.1553288Z**. Task ports had no listeners. +The heavy window was explicitly released before PR preparation. Private logs, +ARIA snapshots, command receipts and the release record remain in the task's +ignored evidence directory; none of the private runtime profiles are published. diff --git a/.github/pr-screenshots/2500-hs/a-a-d.png b/.github/pr-screenshots/2500-hs/a-a-d.png new file mode 100644 index 0000000000..f169d3e70a Binary files /dev/null and b/.github/pr-screenshots/2500-hs/a-a-d.png differ diff --git a/.github/pr-screenshots/2500-hs/a-a-l.png b/.github/pr-screenshots/2500-hs/a-a-l.png new file mode 100644 index 0000000000..4f0a669b34 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/a-a-l.png differ diff --git a/.github/pr-screenshots/2500-hs/a-m-d.png b/.github/pr-screenshots/2500-hs/a-m-d.png new file mode 100644 index 0000000000..7c759f4eac Binary files /dev/null and b/.github/pr-screenshots/2500-hs/a-m-d.png differ diff --git a/.github/pr-screenshots/2500-hs/a-m-l.png b/.github/pr-screenshots/2500-hs/a-m-l.png new file mode 100644 index 0000000000..6a86ee0cd2 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/a-m-l.png differ diff --git a/.github/pr-screenshots/2500-hs/a-n-d.png b/.github/pr-screenshots/2500-hs/a-n-d.png new file mode 100644 index 0000000000..a91a0f325f Binary files /dev/null and b/.github/pr-screenshots/2500-hs/a-n-d.png differ diff --git a/.github/pr-screenshots/2500-hs/a-n-l.png b/.github/pr-screenshots/2500-hs/a-n-l.png new file mode 100644 index 0000000000..f94da50524 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/a-n-l.png differ diff --git a/.github/pr-screenshots/2500-hs/a-s-d.png b/.github/pr-screenshots/2500-hs/a-s-d.png new file mode 100644 index 0000000000..90af3c1c03 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/a-s-d.png differ diff --git a/.github/pr-screenshots/2500-hs/a-s-l.png b/.github/pr-screenshots/2500-hs/a-s-l.png new file mode 100644 index 0000000000..3764853bdf Binary files /dev/null and b/.github/pr-screenshots/2500-hs/a-s-l.png differ diff --git a/.github/pr-screenshots/2500-hs/b-a-d.png b/.github/pr-screenshots/2500-hs/b-a-d.png new file mode 100644 index 0000000000..87712955f6 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/b-a-d.png differ diff --git a/.github/pr-screenshots/2500-hs/b-a-l.png b/.github/pr-screenshots/2500-hs/b-a-l.png new file mode 100644 index 0000000000..d902c1b0c5 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/b-a-l.png differ diff --git a/.github/pr-screenshots/2500-hs/b-m-d.png b/.github/pr-screenshots/2500-hs/b-m-d.png new file mode 100644 index 0000000000..b7871af01f Binary files /dev/null and b/.github/pr-screenshots/2500-hs/b-m-d.png differ diff --git a/.github/pr-screenshots/2500-hs/b-m-l.png b/.github/pr-screenshots/2500-hs/b-m-l.png new file mode 100644 index 0000000000..c9d1e5a56a Binary files /dev/null and b/.github/pr-screenshots/2500-hs/b-m-l.png differ diff --git a/.github/pr-screenshots/2500-hs/b-n-d.png b/.github/pr-screenshots/2500-hs/b-n-d.png new file mode 100644 index 0000000000..daa8bd3235 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/b-n-d.png differ diff --git a/.github/pr-screenshots/2500-hs/b-n-l.png b/.github/pr-screenshots/2500-hs/b-n-l.png new file mode 100644 index 0000000000..a3a4c78415 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/b-n-l.png differ diff --git a/.github/pr-screenshots/2500-hs/b-s-d.png b/.github/pr-screenshots/2500-hs/b-s-d.png new file mode 100644 index 0000000000..2e66569d91 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/b-s-d.png differ diff --git a/.github/pr-screenshots/2500-hs/b-s-l.png b/.github/pr-screenshots/2500-hs/b-s-l.png new file mode 100644 index 0000000000..a2db5b9f60 Binary files /dev/null and b/.github/pr-screenshots/2500-hs/b-s-l.png differ diff --git a/apps/mobile/src/host/host-look.ts b/apps/mobile/src/host/host-look.ts index 2f8659989c..2b3b6986f1 100644 --- a/apps/mobile/src/host/host-look.ts +++ b/apps/mobile/src/host/host-look.ts @@ -13,11 +13,22 @@ import { export interface LookStorage { read(): Promise; - write(value: Record): void; + write(value: Record, check?: () => void): void; forgetLegacyImages?(): void; + transaction?(work: () => Promise): Promise; + listen?(changed: () => void): () => void; } export interface HostLooks { + readonly ready: Promise; + error(): string | undefined; + refresh(check?: () => void): Promise; + read(hostId: string, check?: () => void): Promise; + update( + hostId: string, + change: (current: HostLook) => HostLook, + check?: () => void, + ): Promise; look(hostId: string): HostLook; set(hostId: string, look: HostLook): void; subscribe(listener: () => void): () => void; @@ -30,24 +41,31 @@ function record(value: unknown): Record { const text = (value: unknown): string | undefined => typeof value === 'string' && value !== '' ? value : undefined; -export function parseHostIcon(input: unknown): HostIcon { +export function isHostIcon(input: unknown): input is HostIcon { const value = record(input); - const id = text(value['id']); - const emoji = text(value['emoji']); - if (value['kind'] === 'symbol' && id !== undefined && Object.hasOwn(HOST_SYMBOLS, id)) - return { kind: 'symbol', id: id as keyof typeof HOST_SYMBOLS }; - if ( - value['kind'] === 'emoji' && - emoji !== undefined && - (HOST_EMOJI as readonly string[]).includes(emoji) - ) - return { kind: 'emoji', emoji }; - return DEFAULT_HOST_LOOK.icon; + return ( + (value['kind'] === 'symbol' && + typeof value['id'] === 'string' && + Object.hasOwn(HOST_SYMBOLS, value['id'])) || + (value['kind'] === 'emoji' && + typeof value['emoji'] === 'string' && + (HOST_EMOJI as readonly string[]).includes(value['emoji'])) + ); +} + +export function isHostColor(input: unknown): input is HostColorId { + return typeof input === 'string' && Object.hasOwn(HOST_COLORS, input); +} + +export function parseHostIcon(input: unknown): HostIcon { + if (!isHostIcon(input)) return DEFAULT_HOST_LOOK.icon; + return input.kind === 'symbol' + ? { kind: 'symbol', id: input.id } + : { kind: 'emoji', emoji: input.emoji }; } export function parseHostColor(input: unknown): HostColorId | undefined { - const id = text(input); - return id !== undefined && Object.hasOwn(HOST_COLORS, id) ? (id as HostColorId) : undefined; + return isHostColor(input) ? input : undefined; } function legacyColor(input: unknown): HostColorId | undefined { @@ -70,34 +88,139 @@ export function parseHostLook(input: unknown): HostLook { export function createHostLooks(storage: LookStorage): HostLooks { const byHost = new Map(); const listeners = new Set<() => void>(); + let failure: string | undefined; + let unlisten: (() => void) | undefined; + let touched = false; const notify = (): void => { for (const listener of listeners) listener(); }; - let touched = false; - void storage.read().then( - (value) => { - if (touched) return; - const stored = Object.entries(record(value)); - for (const [hostId, look] of stored) byHost.set(hostId, parseHostLook(look)); - if (stored.some(([, look]) => isLegacyHostLook(look))) { - storage.write(Object.fromEntries(byHost)); - storage.forgetLegacyImages?.(); - } - notify(); - }, - () => undefined, - ); + const fail = (code: string): never => { + failure = code; + notify(); + throw new Error(code); + }; + const publish = (value: Record) => { + for (const id of byHost.keys()) if (!(id in value)) byHost.delete(id); + for (const [id, look] of Object.entries(value)) + if (JSON.stringify(byHost.get(id)) !== JSON.stringify(look)) byHost.set(id, look); + failure = undefined; + notify(); + }; + const load = async (): Promise> => { + let raw: unknown; + try { + raw = await storage.read(); + } catch { + return fail('host-look-storage-read-failed'); + } + return Object.fromEntries( + Object.entries(record(raw)).map(([id, look]) => [id, parseHostLook(look)]), + ); + }; + const transaction = async (work: () => Promise) => { + if (storage.transaction === undefined) return work(); + try { + await storage.transaction(work); + } catch (error) { + if (error instanceof Error && error.message === 'host-look-lock-unavailable') + return fail(error.message); + throw error; + } + }; + const persist = (value: Record, check?: () => void) => { + check?.(); + try { + if (check === undefined) storage.write(value); + else storage.write(value, check); + } catch { + check?.(); + return fail('host-look-storage-failed'); + } + }; + const refresh = async (check?: () => void) => + transaction(async () => { + check?.(); + const value = await load(); + check?.(); + publish(value); + }); + const ready = ( + storage.transaction === undefined + ? storage.read().then( + (raw) => { + if (touched) return; + const stored = Object.entries(record(raw)); + const value = Object.fromEntries(stored.map(([id, look]) => [id, parseHostLook(look)])); + if (stored.some(([, look]) => isLegacyHostLook(look))) { + persist(value); + storage.forgetLegacyImages?.(); + } + publish(value); + }, + () => fail('host-look-storage-read-failed'), + ) + : refresh() + ).catch(() => undefined); + const available = async () => { + await ready; + if (failure !== undefined) throw new Error(failure); + }; + const current = (hostId: string) => byHost.get(hostId) ?? DEFAULT_HOST_LOOK; return { - look: (hostId) => byHost.get(hostId) ?? DEFAULT_HOST_LOOK, + ready, + error: () => failure, + refresh, + look: current, + async read(hostId, check) { + await available(); + let result = current(hostId); + await transaction(async () => { + if (failure !== undefined) throw new Error(failure); + check?.(); + const value = await load(); + check?.(); + publish(value); + result = current(hostId); + }); + return result; + }, + async update(hostId, change, check) { + await available(); + let result = current(hostId); + await transaction(async () => { + if (failure !== undefined) throw new Error(failure); + check?.(); + const value = await load(); + check?.(); + result = parseHostLook(change(value[hostId] ?? DEFAULT_HOST_LOOK)); + check?.(); + persist({ ...value, [hostId]: result }, check); + publish({ ...value, [hostId]: result }); + }); + return result; + }, set(hostId, look) { + if (storage.transaction !== undefined) throw new Error('host-look-transaction-required'); + if (failure !== undefined) return; + const next = parseHostLook(look); + persist({ ...Object.fromEntries(byHost), [hostId]: next }); touched = true; - byHost.set(hostId, parseHostLook(look)); - storage.write(Object.fromEntries(byHost)); + byHost.set(hostId, next); notify(); }, subscribe(listener) { listeners.add(listener); - return () => listeners.delete(listener); + if (listeners.size === 1) + unlisten = storage.listen?.(() => { + if (failure === undefined) void refresh().catch(() => undefined); + }); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unlisten?.(); + unlisten = undefined; + } + }; }, }; } diff --git a/apps/mobile/test/native-persistence.test.ts b/apps/mobile/test/native-persistence.test.ts index 54d09e953e..0aaa0e1651 100644 --- a/apps/mobile/test/native-persistence.test.ts +++ b/apps/mobile/test/native-persistence.test.ts @@ -140,3 +140,25 @@ describe('native persistence across SDK upgrades', () => { expect(restarted.dmActivityPreferences.hidden('a', 'ada')).toEqual(['posts']); }); }); + +it('blocks native Host look writes after a failed read and recovers explicitly without changing the setter contract', async () => { + const path = 'documents/deepseekbot-host-looks.json'; + disk.files.set(path, '{invalid'); + const { hostLooks } = await import('../src/host/native-host-look'); + await expect(hostLooks.ready).resolves.toBeUndefined(); + expect(hostLooks.error()).toBe('host-look-storage-read-failed'); + expect(() => hostLooks.set('b', { ...hostLooks.look('b'), name: 'Unsafe' })).not.toThrow(); + expect(disk.files.get(path)).toBe('{invalid'); + await expect(hostLooks.read('b')).rejects.toThrow('host-look-storage-read-failed'); + disk.files.set(path, JSON.stringify({ a: { name: 'Preserved' } })); + await hostLooks.refresh(); + expect(hostLooks.set('b', { ...hostLooks.look('b'), name: 'Recovered' })).toBeUndefined(); + expect(snapshot(path).a.name).toBe('Preserved'); + expect(snapshot(path).b.name).toBe('Recovered'); + const known = hostLooks.look('b'); + disk.files.set(path, '{broken again'); + await expect(hostLooks.refresh()).rejects.toThrow('host-look-storage-read-failed'); + expect(hostLooks.look('b')).toBe(known); + hostLooks.set('b', { ...known, name: 'Unsafe again' }); + expect(disk.files.get(path)).toBe('{broken again'); +}); diff --git a/changes/2500-host-settings.md b/changes/2500-host-settings.md new file mode 100644 index 0000000000..3e94939266 --- /dev/null +++ b/changes/2500-host-settings.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- Unified Remote Host appearance, notifications, models, usage and storage in one settings dialog, with a device-local display name, icon and colour shared by the UI and `host-look` CLI ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). + +## zh + +- 将远程 Host 的外观、通知、模型、用量和存储统一到一个设置窗口,UI 与 `host-look` CLI 共用设备本地的显示名称、图标和颜色([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index b6ea1a0d94..3267422756 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1152,7 +1152,7 @@ The phone owns its persisted four-kind Device Notification Preference and copies The application-defined development inspector transport settles this asynchronous phone operation explicitly with a request-scoped result slot and synchronous polling, rather than assuming Hermes awaits a JavaScript Promise for CDP. Startup, polling and result consumption share one 20-second CLI deadline. The unique slot is consumed and its timer cleared on fulfillment or rejection; cancellation sends best-effort cleanup, and a runtime TTL clears abandoned slots after disconnect when its event loop runs. Late completion cannot recreate the slot. Replies pass envelope and phone-owner schema checks before success. This temporary process state stores no credential and is not a preference authority. Transport expiry cannot roll back an owner write already started: its outcome remains unknown until readback. The existing UAT fixture driver retains its exact device and trusted-fixture restrictions and raw inspector interface. -Remote Host settings resolve the selected Host's existing client from `RemoteHosts`. The browser reuses `apps/mobile/src/host/model-settings`, `usage` and `storage` for default models, presets, usage/Memory activity and cleanup; the existing Web settings, model picker, charts and destructive confirmation controls present those operations. Settings unmount their content when the source is disconnected or removed. Model resources are keyed by client and scope; there is no This Host fallback, Bot-profile link or automatic write replay. Cleanup retains the owning Host's preview token, scope and explicit confirmation. Host looks remain device-local and independent of these Host-owned settings. +Remote Host settings resolve the selected Host's existing client from `RemoteHosts`. The browser reuses `apps/mobile/src/host/model-settings`, `usage` and `storage` for default models, presets, usage/Memory activity and cleanup; the existing Web settings, model picker, charts and destructive confirmation controls present those operations. Host-owned settings unmount their content when the source disconnects; removing the source closes the settings surface. Model resources are keyed by client and scope; there is no This Host fallback, Bot-profile link or automatic write replay. Cleanup retains the owning Host's preview token, scope and explicit confirmation. Host looks remain device-local and independent of these Host-owned settings. The Host Settings dialog presents Appearance, Notifications, Models, Usage and Storage under one Host icon and display name. Appearance reuses the phone's application-defined `HostLooks` owner and normalization rules through the existing browser storage adapter; it never rewrites pairing metadata or the remote system hostname. Local appearance and notification sections remain reachable offline. Leaving Notifications releases its pending activation through the existing cancellation owner. The existing authenticated Client appearance relay accepts `hostLook` for CLI parity: explicit Client/Host selection, observed content revision and an absolute CLI deadline capped by the Host at eight seconds. The browser adapter acquires the same-origin exclusive Web Lock named by its existing storage key; the shared owner rereads persistence, checks the selected Host revision and applies only that Host’s field transformation under the lock. Missing Web Locks refuses access without an unsafe fallback. Read failure retains known display state but blocks control reads and writes until explicit refresh; rejected writes do not publish an unsaved look. Deadline, Client release and source removal are rechecked after hydration and lock acquisition and immediately before setItem. Early CLI termination or HTTP loss is an unknown write outcome requiring readback, not proof of cancellation. The native synchronous setter and legacy migration remain available through the shared owner; no mobile UI, native persistence adapter or dependency changes are introduced. The relay stores no preference copy and introduces no remote Host API. ### Version-bound Memory image previews (#2214, first Web slice) diff --git a/docs/bot-cli.md b/docs/bot-cli.md index 923c7c0537..9402c9a1b7 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -68,7 +68,15 @@ For a blank bot, `--core-memory ` or `--core-memory-stdin` writes the root ### Remote Host settings -The browser's Remote Host icon menu → **Remote settings** uses the same Host owners as these online commands. Select the source Host with `--host ` and its private `--token-file ` (or the existing environment variables); never point a remote-setting command at the serving Host by default. `model-catalog`, `home-model-set`, `model-presets`, `model-preset-create` and `model-preset-update` cover models; `overview-usage` and `overview-memory` cover usage and Memory activity; `storage-usage` and both storage cleanup preview/confirm pairs cover storage. Destructive confirmations still require `--confirmation-stdin --confirm`, using the preview from that same Host. An unavailable or refusing Host never falls back to a local profile. These commands do not read browser pairing secrets or change device-local Host looks. +The browser's Remote Host icon menu → **Host settings** uses the same Host owners as these online commands. Select the source Host with `--host ` and its private `--token-file ` (or the existing environment variables); never point a remote-setting command at the serving Host by default. `model-catalog`, `home-model-set`, `model-presets`, `model-preset-create` and `model-preset-update` cover models; `overview-usage` and `overview-memory` cover usage and Memory activity; `storage-usage` and both storage cleanup preview/confirm pairs cover storage. Destructive confirmations still require `--confirmation-stdin --confirm`, using the preview from that same Host. An unavailable or refusing Host never falls back to a local profile. These commands do not read browser pairing secrets or change device-local Host looks. Appearance and Notifications are separate sections in the same dialog and remain available while the Remote Host is offline. + +`appearance-clients` lists the open browser Clients. `host-look --client-id --host-id ` reads that browser's name, icon, colour and content revision. Add `--patch-stdin --expected-revision ` to write a JSON patch such as `{"name":"Studio","icon":{"kind":"symbol","id":"server"},"color":"teal"}`. An empty name restores the Host's original display name; `color: null` clears the colour. Names are trimmed and limited to 40 characters, matching the phone's shared HostLook rules. Use a paired Remote Host ID or the serving Host's ID; no remote credential is accepted or returned. + +This command uses the existing authenticated `appearance-control` relay to reach the same Client-local `HostLooks` owner as the UI. It does not rename the operating-system hostname or sync choices to another device. The browser must be open; select it explicitly. All cooperating same-origin tabs use the exclusive Web Lock `botharness.hostLooks.v1`. Inside that lock the owner rereads storage, compares the selected Host's content revision and merges only that Host; UI edits transform the current field values inside the same lock. A stale revision returns `host-look-revision-stale`. Browsers without usable Web Locks return `host-look-lock-unavailable`; there is no unsafe read/modify/write fallback. + +`host-look-storage-read-failed` keeps the last known display state and blocks reads and writes instead of reporting defaults as loaded. Repair browser storage access, then use `host-look --client-id --host-id --refresh` (or Refresh appearance in settings) to recover explicitly. Refresh never writes. A read failure inside a write transaction also prevents persistence. `host-look-storage-failed` means the browser rejected the write; it does not publish an unsaved look. After resolving the storage problem, refresh before sending a new write. + +The CLI sets an absolute expiry before stdin and connection setup. The Host caps it at eight seconds from relay admission. The Client rechecks wall time after hydration, after acquiring the lock and immediately before `localStorage.setItem`; an overdue command cannot begin a write even if a timeout callback has not run. Client release and Host removal are also rechecked before writing. This is not a remote cancellation acknowledgement: killing the CLI or disconnecting HTTP before expiry can leave a delivered command running until that deadline. A lost write reply or relay timeout returns `host-look-outcome-unknown` where the CLI can still report it; read the same Client/Host before retrying. A write completed before expiry may have succeeded even when its reply was lost. `browser-notifications` continues to own the Notifications section, including permission, activation baseline and cancellation. Set `DEEPSEEKBOT_HOST` to the running Host's loopback origin, or a tailnet HTTPS origin. A tailnet origin (for example `tailscale serve` in front of a loopback Host) works only when the Host was started with `--trusted-host ..ts.net`; otherwise login succeeds but DSH's `/api` Host fence refuses every request and the CLI returns `host-untrusted`. Supply its current launch token through `DEEPSEEKBOT_HOST_TOKEN` or `--token-file `, never a token argument. `--host ` overrides the environment. Login happens per invocation, cookies remain in memory, and restarting the Host requires its new token. Live commands never open the profile database. See [ADR-0159](adr/0159-live-cli-verbs-ride-the-dsh-http-carrier.md). diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 3c9d693b88..10b04316ee 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -68,7 +68,15 @@ deepseekbot search ### 远程设置 -浏览器远程图标菜单中的「远程设置」与以下在线命令使用同一个 Host 所有者。通过 `--host ` 和私有的 `--token-file `(或已有环境变量)指定来源 Host,不要默认指向提供当前网页的本机。模型使用 `model-catalog`、`home-model-set`、`model-presets`、`model-preset-create` 和 `model-preset-update`;用量与 Memory 活动使用 `overview-usage`、`overview-memory`;存储使用 `storage-usage` 及两组清理预览/确认命令。删除仍须传入 `--confirmation-stdin --confirm`,且预览必须来自同一 Host。连接失败或被拒绝时,不会改用本地 Profile。这些命令不读取浏览器的配对密钥,也不修改本设备保存的 Host 外观。 +`appearance-clients` 列出打开的浏览器 Client。`host-look --client-id --host-id ` 读取该浏览器内的 Host 名称、图标、颜色和内容修订。写入时加 `--patch-stdin --expected-revision `,从 stdin 传入 JSON,例如 `{"name":"Studio","icon":{"kind":"symbol","id":"server"},"color":"teal"}`。空名称恢复 Host 原始显示名;`color: null` 清除颜色。名称去除首尾空格并限制为 40 字符,与手机共享 HostLook 规则。Host ID 可为已配对的远程 Host 或本机 Host;命令不接收或返回远程凭据。 + +命令复用已认证的 `appearance-control` relay,直达 UI 使用的同一 Client 本地 `HostLooks`,不修改操作系统 hostname,也不同步到其他设备。浏览器须保持打开并显式选择 Client。同源标签页共同使用名为 `botharness.hostLooks.v1` 的独占 Web Lock;owner 在锁内重读持久数据、比较目标 Host 的内容 revision,再只合并该 Host。UI 字段变换也在锁内应用于当前值,不携带旧整份外观覆盖其他字段。旧 revision 返回 `host-look-revision-stale`;Web Locks 不可用时返回 `host-look-lock-unavailable`,不降级为不安全的读改写。 + +`host-look-storage-read-failed` 会保留内存中已知的显示状态,但阻止读取和写入,不把默认值当成成功加载。修复浏览器存储访问后,执行 `host-look --client-id --host-id --refresh`,或在设置中选择「刷新外观」,显式恢复;刷新本身不写入。事务锁内重读失败同样阻止落盘。`host-look-storage-failed` 表示浏览器拒绝写入,不发布未保存的外观;解决存储问题并刷新后才能重新提交。 + +CLI 在读取 stdin 和连接前记录绝对期限;Host 将期限限制为 relay 接收后最多八秒。Client 在加载后、获得锁后和实际 `localStorage.setItem` 前复核墙上时间,即使定时回调尚未运行,越期命令也不能开始写入。Client 释放与 Host 移除也在写入前复核。这不代表远端取消已确认:期限前杀掉 CLI 或断开 HTTP,已送达命令仍可能在期限内执行。写入回执丢失或 relay 超时,在 CLI 仍能输出时报告 `host-look-outcome-unknown`;重试前须读回相同 Client/Host。期限内已完成的写入可能成功而回执丢失。Host 设置统一包含外观、通知、模型、用量和存储;离线时仍可进入外观和通知。通知继续使用 `browser-notifications`,保留权限、激活基线和取消语义。 + +浏览器远程图标菜单中的「Host 设置」与以下在线命令使用同一个 Host 所有者。通过 `--host ` 和私有的 `--token-file `(或已有环境变量)指定来源 Host,不要默认指向提供当前网页的本机。模型使用 `model-catalog`、`home-model-set`、`model-presets`、`model-preset-create` 和 `model-preset-update`;用量与 Memory 活动使用 `overview-usage`、`overview-memory`;存储使用 `storage-usage` 及两组清理预览/确认命令。删除仍须传入 `--confirmation-stdin --confirm`,且预览必须来自同一 Host。连接失败或被拒绝时,不会改用本地 Profile。这些命令不读取浏览器的配对密钥,也不修改本设备保存的 Host 外观。 将 `DEEPSEEKBOT_HOST` 设为运行中 Host 的 loopback origin 或 tailnet HTTPS origin。使用 tailnet origin(例如 loopback Host 前面的 `tailscale serve`)时,Host 必须以 `--trusted-host ..ts.net` 启动;否则登录能成功,但 DSH 的 `/api` Host 校验会拒绝每个请求,CLI 返回 `host-untrusted`。`--host ` 可覆盖它。当前启动令牌通过 `DEEPSEEKBOT_HOST_TOKEN` 或 `--token-file <私有文件>` 提供,不得把令牌值放入参数。每次调用重新登录,cookie 只留在内存;Host 重启后须使用新令牌。在线命令不打开 Profile 数据库。参见 [ADR-0159](adr/0159-live-cli-verbs-ride-the-dsh-http-carrier.md)。 diff --git a/packages/client/src/client/appearance-control.ts b/packages/client/src/client/appearance-control.ts index 9dfc4b33a4..3d7389905a 100644 --- a/packages/client/src/client/appearance-control.ts +++ b/packages/client/src/client/appearance-control.ts @@ -1,3 +1,6 @@ +import { getThisHost } from './this-host.js'; +import { hostLooks } from './host-looks.js'; +import { controlHostLook } from './host-look-control.js'; import { resetAppearance } from './appearance-recovery.js'; import { WallpaperError } from './wallpaper-store.js'; import type { Wallpaper } from './wallpaper.js'; @@ -39,7 +42,24 @@ export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper) try { if (typeof command.expiresAt !== 'number' || Date.now() > command.expiresAt) throw new AppearanceError('appearance-request-stale'); - if (command.notifications !== undefined) { + if (command.hostLook !== undefined) { + const looks = hostLooks(); + await looks.ready; + result = await controlHostLook( + looks, + command.hostLook, + command.expectedRevision, + { + expiresAt: command.expiresAt, + signal: abort.signal, + }, + (hostId) => + hostId === getThisHost().id || + remoteHosts() + .getSnapshot() + .hosts.some((entry) => entry.host.hostId === hostId), + ); + } else if (command.notifications !== undefined) { const sources = remoteHosts(); const input = object(command.notifications); if ( @@ -98,7 +118,7 @@ export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper) } if (abort.signal.aborted) return; if ( - command.notifications !== undefined && + (command.notifications !== undefined || command.hostLook !== undefined) && typeof command.expiresAt === 'number' && Date.now() >= command.expiresAt ) diff --git a/packages/client/src/client/host-glyph.tsx b/packages/client/src/client/host-glyph.tsx new file mode 100644 index 0000000000..5ed256a44b --- /dev/null +++ b/packages/client/src/client/host-glyph.tsx @@ -0,0 +1,26 @@ +import * as stylex from '@stylexjs/stylex'; +import { ChannelSidebarIcon } from './channel-sidebar-icon.js'; +import { HOST_SYMBOL_GLYPHS, useHostInk } from './host-looks.js'; +import type { HostLook } from '../../../../apps/mobile/src/ui/design/host-look.js'; +import { text } from './ds/tokens.stylex.js'; + +const styles = stylex.create({ + glyph: { display: 'inline-flex', flexShrink: 0, alignItems: 'center', justifyContent: 'center' }, + emoji: { fontSize: text.titleSize, lineHeight: text.metaLine }, + ink: (value: string) => ({ color: value }), +}); + +export function HostGlyph({ look, size: glyphSize = 16 }: { look: HostLook; size?: number }) { + const ink = useHostInk(look.color); + if (look.icon.kind === 'emoji') + return ( + + ); + return ( + + ); +} diff --git a/packages/client/src/client/host-look-control.ts b/packages/client/src/client/host-look-control.ts new file mode 100644 index 0000000000..9dd7f8ea87 --- /dev/null +++ b/packages/client/src/client/host-look-control.ts @@ -0,0 +1,72 @@ +import { + isHostColor, + isHostIcon, + type HostLooks, +} from '../../../../apps/mobile/src/host/host-look.js'; +import { + withHostColor, + withHostIcon, + withHostName, + type HostLook, +} from '../../../../apps/mobile/src/ui/design/host-look.js'; +import { AppearanceError, object } from './appearance.js'; + +export async function controlHostLook( + looks: HostLooks, + input: unknown, + expectedRevision: unknown, + request: { expiresAt: number; signal: AbortSignal }, + available: (hostId: string) => boolean, +) { + const value = object(input); + const hostId = value.hostId; + const check = () => { + if (request.signal.aborted) throw new AppearanceError('host-look-cancelled'); + if (!Number.isFinite(request.expiresAt) || Date.now() >= request.expiresAt) + throw new AppearanceError('appearance-request-stale'); + if (typeof hostId !== 'string' || !available(hostId)) + throw new AppearanceError('host-look-host-unknown'); + }; + check(); + if (typeof hostId !== 'string') throw new AppearanceError('host-look-host-unknown'); + if (value.refresh !== undefined && (value.refresh !== true || value.patch !== undefined)) + throw new AppearanceError('host-look-invalid-input'); + let transform: ((current: HostLook) => HostLook) | undefined; + if (value.patch !== undefined) { + if (typeof expectedRevision !== 'string') + throw new AppearanceError('host-look-revision-required'); + const patch = object(value.patch); + const keys = Object.keys(patch); + if (keys.length === 0 || keys.some((key) => !['name', 'icon', 'color'].includes(key))) + throw new AppearanceError('host-look-invalid-input'); + if ( + ('name' in patch && typeof patch.name !== 'string') || + ('icon' in patch && !isHostIcon(patch.icon)) || + ('color' in patch && patch.color !== null && !isHostColor(patch.color)) + ) + throw new AppearanceError('host-look-invalid-input'); + transform = (current) => { + if (expectedRevision !== JSON.stringify(current)) + throw new AppearanceError('host-look-revision-stale'); + let next = current; + if (typeof patch.name === 'string') next = withHostName(next, patch.name); + if (isHostIcon(patch.icon)) next = withHostIcon(next, patch.icon); + if (patch.color === null || isHostColor(patch.color)) + next = withHostColor(next, patch.color === null ? undefined : patch.color); + return next; + }; + } + try { + await looks.ready; + check(); + if (value.refresh === true) await looks.refresh(check); + const look = + transform === undefined + ? await looks.read(hostId, check) + : await looks.update(hostId, transform, check); + return { hostId, look, revision: JSON.stringify(look) }; + } catch (error) { + if (error instanceof AppearanceError) throw error; + throw new AppearanceError(error instanceof Error ? error.message : 'host-look-storage-failed'); + } +} diff --git a/packages/client/src/client/host-look-settings.tsx b/packages/client/src/client/host-look-settings.tsx new file mode 100644 index 0000000000..f6f1114309 --- /dev/null +++ b/packages/client/src/client/host-look-settings.tsx @@ -0,0 +1,241 @@ +import { useState, type ReactElement } from 'react'; +import { Button } from '@deepseek-ai/dsh-client-ui-primitives'; +import * as stylex from '@stylexjs/stylex'; +import { Note } from './ds/note.js'; +import { TextField } from './ds/field.js'; +import { SettingsSection } from './ds/settings-row.js'; +import { border, color, radius, size, space, text } from './ds/tokens.stylex.js'; +import { ChannelSidebarIcon } from './channel-sidebar-icon.js'; +import { HostGlyph } from './host-glyph.js'; +import { hostLooks, useHostLook, useHostLookError } from './host-looks.js'; +import { useBotColorScheme } from './bot-color-scheme.js'; +import type { BotHarnessTranslate } from './locale.js'; +import { + HOST_COLOR_IDS, + HOST_COLORS, + HOST_EMOJI, + HOST_SYMBOL_IDS, + HOST_NAME_MAX, + hostLookName, + withHostName, + withHostIcon, + withHostColor, + type HostLook, +} from '../../../../apps/mobile/src/ui/design/host-look.js'; + +const focusRing = { default: 'none', ':focus-visible': `${border.focus} solid ${color.accent}` }; +const styles = stylex.create({ + editor: { display: 'flex', flexDirection: 'column', gap: space.x5 }, + emoji: { fontSize: text.titleSize, lineHeight: text.metaLine }, + section: { display: 'flex', flexDirection: 'column', gap: space.x2 }, + sectionName: { color: color.subtle, fontSize: text.metaSize, lineHeight: text.metaLine }, + grid: { display: 'flex', flexWrap: 'wrap', gap: space.x1 }, + choice: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: size.icon, + height: size.icon, + padding: 0, + borderWidth: border.hairline, + borderStyle: 'solid', + borderColor: 'transparent', + borderRadius: radius.sm, + backgroundColor: { default: 'transparent', ':hover': color.hover }, + color: color.fg, + fontFamily: 'inherit', + cursor: 'pointer', + outline: focusRing, + outlineOffset: '-2px', + }, + chosen: { borderColor: color.accent, backgroundColor: color.selected }, + swatch: (value: string) => ({ + width: text.titleSize, + height: text.titleSize, + borderRadius: radius.round, + backgroundColor: value, + }), +}); + +function HostNameField({ + name, + saved, + change, + t, +}: { + name: string; + saved: string | undefined; + change: (transform: (current: HostLook) => HostLook) => Promise; + t: BotHarnessTranslate; +}) { + const [draft, setDraft] = useState(saved ?? ''); + const save = async () => { + if (draft === (saved ?? '')) return; + const name = draft; + if (await change((current) => withHostName(current, name))) + setDraft((current) => (current === name ? (hostLookName(name) ?? '') : current)); + }; + return ( + { + void save(); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' || event.nativeEvent.isComposing) return; + event.preventDefault(); + void save(); + }} + /> + ); +} + +export function HostLookSettings({ + hostId, + name, + t, +}: { + hostId: string; + name: string; + t: BotHarnessTranslate; +}): ReactElement { + const scheme = useBotColorScheme(); + const look = useHostLook(hostId); + const icon = look.icon; + const [error, setError] = useState(false); + const storageError = useHostLookError(); + const change = async (transform: (current: HostLook) => HostLook): Promise => { + try { + await hostLooks().update(hostId, transform); + setError(false); + return true; + } catch { + setError(true); + return false; + } + }; + const refresh = async () => { + try { + await hostLooks().refresh(); + setError(false); + } catch { + setError(true); + } + }; + return ( + +
+ {t('remoteHosts.look.hint')} + {error || storageError ? ( +
+ + {t( + storageError === 'host-look-lock-unavailable' + ? 'remoteHosts.look.unsupported' + : 'remoteHosts.look.failed', + )} + + +
+ ) : null} + +
+ {t('remoteHosts.look.symbols')} +
+ {HOST_SYMBOL_IDS.map((id) => { + const chosen = icon.kind === 'symbol' && icon.id === id; + return ( + + ); + })} +
+
+
+ {t('remoteHosts.look.emoji')} +
+ {HOST_EMOJI.map((emoji) => { + const chosen = icon.kind === 'emoji' && icon.emoji === emoji; + return ( + + ); + })} +
+
+
+ {t('remoteHosts.look.color')} +
+ + {HOST_COLOR_IDS.map((id) => ( + + ))} +
+
+
+
+ ); +} diff --git a/packages/client/src/client/host-looks.ts b/packages/client/src/client/host-looks.ts index 0bee4a6af5..ddd38ac666 100644 --- a/packages/client/src/client/host-looks.ts +++ b/packages/client/src/client/host-looks.ts @@ -3,6 +3,7 @@ import { useSyncExternalStore } from 'react'; import { createHostLooks, type HostLooks } from '../../../../apps/mobile/src/host/host-look.js'; import { DEFAULT_HOST_LOOK, + shownHostName, HOST_COLOR_IDS, HOST_SYMBOL_IDS, hostColorInk, @@ -87,13 +88,48 @@ export function unusedHostLook(used: readonly HostLook[]): HostLook { return { icon: { kind: 'symbol', id: symbol }, color }; } -export function browserHostLooks(storage: Storage | undefined): HostLooks { +export function browserHostLooks( + storage: Storage | undefined, + lock: (work: () => Promise) => Promise = async (work) => { + if (globalThis.navigator?.locks === undefined) throw new Error('host-look-lock-unavailable'); + let acquired = false; + try { + await navigator.locks.request(HOST_LOOKS_STORAGE_KEY, { mode: 'exclusive' }, async () => { + acquired = true; + await work(); + }); + } catch (error) { + if (!acquired) throw new Error('host-look-lock-unavailable'); + throw error; + } + }, +): HostLooks { return createHostLooks({ - read: async () => JSON.parse(storage?.getItem(HOST_LOOKS_STORAGE_KEY) ?? '{}') as unknown, - write: (value) => { - try { - storage?.setItem(HOST_LOOKS_STORAGE_KEY, JSON.stringify(value)); - } catch {} + read: async () => { + if (storage === undefined) throw new Error('storage-unavailable'); + const value: unknown = JSON.parse(storage.getItem(HOST_LOOKS_STORAGE_KEY) ?? '{}'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new Error('storage-invalid'); + return value; + }, + write: (value, check) => { + if (storage === undefined) throw new Error('storage-unavailable'); + const encoded = JSON.stringify(value); + check?.(); + storage.setItem(HOST_LOOKS_STORAGE_KEY, encoded); + }, + transaction: lock, + listen: (changed) => { + if (typeof window === 'undefined') return () => {}; + const listener = (event: StorageEvent) => { + if ( + event.storageArea === storage && + (event.key === HOST_LOOKS_STORAGE_KEY || event.key === null) + ) + changed(); + }; + window.addEventListener('storage', listener); + return () => window.removeEventListener('storage', listener); }, }); } @@ -129,3 +165,12 @@ export function useHostInk(color: HostColorId | undefined): string | undefined { if (color === undefined) return undefined; return hostColorInk(scheme, INK_ROLES[scheme] as unknown as Record, color); } + +export function useHostName(hostId: string | undefined, name: string): string { + return shownHostName(useHostLook(hostId), name); +} + +export function useHostLookError(): string | undefined { + const looks = hostLooks(); + return useSyncExternalStore(looks.subscribe, looks.error, looks.error); +} diff --git a/packages/client/src/client/host-settings.tsx b/packages/client/src/client/host-settings.tsx new file mode 100644 index 0000000000..011b0d8a13 --- /dev/null +++ b/packages/client/src/client/host-settings.tsx @@ -0,0 +1,165 @@ +import { useMemo, useState, useSyncExternalStore } from 'react'; +import * as stylex from '@stylexjs/stylex'; +import { HostGlyph } from './host-glyph.js'; +import { HostLookSettings } from './host-look-settings.js'; +import { useHostLook } from './host-looks.js'; +import { shownHostName } from '../../../../apps/mobile/src/ui/design/host-look.js'; +import { RemoteNotificationSettings } from './remote-notification-settings.js'; +import { ChannelSidebarIcon } from './channel-sidebar-icon.js'; +import { color, space, text } from './ds/tokens.stylex.js'; +import { SettingsDialog } from './ds/settings-dialog.js'; +import { Note } from './ds/note.js'; +import type { BotHarnessTranslate } from './locale.js'; +import { OverviewUsageView } from './overview-usage-view.js'; +import { OverviewMemoryView } from './overview-memory-view.js'; +import { RemoteHostModels } from './remote-host-models.js'; +import { remoteHosts } from './remote-hosts.js'; +import { remoteHostSettingsSource } from './remote-host-settings-source.js'; +import { StorageSettings } from './storage-settings.js'; +import type { HostClient } from '../../../../apps/mobile/src/host/client.js'; + +const styles = stylex.create({ + identity: { + display: 'flex', + alignItems: 'center', + gap: space.x3, + paddingInline: space.x3, + minWidth: 0, + }, + label: { display: 'flex', flexDirection: 'column', gap: space.x1, minWidth: 0 }, + name: { + color: color.fg, + fontSize: text.titleSize, + lineHeight: text.titleLine, + fontWeight: text.strong, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + kind: { color: color.muted, fontSize: text.metaSize, lineHeight: text.metaLine }, +}); + +export function HostSettings({ + hostId, + name, + local = false, + onClose, + t, +}: { + hostId: string; + name: string; + local?: boolean; + onClose: () => void; + t: BotHarnessTranslate; +}) { + const sources = remoteHosts(); + const snapshot = useSyncExternalStore( + sources.subscribe, + sources.getSnapshot, + sources.getSnapshot, + ); + const remote = snapshot.hosts.find((entry) => entry.host.hostId === hostId); + const client = sources.client(hostId); + const look = useHostLook(hostId); + const displayName = shownHostName(look, name); + const [section, setSection] = useState('appearance'); + const sections = [ + { + id: 'appearance', + label: t('remoteHosts.look.appearance'), + icon: , + }, + ...(local + ? [] + : [ + { + id: 'notifications', + label: t('remoteHosts.notifications'), + icon: , + }, + { + id: 'models', + label: t('remoteHosts.settings.models'), + icon: , + }, + { + id: 'usage', + label: t('overviewUsage.title'), + icon: , + }, + { id: 'storage', label: t('storage.title'), icon: }, + ]), + ]; + if (!local && remote === undefined) return null; + return ( + + + + + {displayName} + + {t('remoteHosts.settings')} + + + } + sections={sections} + activeId={section} + onSelect={setSection} + onClose={onClose} + labels={{ + nav: t('remoteHosts.settings'), + close: t('common.close'), + picker: (label) => label, + }} + > + {section === 'appearance' ? ( + + ) : section === 'notifications' ? ( + + ) : client === undefined || remote?.snapshot.connection.status !== 'connected' ? ( + + {t( + remote?.snapshot.connection.status === 'refused' + ? 'remoteHosts.refused' + : 'remoteHosts.offline', + { name: displayName }, + )} + + ) : ( + + )} + + ); +} + +function SettingsContent({ + client, + name, + section, + t, +}: { + client: HostClient; + name: string; + section: string; + t: BotHarnessTranslate; +}) { + const source = useMemo(() => remoteHostSettingsSource(client), [client]); + if (section === 'models') return ; + if (section === 'storage') + return ( + + ); + return ( + <> + + + + ); +} diff --git a/packages/client/src/client/host-tabs.tsx b/packages/client/src/client/host-tabs.tsx index 46787452b8..4aad89b729 100644 --- a/packages/client/src/client/host-tabs.tsx +++ b/packages/client/src/client/host-tabs.tsx @@ -4,7 +4,7 @@ import * as stylex from '@stylexjs/stylex'; import type { RemoteHost, RemoteConversationTarget } from './remote-hosts.js'; import { ALL_HOSTS, remoteHosts, THIS_HOST } from './remote-hosts.js'; import { UnreadCount } from './unread-count.js'; -import { RemoteHostSettings } from './remote-host-settings.js'; +import { HostSettings } from './host-settings.js'; import { ChannelSidebarIcon } from './channel-sidebar-icon.js'; import { RemoteAvatar } from './remote-avatar.js'; import { ConfirmDialog, FormDialog } from './ds/form-dialog.js'; @@ -13,22 +13,16 @@ import { TextField } from './ds/field.js'; import { Tooltip } from './ds/tooltip.js'; import { border, color, radius, size, space, text } from './ds/tokens.stylex.js'; import type { BotHarnessKey, BotHarnessTranslate } from './locale.js'; -import { RemoteNotificationSettings } from './remote-notification-settings.js'; +import { useThisHost } from './this-host.js'; +export { useThisHost } from './this-host.js'; +import { HostGlyph } from './host-glyph.js'; +export { HostGlyph } from './host-glyph.js'; import { - HOST_COLOR_IDS, - HOST_COLORS, - HOST_EMOJI, - HOST_SYMBOL_IDS, + DEFAULT_HOST_LOOK, + shownHostName, type HostLook, } from '../../../../apps/mobile/src/ui/design/host-look.js'; -import { useBotColorScheme } from './bot-color-scheme.js'; -import { - HOST_SYMBOL_GLYPHS, - hostLooks, - unusedHostLook, - useHostInk, - useHostLook, -} from './host-looks.js'; +import { hostLooks, unusedHostLook, useHostLook, useHostName } from './host-looks.js'; const focusRing = { default: 'none', ':focus-visible': `${border.focus} solid ${color.accent}` }; @@ -67,9 +61,6 @@ const styles = stylex.create({ color: { default: color.fg, ':hover': color.fg }, }, offline: { filter: 'grayscale(1)', opacity: 0.5 }, - glyph: { display: 'inline-flex', flexShrink: 0, alignItems: 'center', justifyContent: 'center' }, - emoji: { fontSize: text.titleSize, lineHeight: text.metaLine }, - ink: (value: string) => ({ color: value }), badge: { position: 'absolute', top: 0, @@ -94,96 +85,13 @@ const styles = stylex.create({ fontSize: text.metaSize, lineHeight: text.metaLine, }, - section: { display: 'flex', flexDirection: 'column', gap: space.x2 }, - sectionName: { color: color.subtle, fontSize: text.metaSize, lineHeight: text.metaLine }, - grid: { display: 'flex', flexWrap: 'wrap', gap: space.x1 }, - choice: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: size.icon, - height: size.icon, - padding: 0, - borderWidth: border.hairline, - borderStyle: 'solid', - borderColor: 'transparent', - borderRadius: radius.sm, - backgroundColor: { default: 'transparent', ':hover': color.hover }, - color: color.fg, - fontFamily: 'inherit', - cursor: 'pointer', - outline: focusRing, - outlineOffset: '-2px', - }, - chosen: { borderColor: color.accent, backgroundColor: color.selected }, - swatch: (value: string) => ({ - width: '14px', - height: '14px', - borderRadius: radius.round, - backgroundColor: value, - }), }); -interface ThisHostInfo { - id?: string; - name?: string; -} - -let local: ThisHostInfo = {}; -let localRequested = false; -const localListeners = new Set<() => void>(); - -function subscribeLocal(listener: () => void): () => void { - localListeners.add(listener); - if (!localRequested) { - localRequested = true; - void fetch('./api/botharness/devices') - .then((response) => (response.ok ? response.json() : undefined)) - .then((value: { host?: { id?: unknown; name?: unknown } } | undefined) => { - const host = value?.host; - local = { - ...(typeof host?.id === 'string' ? { id: host.id } : {}), - ...(typeof host?.name === 'string' ? { name: host.name } : {}), - }; - for (const notify of localListeners) notify(); - }) - .catch(() => { - localRequested = false; - }); - } - return () => { - localListeners.delete(listener); - }; -} - -export function useThisHost(): ThisHostInfo { - return useSyncExternalStore( - subscribeLocal, - () => local, - () => local, - ); -} - export function useRemoteHosts() { const sources = remoteHosts(); return useSyncExternalStore(sources.subscribe, sources.getSnapshot, sources.getSnapshot); } -export function HostGlyph({ look, size: glyphSize = 16 }: { look: HostLook; size?: number }) { - const ink = useHostInk(look.color); - if (look.icon.kind === 'emoji') - return ( - - ); - return ( - - ); -} - export function HostMark({ hostId }: { hostId?: string | undefined }): ReactElement { const thisHost = useThisHost(); const look = useHostLook(hostId ?? thisHost.id); @@ -251,28 +159,59 @@ function HostTab({ ); } +function RemoteHostTab({ + remote, + selected, + onMenu, + t, +}: { + remote: RemoteHost; + selected: boolean; + onMenu: (point: MenuPoint) => void; + t: BotHarnessTranslate; +}) { + const look = useHostLook(remote.host.hostId); + const name = shownHostName(look, remote.host.hostName); + const unread = unreadCount(remote); + return ( + 0 + ? t('remoteHosts.unreadNamed', { name, count: unread }) + : name + } + look={look} + selected={selected} + offline={!online(remote)} + unread={unread} + onMenu={onMenu} + /> + ); +} + export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null { const sources = remoteHosts(); const snapshot = useRemoteHosts(); const thisHost = useThisHost(); - const looks = hostLooks(); - useSyncExternalStore( - looks.subscribe, - () => looks, - () => looks, - ); + const localLook = useHostLook(thisHost.id ?? THIS_HOST); const [menu, setMenu] = useState<{ hostId: string; point: MenuPoint } | undefined>(undefined); const [removing, setRemoving] = useState(undefined); - const [notificationHost, setNotificationHost] = useState(); - const [styling, setStyling] = useState<{ hostId: string; name: string } | undefined>(undefined); - const [settings, setSettings] = useState<{ hostId: string; name: string }>(); + const [settings, setSettings] = useState<{ hostId: string; name: string; local: boolean }>(); const proxy = useRef(null); - if (snapshot.hosts.length === 0) return null; + const thisId = thisHost.id ?? THIS_HOST; - const thisName = thisHost.name ?? t('remoteHosts.this'); + const thisName = shownHostName(localLook, thisHost.name ?? t('remoteHosts.this')); const selectedRemote = snapshot.hosts.find((remote) => remote.host.hostId === snapshot.tab); const menuRemote = snapshot.hosts.find((remote) => remote.host.hostId === menu?.hostId); - const menuName = menuRemote?.host.hostName ?? thisName; + const selectedName = useHostName( + selectedRemote?.host.hostId, + selectedRemote?.host.hostName ?? '', + ); + const removingName = useHostName(removing?.host.hostId, removing?.host.hostName ?? ''); + if (snapshot.hosts.length === 0) return null; return ( <>
setMenu({ hostId: thisId, point })} /> - {snapshot.hosts.map((remote) => { - const unread = unreadCount(remote); - const name = remote.host.hostName; - return ( - 0 - ? t('remoteHosts.unreadNamed', { name, count: unread }) - : name - } - look={looks.look(remote.host.hostId)} - selected={snapshot.tab === remote.host.hostId} - offline={!online(remote)} - unread={unread} - onMenu={(point) => setMenu({ hostId: remote.host.hostId, point })} - /> - ); - })} + {snapshot.hosts.map((remote) => ( + setMenu({ hostId: remote.host.hostId, point })} + t={t} + /> + ))}
{selectedRemote !== undefined && !online(selectedRemote) ? (

{selectedRemote.snapshot.connection.status === 'refused' - ? t('remoteHosts.refused', { name: selectedRemote.host.hostName }) - : t('remoteHosts.offline', { name: selectedRemote.host.hostName })} + ? t('remoteHosts.refused', { name: selectedName }) + : t('remoteHosts.offline', { name: selectedName })}

) : null} {menu === undefined ? null : ( @@ -336,57 +262,38 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null autoFocus anchor={ )} - {styling === undefined ? null : ( - setStyling(undefined)} - t={t} - /> - )} {settings === undefined ? null : ( - setSettings(undefined)} t={t} /> )} - {notificationHost === undefined ? null : ( - setNotificationHost(undefined)} - /> - )} {removing === undefined ? null : ( void; - t: BotHarnessTranslate; -}): ReactElement { - const scheme = useBotColorScheme(); - const [look, setLook] = useState(() => hostLooks().look(hostId)); - const icon = look.icon; - return ( - { - hostLooks().set(hostId, look); - onClose(); - }} - > -
- {t('remoteHosts.look.symbols')} -
- {HOST_SYMBOL_IDS.map((id) => { - const chosen = icon.kind === 'symbol' && icon.id === id; - return ( - - ); - })} -
-
-
- {t('remoteHosts.look.emoji')} -
- {HOST_EMOJI.map((emoji) => { - const chosen = icon.kind === 'emoji' && icon.emoji === emoji; - return ( - - ); - })} -
-
-
- {t('remoteHosts.look.color')} -
- - {HOST_COLOR_IDS.map((id) => ( - - ))} -
-
-
- ); -} - const PAIR_ERRORS: Partial> = { 'link-invalid': 'remoteHosts.error.link-invalid', 'link-expired': 'remoteHosts.error.link-expired', @@ -568,7 +361,7 @@ export function AddRemoteHostDialog({ }), onPending: setDeviceFingerprint, }) - .then((result) => { + .then(async (result) => { setBusy(false); if (!result.ok) { setError(t(PAIR_ERRORS[result.code] ?? 'remoteHosts.error.unreachable')); @@ -582,7 +375,11 @@ export function AddRemoteHostDialog({ .hosts.filter((remote) => remote.host.hostId !== result.host.hostId) .map((remote) => looks.look(remote.host.hostId)), ]; - looks.set(result.host.hostId, unusedHostLook(used)); + try { + await looks.update(result.host.hostId, (current) => + current === DEFAULT_HOST_LOOK ? unusedHostLook(used) : current, + ); + } catch {} onClose(); }); }; @@ -685,6 +482,7 @@ export function RemoteHostRows({ }): ReactElement | null { const sources = remoteHosts(); const { open, avatars } = useRemoteHosts(); + const name = useHostName(remote.host.hostId, remote.host.hostName); const needle = query.trim().toLocaleLowerCase(); const visible = rows(remote).filter( (row) => needle.length === 0 || row.title.toLocaleLowerCase().includes(needle), @@ -699,9 +497,7 @@ export function RemoteHostRows({ className={`bh-channel-row${row.unread > 0 ? ' bh-has-unread' : ''}${sameTarget(open, row) ? ' bh-selected' : ''}`} data-remote-row={row.key} aria-label={ - showSource - ? t('remoteHosts.rowLabel', { name: row.title, host: remote.host.hostName }) - : row.title + showSource ? t('remoteHosts.rowLabel', { name: row.title, host: name }) : row.title } onClick={() => { onOpen?.(); @@ -741,7 +537,7 @@ export function RemoteHostRows({ return (
- {remote.host.hostName} + {name} {visible.length}
{list} diff --git a/packages/client/src/client/locale.ts b/packages/client/src/client/locale.ts index 67b7a15f8c..dea99127c3 100644 --- a/packages/client/src/client/locale.ts +++ b/packages/client/src/client/locale.ts @@ -3936,14 +3936,49 @@ export const zh = { 'remoteHosts.thisNamed': '本机 · {name}', 'remoteHosts.unreadNamed': '{name} · {count} 条未读', 'remoteHosts.look': '更改图标…', - 'remoteHosts.settings': '远程设置', + 'remoteHosts.look.appearance': '外观', + 'remoteHosts.look.name': '名称', + 'remoteHosts.look.hint': '仅保存在此浏览器。', + 'remoteHosts.look.failed': '无法读取或保存此浏览器的外观。请刷新后重试。', + 'remoteHosts.look.unsupported': '此浏览器不支持安全保存外观。请使用支持 Web Locks 的浏览器。', + 'remoteHosts.look.refresh': '刷新外观', + 'remoteHosts.settings.title': '{name} 的设置', + 'remoteHosts.look.symbol.desktop': '台式电脑', + 'remoteHosts.look.symbol.laptop': '笔记本', + 'remoteHosts.look.symbol.display': '显示器', + 'remoteHosts.look.symbol.server': '服务器', + 'remoteHosts.look.symbol.terminal': '终端', + 'remoteHosts.look.symbol.cpu': '芯片', + 'remoteHosts.look.symbol.home': '家', + 'remoteHosts.look.symbol.office': '办公室', + 'remoteHosts.look.symbol.work': '工作', + 'remoteHosts.look.symbol.lab': '实验室', + 'remoteHosts.look.symbol.game': '游戏', + 'remoteHosts.look.symbol.art': '艺术', + 'remoteHosts.look.symbol.book': '书籍', + 'remoteHosts.look.symbol.globe': '地球', + 'remoteHosts.look.symbol.bolt': '闪电', + 'remoteHosts.look.symbol.star': '星星', + 'remoteHosts.look.symbol.heart': '爱心', + 'remoteHosts.look.symbol.leaf': '叶子', + 'remoteHosts.look.color.blue': '蓝色', + 'remoteHosts.look.color.indigo': '靛蓝', + 'remoteHosts.look.color.purple': '紫色', + 'remoteHosts.look.color.pink': '粉色', + 'remoteHosts.look.color.red': '红色', + 'remoteHosts.look.color.orange': '橙色', + 'remoteHosts.look.color.yellow': '黄色', + 'remoteHosts.look.color.green': '绿色', + 'remoteHosts.look.color.teal': '青色', + 'remoteHosts.look.color.graphite': '石墨色', + 'remoteHosts.settings': 'Host 设置', 'remoteHosts.settings.models': '模型', 'remoteHosts.settings.storageTitle': '{name} · 存储空间', 'remoteHosts.settings.error': '无法读取或保存设置,请重试。', 'remoteHosts.look.title': '{name} 的图标', 'remoteHosts.look.symbols': '符号', 'remoteHosts.look.emoji': 'Emoji', - 'remoteHosts.look.color': '颜色', + 'remoteHosts.look.color': '图标颜色', 'remoteHosts.look.noColor': '不设颜色', 'remoteHosts.look.save': '保存', 'remoteHosts.remote': '远程', @@ -3954,7 +3989,7 @@ export const zh = { 'connect.pending': '请在电脑上批准指纹如下的设备:', 'connect.approve': '批准设备', 'connect.fingerprint': 'Host 指纹', - 'remoteHosts.notifications': '远程通知', + 'remoteHosts.notifications': '通知', 'remoteHosts.notifications.title': '{name} · 通知', 'remoteHosts.notifications.hint': '仅在此页面打开时通知。选择保存在此浏览器。', 'remoteHosts.notifications.enable': '允许通知', @@ -8331,14 +8366,51 @@ My relationship with you, established lore and topics to avoid: [fill in]. 'remoteHosts.thisNamed': 'This host · {name}', 'remoteHosts.unreadNamed': '{name} · {count} unread', 'remoteHosts.look': 'Change icon…', - 'remoteHosts.settings': 'Remote settings', + 'remoteHosts.look.appearance': 'Appearance', + 'remoteHosts.look.name': 'Name', + 'remoteHosts.look.hint': 'Saved in this browser only.', + 'remoteHosts.look.failed': + 'Could not read or save this browser’s appearance. Refresh and try again.', + 'remoteHosts.look.unsupported': + 'This browser cannot safely save appearance. Use a browser with Web Locks support.', + 'remoteHosts.look.refresh': 'Refresh appearance', + 'remoteHosts.settings.title': 'Settings for {name}', + 'remoteHosts.look.symbol.desktop': 'Desktop', + 'remoteHosts.look.symbol.laptop': 'Laptop', + 'remoteHosts.look.symbol.display': 'Display', + 'remoteHosts.look.symbol.server': 'Server', + 'remoteHosts.look.symbol.terminal': 'Terminal', + 'remoteHosts.look.symbol.cpu': 'Cpu', + 'remoteHosts.look.symbol.home': 'Home', + 'remoteHosts.look.symbol.office': 'Office', + 'remoteHosts.look.symbol.work': 'Work', + 'remoteHosts.look.symbol.lab': 'Lab', + 'remoteHosts.look.symbol.game': 'Game', + 'remoteHosts.look.symbol.art': 'Art', + 'remoteHosts.look.symbol.book': 'Book', + 'remoteHosts.look.symbol.globe': 'Globe', + 'remoteHosts.look.symbol.bolt': 'Bolt', + 'remoteHosts.look.symbol.star': 'Star', + 'remoteHosts.look.symbol.heart': 'Heart', + 'remoteHosts.look.symbol.leaf': 'Leaf', + 'remoteHosts.look.color.blue': 'Blue', + 'remoteHosts.look.color.indigo': 'Indigo', + 'remoteHosts.look.color.purple': 'Purple', + 'remoteHosts.look.color.pink': 'Pink', + 'remoteHosts.look.color.red': 'Red', + 'remoteHosts.look.color.orange': 'Orange', + 'remoteHosts.look.color.yellow': 'Yellow', + 'remoteHosts.look.color.green': 'Green', + 'remoteHosts.look.color.teal': 'Teal', + 'remoteHosts.look.color.graphite': 'Graphite', + 'remoteHosts.settings': 'Host settings', 'remoteHosts.settings.models': 'Models', 'remoteHosts.settings.storageTitle': '{name} · Storage', 'remoteHosts.settings.error': 'Could not load or save settings. Try again.', 'remoteHosts.look.title': 'Icon for {name}', 'remoteHosts.look.symbols': 'Symbols', 'remoteHosts.look.emoji': 'Emoji', - 'remoteHosts.look.color': 'Color', + 'remoteHosts.look.color': 'Icon color', 'remoteHosts.look.noColor': 'No color', 'remoteHosts.look.save': 'Save', 'remoteHosts.remote': 'Remote', @@ -8349,7 +8421,7 @@ My relationship with you, established lore and topics to avoid: [fill in]. 'connect.pending': 'On the computer, approve the device with this fingerprint:', 'connect.approve': 'Approve device', 'connect.fingerprint': 'Host fingerprint', - 'remoteHosts.notifications': 'Remote notifications', + 'remoteHosts.notifications': 'Notifications', 'remoteHosts.notifications.title': '{name} · Notifications', 'remoteHosts.notifications.hint': 'Alerts while this page is open. Choices stay in this browser.', 'remoteHosts.notifications.enable': 'Allow notifications', diff --git a/packages/client/src/client/remote-activity.tsx b/packages/client/src/client/remote-activity.tsx index 25baf40392..4c56f6c88b 100644 --- a/packages/client/src/client/remote-activity.tsx +++ b/packages/client/src/client/remote-activity.tsx @@ -1,3 +1,5 @@ +import { useHostName, hostLooks } from './host-looks.js'; +import { shownHostName } from '../../../../apps/mobile/src/ui/design/host-look.js'; import { useMemo, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'; import * as stylex from '@stylexjs/stylex'; import { @@ -288,7 +290,7 @@ export function RemoteActivityGroup({ channelId, title: current.snapshot.roster?.channels.find((channel) => channel.id === channelId)?.name ?? - current.host.hostName, + shownHostName(hostLooks().look(current.host.hostId), current.host.hostName), }); registry?.open(CHANNEL_BODY_PANEL); }, @@ -296,6 +298,7 @@ export function RemoteActivityGroup({ [sources, host.host.hostId, observation, registry, t], ); const [collapsed, setCollapsed] = useState>>({}); + const hostName = useHostName(host.host.hostId, host.host.hostName); if (query.botSlug !== undefined || query.channelId !== undefined) return null; const refused = host.snapshot.connection.status === 'refused'; const bots = (host.snapshot.roster?.bots ?? []).map((bot) => ({ @@ -322,11 +325,11 @@ export function RemoteActivityGroup({ registry?.open(CHANNEL_BODY_PANEL); }; return ( -
- +
+ {host.snapshot.connection.status !== 'connected' ? (

- {t(refused ? 'remoteHosts.refused' : 'remoteHosts.offline', { name: host.host.hostName })} + {t(refused ? 'remoteHosts.refused' : 'remoteHosts.offline', { name: hostName })}

) : null} {refused diff --git a/packages/client/src/client/remote-conversation-view.tsx b/packages/client/src/client/remote-conversation-view.tsx index b77e1ca5d7..b0c0ae708f 100644 --- a/packages/client/src/client/remote-conversation-view.tsx +++ b/packages/client/src/client/remote-conversation-view.tsx @@ -1,3 +1,4 @@ +import { useHostName } from './host-looks.js'; import { ChannelMessageBody } from './channel-message-body.js'; import { RemoteMessageBody } from './remote-message-body.js'; import { MessageHostContext, type MessageHost } from './message-host.js'; @@ -179,6 +180,10 @@ export function RemoteConversationView({ }): ReactElement { const sources = remoteHosts(); const { hosts, avatars } = useRemoteHosts(); + const hostName = useHostName( + target.hostId, + hosts.find((entry) => entry.host.hostId === target.hostId)?.host.hostName ?? '', + ); const remote = hosts.find((candidate) => candidate.host.hostId === target.hostId); const conversation = useMemo( () => sources.conversation(target), @@ -258,7 +263,7 @@ export function RemoteConversationView({ {target.title} - {remote?.host.hostName ?? ''} + {hostName} {!unavailable && bot?.browserAccess ? ( @@ -298,7 +303,7 @@ export function RemoteConversationView({ client={viewerClient} kind={viewer} slug={bot.slug} - hostName={remote?.host.hostName ?? ''} + hostName={hostName} t={t} onClose={() => setViewer(undefined)} /> @@ -306,7 +311,7 @@ export function RemoteConversationView({ <> {!reachable ? (

- {t('remoteHosts.offline', { name: remote?.host.hostName ?? '' })} + {t('remoteHosts.offline', { name: hostName })}

) : snapshot.status === 'interrupted' ? (

diff --git a/packages/client/src/client/remote-host-settings.tsx b/packages/client/src/client/remote-host-settings.tsx deleted file mode 100644 index 4373484b28..0000000000 --- a/packages/client/src/client/remote-host-settings.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useMemo, useState, useSyncExternalStore } from 'react'; -import { SettingsDialog } from './ds/settings-dialog.js'; -import { Note } from './ds/note.js'; -import type { BotHarnessTranslate } from './locale.js'; -import { OverviewUsageView } from './overview-usage-view.js'; -import { OverviewMemoryView } from './overview-memory-view.js'; -import { RemoteHostModels } from './remote-host-models.js'; -import { remoteHosts } from './remote-hosts.js'; -import { remoteHostSettingsSource } from './remote-host-settings-source.js'; -import { StorageSettings } from './storage-settings.js'; -import type { HostClient } from '../../../../apps/mobile/src/host/client.js'; - -export function RemoteHostSettings({ - hostId, - name, - onClose, - t, -}: { - hostId: string; - name: string; - onClose: () => void; - t: BotHarnessTranslate; -}) { - const sources = remoteHosts(); - const snapshot = useSyncExternalStore( - sources.subscribe, - sources.getSnapshot, - sources.getSnapshot, - ); - const remote = snapshot.hosts.find((entry) => entry.host.hostId === hostId); - const client = sources.client(hostId); - const [section, setSection] = useState('models'); - const sections = [ - { id: 'models', label: t('remoteHosts.settings.models') }, - { id: 'usage', label: t('overviewUsage.title') }, - { id: 'storage', label: t('storage.title') }, - ]; - return ( - entry.id === section)!.label} - navTitle={t('remoteHosts.settings')} - caption={name} - sections={sections} - activeId={section} - onSelect={setSection} - onClose={onClose} - labels={{ - nav: t('remoteHosts.settings'), - close: t('common.close'), - picker: (label) => label, - }} - > - {client === undefined || remote?.snapshot.connection.status !== 'connected' ? ( - - {t( - remote?.snapshot.connection.status === 'refused' - ? 'remoteHosts.refused' - : 'remoteHosts.offline', - { name }, - )} - - ) : ( - - )} - - ); -} - -function SettingsContent({ - client, - name, - section, - t, -}: { - client: HostClient; - name: string; - section: string; - t: BotHarnessTranslate; -}) { - const source = useMemo(() => remoteHostSettingsSource(client), [client]); - if (section === 'models') return ; - if (section === 'storage') - return ( - - ); - return ( - <> - - - - ); -} diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index 8cee5525d4..6cdc99e3d1 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -1,3 +1,5 @@ +import { hostLooks } from './host-looks.js'; +import { shownHostName } from '../../../../apps/mobile/src/ui/design/host-look.js'; import type { HostCarrier } from '../../../../apps/mobile/src/host/carrier.js'; import { createHostClient, @@ -150,6 +152,7 @@ export function createRemoteHosts(options: { loadImage?: (url: string, headers: Record) => Promise; releaseImage?: (image: string) => void; translate?: NotificationTranslate; + hostName?: (hostId: string, fallback: string) => string; }): RemoteHosts { const { storage, carrier, loadImage, releaseImage } = options; const listeners = new Set<() => void>(); @@ -163,6 +166,7 @@ export function createRemoteHosts(options: { let open: RemoteConversationTarget | undefined; let snapshot: RemoteHostsSnapshot = { hosts: [], avatars, tab }; const notifications = createRemoteNotifications({ + ...(options.hostName === undefined ? {} : { hostName: options.hostName }), storage, api: browserNotificationApi(), async prepareEnable(hostId, signal) { @@ -537,6 +541,7 @@ export function remoteHosts(translator?: NotificationTranslate): RemoteHosts { loadImage: browserImage, releaseImage: (image) => URL.revokeObjectURL(image), translate: (key) => translate(key), + hostName: (hostId, fallback) => shownHostName(hostLooks().look(hostId), fallback), }); const sources = shared; let selection = store.getSnapshot().selection; diff --git a/packages/client/src/client/remote-notification-settings.tsx b/packages/client/src/client/remote-notification-settings.tsx index 2e9f226f41..92eb271a37 100644 --- a/packages/client/src/client/remote-notification-settings.tsx +++ b/packages/client/src/client/remote-notification-settings.tsx @@ -1,9 +1,9 @@ +import { useMountedResource } from './mounted-resource.js'; import { useState, useSyncExternalStore } from 'react'; import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'; import { PUSH_KINDS, type PushKind } from '../../../../apps/mobile/src/host/push.js'; import type { RemoteNotifications } from './remote-notifications.js'; import type { BotHarnessTranslate, BotHarnessKey } from './locale.js'; -import { SettingsDialog } from './ds/settings-dialog.js'; import { Note } from './ds/note.js'; import { SettingsRow, SettingsSection } from './ds/settings-row.js'; @@ -16,17 +16,17 @@ const labels: Record = { export function RemoteNotificationSettings({ hostId, - name, notifications, t, - onClose, }: { hostId: string; - name: string; notifications: RemoteNotifications; t: BotHarnessTranslate; - onClose(): void; }) { + const mount = useMountedResource( + () => () => notifications.cancel(hostId), + [notifications, hostId], + ); const snapshot = useSyncExternalStore(notifications.subscribe, notifications.getSnapshot); const choice = notifications.choice(hostId); const [busy, setBusy] = useState(false); @@ -40,18 +40,7 @@ export function RemoteNotificationSettings({ .finally(() => setBusy(false)); }; return ( - section }} - sections={[{ id: 'notifications', label: t('remoteHosts.notifications.title', { name }) }]} - activeId="notifications" - onSelect={() => {}} - onClose={() => { - notifications.cancel(hostId); - onClose(); - }} - > +

{(error || snapshot.permission === 'denied' || snapshot.permission === 'unsupported') && ( {error @@ -64,6 +53,7 @@ export function RemoteNotificationSettings({ )} + {t('remoteHosts.notifications.hint')} ))} - +
); } diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts index b9fc10572c..25026ae9de 100644 --- a/packages/client/src/client/remote-notifications.ts +++ b/packages/client/src/client/remote-notifications.ts @@ -56,6 +56,7 @@ export function createRemoteNotifications(options: { api?: DesktopNotificationApi | undefined; open(target: RemoteConversationTarget): void; privateBody(): string; + hostName?(hostId: string, fallback: string): string; prepareEnable(hostId: string, signal: AbortSignal): Promise; diagnostic?(code: NotificationDiagnostic): void; }) { @@ -315,7 +316,7 @@ export function createRemoteNotifications(options: { ) continue; const bot = remote.snapshot.roster?.bots.find((bot) => bot.slug === item.botSlug); - const title = `${remote.host.hostName} · ${item.channelName ?? bot?.displayName ?? item.botSlug}`; + const title = `${options.hostName?.(id, remote.host.hostName) ?? remote.host.hostName} · ${item.channelName ?? bot?.displayName ?? item.botSlug}`; try { const handle = api.show(title, { body: kind === 'private-input' ? options.privateBody() : noticeBody(item.summary), diff --git a/packages/client/src/client/this-host.ts b/packages/client/src/client/this-host.ts new file mode 100644 index 0000000000..4e92411e13 --- /dev/null +++ b/packages/client/src/client/this-host.ts @@ -0,0 +1,43 @@ +import { useSyncExternalStore } from 'react'; + +interface ThisHostInfo { + id?: string; + name?: string; +} + +export const getThisHost = (): ThisHostInfo => local; + +let local: ThisHostInfo = {}; +let localRequested = false; +const localListeners = new Set<() => void>(); + +function subscribeLocal(listener: () => void): () => void { + localListeners.add(listener); + if (!localRequested) { + localRequested = true; + void fetch('./api/botharness/devices') + .then((response) => (response.ok ? response.json() : undefined)) + .then((value: { host?: { id?: unknown; name?: unknown } } | undefined) => { + const host = value?.host; + local = { + ...(typeof host?.id === 'string' ? { id: host.id } : {}), + ...(typeof host?.name === 'string' ? { name: host.name } : {}), + }; + for (const notify of localListeners) notify(); + }) + .catch(() => { + localRequested = false; + }); + } + return () => { + localListeners.delete(listener); + }; +} + +export function useThisHost(): ThisHostInfo { + return useSyncExternalStore( + subscribeLocal, + () => local, + () => local, + ); +} diff --git a/packages/client/test/host-look-control.test.ts b/packages/client/test/host-look-control.test.ts new file mode 100644 index 0000000000..96bbdb1a77 --- /dev/null +++ b/packages/client/test/host-look-control.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createHostLooks } from '../../../apps/mobile/src/host/host-look.js'; +import { shownHostName, type HostLook } from '../../../apps/mobile/src/ui/design/host-look.js'; +import { controlHostLook } from '../src/client/host-look-control.js'; + +async function setup() { + let stored: Record = {}; + const write = vi.fn((value: Record) => { + stored = value; + }); + const looks = createHostLooks({ read: async () => stored, write }); + await looks.ready; + const abort = new AbortController(); + const request = { signal: abort.signal, expiresAt: Date.now() + 8000 }; + const available = (id: string) => id === 'host-b'; + const read = () => controlHostLook(looks, { hostId: 'host-b' }, undefined, request, available); + return { looks, write, abort, request, available, read }; +} + +afterEach(() => vi.useRealTimers()); + +describe('Host look Client control', () => { + it('writes the shared owner and persists the same normalized local name, icon and colour', async () => { + const { looks, write, request, available, read } = await setup(); + const before = await read(); + const saved = await controlHostLook( + looks, + { + hostId: 'host-b', + patch: { + name: ' Studio ', + icon: { kind: 'emoji', emoji: '🏠' }, + color: 'teal', + }, + }, + before.revision, + request, + available, + ); + expect(saved.look).toEqual({ + name: 'Studio', + icon: { kind: 'emoji', emoji: '🏠' }, + color: 'teal', + }); + expect(looks.look('host-b')).toEqual(saved.look); + expect(write).toHaveBeenLastCalledWith({ 'host-b': saved.look }, expect.any(Function)); + expect(shownHostName(saved.look, 'system-hostname')).toBe('Studio'); + expect(looks.look('host-a')).toEqual(before.look); + const reset = await controlHostLook( + looks, + { hostId: 'host-b', patch: { name: '', color: null } }, + saved.revision, + request, + available, + ); + expect(reset.look).toEqual({ icon: saved.look.icon }); + expect(shownHostName(reset.look, 'system-hostname')).toBe('system-hostname'); + }); + + it('refuses stale revisions and keeps the last known look after write failure', async () => { + const { looks, write, request, available, read } = await setup(); + const before = await read(); + looks.set('host-b', { ...before.look, name: 'UI change' }); + await expect( + controlHostLook( + looks, + { hostId: 'host-b', patch: { name: 'CLI change' } }, + before.revision, + request, + available, + ), + ).rejects.toThrow('host-look-revision-stale'); + const current = await read(); + write.mockImplementation(() => { + throw new Error('quota'); + }); + await expect( + controlHostLook( + looks, + { hostId: 'host-b', patch: { color: 'blue' } }, + current.revision, + request, + available, + ), + ).rejects.toThrow('host-look-storage-failed'); + expect(looks.look('host-b')).toEqual(current.look); + await expect(read()).rejects.toThrow('host-look-storage-failed'); + }); + + it.each(['expired', 'cancelled', 'removed', 'expires-before-write', 'removed-before-write'])( + 'does not persist a %s request', + async (reason) => { + const { write, looks, request, abort, read } = await setup(); + const before = await read(); + if (reason === 'expired') request.expiresAt = Date.now(); + if (reason === 'cancelled') abort.abort(); + let calls = 0; + const available = () => { + calls++; + if (reason === 'expires-before-write') request.expiresAt = Date.now(); + return reason !== 'removed' && (reason !== 'removed-before-write' || calls === 1); + }; + await expect( + controlHostLook( + looks, + { hostId: 'host-b', patch: { name: 'late' } }, + before.revision, + request, + available, + ), + ).rejects.toThrow(); + expect(write).not.toHaveBeenCalled(); + expect(looks.look('host-b')).toBe(before.look); + }, + ); + + it.each([ + { name: 42 }, + { icon: { kind: 'symbol', id: 'unknown' } }, + { color: 'invalid' }, + { arbitrary: true }, + null, + [], + ])('rejects invalid input before storage: %j', async (patch) => { + const { write, looks, request, available, read } = await setup(); + await expect( + controlHostLook( + looks, + { hostId: 'host-b', patch }, + (await read()).revision, + request, + available, + ), + ).rejects.toThrow('host-look-invalid-input'); + expect(write).not.toHaveBeenCalled(); + }); + + it('refuses reads and writes after failed hydration until an explicit refresh succeeds', async () => { + const stored = { 'host-b': { name: 'Stored', icon: { kind: 'symbol', id: 'server' } } }; + const read = vi.fn<() => Promise>().mockRejectedValue(new Error('denied')); + const write = vi.fn(); + const looks = createHostLooks({ read, write }); + await expect(looks.ready).resolves.toBeUndefined(); + const request = { signal: new AbortController().signal, expiresAt: Date.now() + 8000 }; + const control = (input: unknown, revision?: string) => + controlHostLook(looks, input, revision, request, () => true); + await expect(control({ hostId: 'host-b' })).rejects.toMatchObject({ + code: 'host-look-storage-read-failed', + }); + read.mockResolvedValue(stored); + await expect( + control( + { hostId: 'host-b', patch: { name: 'Overwrite' } }, + JSON.stringify(looks.look('host-b')), + ), + ).rejects.toThrow('host-look-storage-read-failed'); + expect(write).not.toHaveBeenCalled(); + expect(await control({ hostId: 'host-b', refresh: true })).toMatchObject({ + look: stored['host-b'], + }); + read.mockRejectedValue(new Error('locked read failed')); + await expect( + control({ hostId: 'host-b', patch: { name: 'Overwrite' } }, JSON.stringify(stored['host-b'])), + ).rejects.toThrow('host-look-storage-read-failed'); + expect(looks.look('host-b')).toEqual(stored['host-b']); + expect(write).not.toHaveBeenCalled(); + }); + + it('checks wall time after waiting for a lock even when no timeout callback has run', async () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + let release!: () => void; + let entered!: () => void; + const waiting = new Promise((resolve) => { + entered = resolve; + }); + let hold = false; + const write = vi.fn(); + const looks = createHostLooks({ + read: async () => ({}), + write, + transaction: async (work) => { + if (hold) { + entered(); + await new Promise((resolve) => { + release = resolve; + }); + } + await work(); + }, + }); + await looks.ready; + hold = true; + const pending = controlHostLook( + looks, + { hostId: 'host-b', patch: { name: 'Late' } }, + JSON.stringify(looks.look('host-b')), + { signal: new AbortController().signal, expiresAt: 1100 }, + () => true, + ); + const refused = expect(pending).rejects.toThrow('appearance-request-stale'); + await waiting; + vi.setSystemTime(1101); + release(); + await refused; + expect(write).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/test/host-look-owner.test.ts b/packages/client/test/host-look-owner.test.ts new file mode 100644 index 0000000000..d6a968a27a --- /dev/null +++ b/packages/client/test/host-look-owner.test.ts @@ -0,0 +1,37 @@ +import { expect, it, vi } from 'vitest'; +import { createHostLooks } from '../../../apps/mobile/src/host/host-look.js'; +import { DEFAULT_HOST_LOOK } from '../../../apps/mobile/src/ui/design/host-look.js'; + +it('preserves the native synchronous set and one-time legacy migration contracts', async () => { + const write = vi.fn(); + const forgetLegacyImages = vi.fn(); + const looks = createHostLooks({ + read: async () => ({ a: { name: 'Phone', banner: { kind: 'color', color: '#2f80ed' } } }), + write, + forgetLegacyImages, + }); + await looks.ready; + expect(looks.look('a')).toEqual({ name: 'Phone', icon: DEFAULT_HOST_LOOK.icon, color: 'blue' }); + expect(forgetLegacyImages).toHaveBeenCalledOnce(); + const result = looks.set('b', { ...DEFAULT_HOST_LOOK, name: 'Native edit' }); + expect(result).toBeUndefined(); + expect(looks.look('b').name).toBe('Native edit'); + expect(write).toHaveBeenLastCalledWith({ a: looks.look('a'), b: looks.look('b') }); +}); + +it('settles native readiness after a failed read without permitting a default-map overwrite', async () => { + const read = vi.fn<() => Promise>().mockRejectedValue(new Error('disk unavailable')); + const write = vi.fn(); + const looks = createHostLooks({ read, write }); + await expect(looks.ready).resolves.toBeUndefined(); + expect(() => looks.set('b', { ...DEFAULT_HOST_LOOK, name: 'Unsafe' })).not.toThrow(); + expect(write).not.toHaveBeenCalled(); + await expect(looks.read('b')).rejects.toThrow('host-look-storage-read-failed'); + read.mockResolvedValue({ a: { ...DEFAULT_HOST_LOOK, name: 'Existing' } }); + await looks.refresh(); + looks.set('b', { ...DEFAULT_HOST_LOOK, name: 'Recovered' }); + expect(write).toHaveBeenLastCalledWith({ + a: { ...DEFAULT_HOST_LOOK, name: 'Existing' }, + b: { ...DEFAULT_HOST_LOOK, name: 'Recovered' }, + }); +}); diff --git a/packages/client/test/host-look-r1-regression.test.ts b/packages/client/test/host-look-r1-regression.test.ts new file mode 100644 index 0000000000..cb603609f1 --- /dev/null +++ b/packages/client/test/host-look-r1-regression.test.ts @@ -0,0 +1,100 @@ +import { expect, it, vi } from 'vitest'; +import { createHostLooks } from '../../../apps/mobile/src/host/host-look.js'; +import { DEFAULT_HOST_LOOK, type HostLook } from '../../../apps/mobile/src/ui/design/host-look.js'; +import { runHostLook } from '../../core/src/bots/cli-host-look.js'; +import { controlHostLook } from '../src/client/host-look-control.js'; + +it('R1 P2-1 rejects a stale persisted revision across independent owners', async () => { + let stored: Record = {}; + let tail = Promise.resolve(); + const storage = { + read: async () => stored, + write: (value: Record) => { + stored = value; + }, + transaction: (work: () => Promise) => { + const pending = tail.then(work); + tail = pending.catch(() => {}); + return pending; + }, + }; + const a = createHostLooks(storage); + const b = createHostLooks(storage); + await Promise.all([a.ready, b.ready]); + const request = { signal: new AbortController().signal, expiresAt: Date.now() + 8000 }; + const before = await controlHostLook(b, { hostId: 'b' }, undefined, request, () => true); + await controlHostLook( + a, + { hostId: 'b', patch: { name: 'A saved' } }, + before.revision, + request, + () => true, + ); + await expect( + Promise.resolve().then(() => + controlHostLook( + b, + { hostId: 'b', patch: { name: 'B stale' } }, + before.revision, + request, + () => true, + ), + ), + ).rejects.toThrow('host-look-revision-stale'); + expect(stored.b?.name).toBe('A saved'); +}); + +it('R1 P2-2 does not submit a write after its CLI expiry is spent on stdin', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1000); + const http = vi.fn(async () => ({ bytes: new TextEncoder().encode('{}') })); + const connect = vi.fn(async () => ({ http })); + try { + await expect( + runHostLook( + [], + { 'client-id': 'a', 'host-id': 'b', 'patch-stdin': true, 'expected-revision': 'rev' }, + { + readStdin: async () => { + now.mockReturnValue(1101); + return '{"name":"Late"}'; + }, + }, + connect, + 1100, + ), + ).rejects.toMatchObject({ code: 'appearance-request-stale' }); + expect(connect).not.toHaveBeenCalled(); + expect(http).not.toHaveBeenCalled(); + } finally { + now.mockRestore(); + } +}); + +it('R1 P2-3 refuses a default read and overwrite after failed initial storage read', async () => { + const write = vi.fn(); + const looks = createHostLooks({ + read: async () => { + throw new Error('read denied'); + }, + write, + }); + await looks.ready; + const request = { signal: new AbortController().signal, expiresAt: Date.now() + 8000 }; + await expect( + Promise.resolve().then(() => + controlHostLook(looks, { hostId: 'b' }, undefined, request, () => true), + ), + ).rejects.toThrow('host-look-storage-read-failed'); + await expect( + Promise.resolve().then(() => + controlHostLook( + looks, + { hostId: 'b', patch: { name: 'Unsafe' } }, + JSON.stringify(DEFAULT_HOST_LOOK), + request, + () => true, + ), + ), + ).rejects.toThrow('host-look-storage-read-failed'); + expect(write).not.toHaveBeenCalled(); +}); diff --git a/packages/client/test/host-look-relay.test.ts b/packages/client/test/host-look-relay.test.ts new file mode 100644 index 0000000000..fac22d700a --- /dev/null +++ b/packages/client/test/host-look-relay.test.ts @@ -0,0 +1,146 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { createHostLooks } from '../../../apps/mobile/src/host/host-look.js'; +import { createAppearanceControl } from '../../core/src/appearance/control.js'; +import { Appearance } from '../src/client/appearance.js'; +import { mountAppearanceControl } from '../src/client/appearance-control.js'; + +const shared = vi.hoisted(() => new Map()); +vi.mock('../src/client/host-looks.js', () => ({ hostLooks: () => shared.get('looks') })); +vi.mock('../src/client/remote-hosts.js', () => ({ remoteHosts: () => shared.get('sources') })); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + shared.clear(); +}); + +it.each(['on-time', 'deadline', 'walltime', 'unmount', 'removed'] as const)( + 'checks %s after hydration at the existing relay boundary', + async (reason) => { + vi.useFakeTimers(); + const host = createAppearanceControl(); + let hydrate!: (value: unknown) => void; + const stored = { name: 'Stored', icon: { kind: 'symbol', id: 'server' }, color: 'teal' }; + const write = vi.fn(); + const read = vi.fn<() => Promise>().mockResolvedValue({ b: stored }); + read.mockImplementationOnce( + () => + new Promise((resolve) => { + hydrate = resolve; + }), + ); + const looks = createHostLooks({ read, write }); + shared.set('looks', looks); + let paired = true; + shared.set('sources', { + getSnapshot: () => ({ hosts: paired ? [{ host: { hostId: 'b' } }] : [] }), + }); + const actions: string[] = []; + vi.stubGlobal('location', { protocol: 'http:' }); + vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => { + actions.push(JSON.parse(String(init?.body)).action); + return host.fetch(new Request('http://localhost/api/botharness/appearance-control', init)); + }); + const release = mountAppearanceControl(new Appearance(undefined, () => 'light')); + try { + await vi.waitFor(() => expect(actions).toContain('poll')); + const { clients } = await (await host.fetch(new Request('http://localhost'))).json(); + const response = host.fetch( + new Request('http://localhost/api/botharness/appearance-control', { + method: 'POST', + body: JSON.stringify({ + action: 'request', + clientId: clients[0].id, + expiresAt: Date.now() + 8000, + expectedRevision: JSON.stringify(stored), + hostLook: { hostId: 'b', patch: { name: 'Studio' } }, + }), + }), + ); + await vi.advanceTimersByTimeAsync(0); + expect(write).not.toHaveBeenCalled(); + if (reason === 'deadline') await vi.advanceTimersByTimeAsync(8000); + if (reason === 'walltime') vi.setSystemTime(Date.now() + 8001); + if (reason === 'unmount') release(); + if (reason === 'removed') paired = false; + hydrate({ b: stored }); + await vi.advanceTimersByTimeAsync(0); + if (reason === 'walltime') { + expect(write).not.toHaveBeenCalled(); + expect(actions).not.toContain('reply'); + await vi.advanceTimersByTimeAsync(8000); + } + const result = await (await response).json(); + if (reason === 'on-time') { + expect(result).toMatchObject({ hostId: 'b', look: { name: 'Studio', color: 'teal' } }); + expect(write).toHaveBeenCalledTimes(1); + expect(looks.look('b').name).toBe('Studio'); + } else { + expect(result).toMatchObject({ + error: { + code: + reason === 'removed' + ? 'host-look-host-unknown' + : reason === 'deadline' || reason === 'walltime' + ? 'appearance-client-timeout' + : 'appearance-client-stale', + }, + }); + expect(write).not.toHaveBeenCalled(); + expect(looks.look('b')).toEqual(stored); + if (reason !== 'removed') expect(actions).not.toContain('reply'); + } + } finally { + release(); + host.dispose(); + } + }, +); + +it.each([100, 20000])( + 'caps the Host relay deadline using the caller absolute expiry (%s ms)', + async (duration) => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const host = createAppearanceControl(); + const post = (body: unknown) => + host.fetch(new Request('http://localhost', { method: 'POST', body: JSON.stringify(body) })); + try { + const registration = await (await post({ action: 'register', platform: 'web' })).json(); + const poll = post({ action: 'poll', ...registration }); + await vi.advanceTimersByTimeAsync(0); + const pending = post({ + action: 'request', + clientId: registration.clientId, + expiresAt: 1000 + duration, + hostLook: { hostId: 'b' }, + }); + const { command } = await (await poll).json(); + expect(command.expiresAt).toBe(1000 + Math.min(duration, 8000)); + await vi.advanceTimersByTimeAsync(Math.min(duration, 8000)); + expect(await (await pending).json()).toMatchObject({ + error: { code: 'appearance-client-timeout' }, + }); + } finally { + host.dispose(); + } + }, +); + +it('refuses a late Host look command before delivering it to the Client', async () => { + const host = createAppearanceControl(() => 1000); + const post = (body: unknown) => + host.fetch(new Request('http://localhost', { method: 'POST', body: JSON.stringify(body) })); + try { + const registration = await (await post({ action: 'register', platform: 'web' })).json(); + const response = await post({ + action: 'request', + clientId: registration.clientId, + expiresAt: 999, + hostLook: { hostId: 'b', patch: { name: 'Late' } }, + }); + expect(await response.json()).toMatchObject({ error: { code: 'appearance-request-stale' } }); + } finally { + host.dispose(); + } +}); diff --git a/packages/client/test/host-looks.test.ts b/packages/client/test/host-looks.test.ts index 24309fe116..3a2badea22 100644 --- a/packages/client/test/host-looks.test.ts +++ b/packages/client/test/host-looks.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_HOST_LOOK } from '../../../apps/mobile/src/ui/design/host-look.js'; +import { controlHostLook } from '../src/client/host-look-control.js'; +import { + DEFAULT_HOST_LOOK, + withHostColor, + withHostIcon, + withHostName, +} from '../../../apps/mobile/src/ui/design/host-look.js'; import { HOST_LOOKS_STORAGE_KEY, browserHostLooks, @@ -22,17 +28,137 @@ describe('unusedHostLook', () => { }); }); +function memoryStorage() { + const values = new Map(); + return { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + values.set(key, value); + }), + clear: () => values.clear(), + removeItem: (key: string) => values.delete(key), + key: (index: number) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + }; +} + +function lockManager() { + let tail = Promise.resolve(); + const request = vi.fn((_name: string, _options: unknown, work: () => Promise) => { + const result = tail.then(work); + tail = result.catch(() => {}); + return result; + }); + vi.stubGlobal('navigator', { locks: { request } }); + return request; +} + +afterEach(() => vi.unstubAllGlobals()); + describe('browserHostLooks', () => { - it('stores looks in this browser only', async () => { - const values = new Map(); - const storage = { - getItem: (key: string) => values.get(key) ?? null, - setItem: (key: string, value: string) => void values.set(key, value), - } as unknown as Storage; + it('uses the same Web Lock across owners and merges only the selected Host against persisted data', async () => { + const locks = lockManager(); + const storage = memoryStorage(); + const a = browserHostLooks(storage); + const b = browserHostLooks(storage); + await Promise.all([a.ready, b.ready]); + const request = { signal: new AbortController().signal, expiresAt: Date.now() + 8000 }; + const before = await controlHostLook(b, { hostId: 'b' }, undefined, request, () => true); + await a.update('b', (current) => withHostName(current, 'Tab A')); + await expect( + controlHostLook( + b, + { hostId: 'b', patch: { name: 'Tab B' } }, + before.revision, + request, + () => true, + ), + ).rejects.toThrow('host-look-revision-stale'); + await a.update('a', (current) => withHostName(current, 'Other Host')); + await b.update('b', (current) => withHostColor(current, 'teal')); + await Promise.all([ + a.update('b', (current) => withHostIcon(current, { kind: 'emoji', emoji: '🏠' })), + b.update('b', (current) => withHostName(current, 'Latest')), + ]); + const stored = JSON.parse(storage.getItem(HOST_LOOKS_STORAGE_KEY) ?? '{}'); + expect(stored.a.name).toBe('Other Host'); + expect(stored.b).toEqual({ + name: 'Latest', + icon: { kind: 'emoji', emoji: '🏠' }, + color: 'teal', + }); + expect( + locks.mock.calls.every( + ([name, options]) => + name === HOST_LOOKS_STORAGE_KEY && JSON.stringify(options) === '{"mode":"exclusive"}', + ), + ).toBe(true); + }); + + it('refuses unavailable Web Locks without reading or writing storage', async () => { + vi.stubGlobal('navigator', {}); + const storage = memoryStorage(); + const looks = browserHostLooks(storage); + await looks.ready; + await expect(looks.read('b')).rejects.toThrow('host-look-lock-unavailable'); + await expect(looks.update('b', (current) => withHostName(current, 'Unsafe'))).rejects.toThrow( + 'host-look-lock-unavailable', + ); + expect(storage.getItem).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('keeps known state and blocks writes after corrupt storage until explicit recovery', async () => { + lockManager(); + const storage = memoryStorage(); + const looks = browserHostLooks(storage); + await looks.update('b', (current) => withHostName(current, 'Known')); + const known = looks.look('b'); + storage.setItem(HOST_LOOKS_STORAGE_KEY, '{bad'); + await expect(looks.read('b')).rejects.toThrow('host-look-storage-read-failed'); + expect(looks.look('b')).toBe(known); + storage.setItem.mockClear(); + await expect(looks.update('b', (current) => withHostName(current, 'Unsafe'))).rejects.toThrow( + 'host-look-storage-read-failed', + ); + expect(storage.setItem).not.toHaveBeenCalled(); + storage.setItem(HOST_LOOKS_STORAGE_KEY, JSON.stringify({ b: known, a: DEFAULT_HOST_LOOK })); + await looks.refresh(); + await looks.update('b', (current) => withHostColor(current, 'blue')); + expect(JSON.parse(storage.getItem(HOST_LOOKS_STORAGE_KEY) ?? '{}')).toEqual({ + b: { ...known, color: 'blue' }, + a: DEFAULT_HOST_LOOK, + }); + }); + + it('checks expiry immediately before localStorage setItem', async () => { + lockManager(); + const storage = memoryStorage(); const looks = browserHostLooks(storage); - await Promise.resolve(); - looks.set('host-b', { icon: { kind: 'emoji', emoji: '🏠' }, color: 'green' }); - expect(looks.look('host-b')).toEqual({ icon: { kind: 'emoji', emoji: '🏠' }, color: 'green' }); - expect(JSON.parse(values.get(HOST_LOOKS_STORAGE_KEY) ?? '{}')).toHaveProperty('host-b'); + await looks.ready; + let expired = false; + const stringify = JSON.stringify; + vi.spyOn(JSON, 'stringify').mockImplementation((value) => { + const result = stringify(value); + expired = true; + return result; + }); + try { + await expect( + looks.update( + 'b', + (current) => withHostName(current, 'Late'), + () => { + if (expired) throw new Error('appearance-request-stale'); + }, + ), + ).rejects.toThrow('appearance-request-stale'); + expect(storage.setItem).not.toHaveBeenCalled(); + expect(looks.error()).toBeUndefined(); + } finally { + vi.restoreAllMocks(); + } }); }); diff --git a/packages/core/src/appearance/control.ts b/packages/core/src/appearance/control.ts index 8d1b795c15..f99c3bace5 100644 --- a/packages/core/src/appearance/control.ts +++ b/packages/core/src/appearance/control.ts @@ -8,6 +8,7 @@ interface Command { wallpaper?: unknown; recovery?: unknown; notifications?: unknown; + hostLook?: unknown; expectedRevision?: string; } interface Client { @@ -70,18 +71,36 @@ export function createAppearanceControl(now = Date.now) { if (!client) return refused('appearance-client-stale', 404); if (input.action === 'request') { if ( - [input.patch, input.wallpaper, input.recovery, input.notifications].filter( - (v) => v !== undefined, - ).length > 1 + [ + input.patch, + input.wallpaper, + input.recovery, + input.notifications, + input.hostLook, + ].filter((v) => v !== undefined).length > 1 ) return refused('appearance-invalid-input', 400); if (input.patch !== undefined && typeof input.expectedRevision !== 'string') return refused('appearance-revision-required', 400); + if ( + input.hostLook !== undefined && + (typeof input.expiresAt !== 'number' || + !Number.isFinite(input.expiresAt) || + input.expiresAt <= now()) + ) + return refused('appearance-request-stale'); + const expiresAt = + input.hostLook === undefined + ? now() + 8000 + : Math.min(now() + 8000, input.expiresAt as number); if (client.pending) return refused('appearance-client-busy'); if (!client.poll) return refused('appearance-client-unavailable'); const command: Command = { id: randomUUID(), - expiresAt: now() + 8000, + expiresAt, + ...(input.hostLook === undefined + ? {} + : { hostLook: input.hostLook, expectedRevision: input.expectedRevision as string }), ...(input.notifications === undefined ? {} : { @@ -99,10 +118,13 @@ export function createAppearanceControl(now = Date.now) { : { patch: input.patch, expectedRevision: input.expectedRevision as string }), }; return new Promise((resolve) => { - const timer = setTimeout(() => { - if (client.pending?.command === command) delete client.pending; - resolve(refused('appearance-client-timeout')); - }, 8000); + const timer = setTimeout( + () => { + if (client.pending?.command === command) delete client.pending; + resolve(refused('appearance-client-timeout')); + }, + Math.max(0, expiresAt - now()), + ); client.pending = { command, resolve: (response) => { @@ -123,7 +145,7 @@ export function createAppearanceControl(now = Date.now) { if ( !client.pending || client.pending.command.id !== input.requestId || - now() > client.pending.command.expiresAt + now() >= client.pending.command.expiresAt ) return refused('appearance-request-stale'); const result = record(input.result); diff --git a/packages/core/src/bots/cli-host-look.ts b/packages/core/src/bots/cli-host-look.ts new file mode 100644 index 0000000000..4edc70260f --- /dev/null +++ b/packages/core/src/bots/cli-host-look.ts @@ -0,0 +1,82 @@ +import { CliLiveError } from './cli-live-error.js'; + +export const HOST_LOOK_COMMANDS = [ + { + command: 'host-look', + description: + 'read or set device-local Host looks on --client-id and --host-id (--refresh, --patch-stdin, --expected-revision)', + }, +] as const; + +export async function runHostLook( + rest: string[], + values: { + 'client-id'?: string; + 'host-id'?: string; + 'patch-stdin'?: boolean; + 'expected-revision'?: string; + refresh?: boolean; + }, + io: { readStdin?: () => Promise }, + connect: () => Promise<{ + http(path: string, body?: Record): Promise<{ bytes: Uint8Array }>; + }>, + expiresAt: number, +) { + if (rest.length || !values['client-id'] || !values['host-id']) + throw new CliLiveError('usage', 'Select --client-id and --host-id for host-look.'); + if (!!values['patch-stdin'] !== (values['expected-revision'] !== undefined)) + throw new CliLiveError('usage', 'Writes require --patch-stdin and --expected-revision.'); + if (values.refresh && values['patch-stdin']) + throw new CliLiveError('usage', 'Refresh reads the stored look; send writes separately.'); + const check = () => { + if (!Number.isFinite(expiresAt) || Date.now() >= expiresAt) + throw new CliLiveError( + 'appearance-request-stale', + 'The Host look request deadline has passed.', + ); + }; + check(); + let patch: unknown; + if (values['patch-stdin']) { + try { + patch = JSON.parse((await io.readStdin?.()) ?? ''); + } catch { + throw new CliLiveError('invalid-input', 'Invalid Host look JSON on stdin.'); + } + } + check(); + const host = await connect(); + check(); + try { + const result = await host.http('/api/botharness/appearance-control', { + action: 'request', + clientId: values['client-id'], + expiresAt, + hostLook: { + hostId: values['host-id'], + ...(values.refresh ? { refresh: true } : {}), + ...(patch === undefined ? {} : { patch }), + }, + ...(patch === undefined ? {} : { expectedRevision: values['expected-revision'] }), + }); + return JSON.parse(new TextDecoder().decode(result.bytes)) as unknown; + } catch (error) { + if ( + patch !== undefined && + (!(error instanceof CliLiveError) || + [ + 'host-unreachable', + 'host-protocol-error', + 'appearance-client-timeout', + 'appearance-client-stale', + 'appearance-request-stale', + ].includes(error.code)) + ) + throw new CliLiveError( + 'host-look-outcome-unknown', + 'The write result is unknown. Read the same Client and Host before retrying; disconnecting does not cancel a delivered command before its deadline.', + ); + throw error; + } +} diff --git a/packages/core/src/bots/cli-live.ts b/packages/core/src/bots/cli-live.ts index 7f06fbb1eb..0e5e0d8f5e 100644 --- a/packages/core/src/bots/cli-live.ts +++ b/packages/core/src/bots/cli-live.ts @@ -1,3 +1,4 @@ +import { HOST_LOOK_COMMANDS, runHostLook } from './cli-host-look.js'; import { runPhoneNotifications } from './cli-phone-notifications.js'; import { WINDOW_ZOOM_COMMANDS, @@ -133,6 +134,7 @@ export { CliLiveError } from './cli-live-error.js'; export const LIVE_OPTIONS = { 'remote-host-id': { type: 'string' }, + 'host-id': { type: 'string' }, ...DEVICE_NOTIFICATION_OPTIONS, ...WINDOW_ZOOM_OPTIONS, ...CONNECT_OPTIONS, @@ -166,6 +168,7 @@ export const LIVE_OPTIONS = { scope: { type: 'string' }, 'acknowledge-risk': { type: 'boolean' }, 'patch-stdin': { type: 'boolean' }, + refresh: { type: 'boolean' }, 'previous-value': { type: 'string' }, member: { type: 'string', multiple: true }, app: { type: 'string' }, @@ -230,6 +233,7 @@ const CONTROL_COMMANDS = [ export const LIVE_COMMANDS = [ ...BROWSER_NOTIFICATION_COMMANDS, + ...HOST_LOOK_COMMANDS, ...DEVICE_NOTIFICATION_COMMANDS, ...WINDOW_ZOOM_COMMANDS, ...PHONE_ROUTE_COMMANDS, @@ -429,6 +433,7 @@ interface LiveValues 'client-id'?: string; 'device-file'?: string; 'remote-host-id'?: string; + 'host-id'?: string; 'preferences-stdin'?: boolean; host?: string; 'token-file'?: string; @@ -452,6 +457,7 @@ interface LiveValues scope?: string; 'acknowledge-risk'?: boolean; 'patch-stdin'?: boolean; + refresh?: boolean; 'previous-value'?: string; member?: string[]; 'operation-key'?: string; @@ -746,6 +752,12 @@ export async function runLiveCli( values: LiveValues, io: BotCreateCliIo, ): Promise { + if (command === 'host-look') { + const duration = timeoutMs(values.timeout); + const expiresAt = Date.now() + duration; + const signal = AbortSignal.timeout(duration); + return runHostLook(rest, values, io, () => connectLiveHost(values, io, signal), expiresAt); + } if (command === 'device-notifications') return runDeviceNotifications(rest, values, io); if (command === 'phone-notifications') return runPhoneNotifications(rest, values, io); if (BROWSER_NOTIFICATION_COMMANDS.some((entry) => entry.command === command)) diff --git a/packages/core/test/cli-host-look.test.ts b/packages/core/test/cli-host-look.test.ts new file mode 100644 index 0000000000..9724fc1717 --- /dev/null +++ b/packages/core/test/cli-host-look.test.ts @@ -0,0 +1,118 @@ +import { expect, it, vi } from 'vitest'; +import { CliLiveError } from '../src/bots/cli-live-error.js'; +import { runHostLook } from '../src/bots/cli-host-look.js'; + +it('targets exactly the selected Client and Host through the existing relay', async () => { + const http = vi.fn(async () => ({ + bytes: new TextEncoder().encode('{"hostId":"b","revision":"rev"}'), + })); + const connect = async () => ({ http }); + const expiresAt = Date.now() + 8000; + const target = { 'client-id': 'browser-a', 'host-id': 'b' }; + expect(await runHostLook([], target, {}, connect, expiresAt)).toEqual({ + hostId: 'b', + revision: 'rev', + }); + expect(http).toHaveBeenLastCalledWith('/api/botharness/appearance-control', { + action: 'request', + clientId: 'browser-a', + expiresAt, + hostLook: { hostId: 'b' }, + }); + const patch = { name: 'Studio', icon: { kind: 'symbol', id: 'server' }, color: 'orange' }; + await runHostLook( + [], + { ...target, 'patch-stdin': true, 'expected-revision': 'rev' }, + { readStdin: async () => JSON.stringify(patch) }, + connect, + expiresAt, + ); + expect(http).toHaveBeenLastCalledWith('/api/botharness/appearance-control', { + action: 'request', + clientId: 'browser-a', + expiresAt, + hostLook: { hostId: 'b', patch }, + expectedRevision: 'rev', + }); +}); + +it('refuses missing targets, unguarded writes and malformed stdin before connecting', async () => { + const connect = vi.fn(); + const expiresAt = Date.now() + 8000; + for (const values of [ + {}, + { 'client-id': 'a' }, + { 'host-id': 'b' }, + { 'client-id': 'a', 'host-id': 'b', 'patch-stdin': true }, + ]) + await expect(runHostLook([], values, {}, connect, expiresAt)).rejects.toMatchObject({ + code: 'usage', + }); + await expect( + runHostLook( + [], + { 'client-id': 'a', 'host-id': 'b', 'patch-stdin': true, 'expected-revision': 'rev' }, + { readStdin: async () => '{bad' }, + connect, + expiresAt, + ), + ).rejects.toMatchObject({ code: 'invalid-input' }); + expect(connect).not.toHaveBeenCalled(); +}); + +it('forwards explicit recovery and refuses a deadline spent reading stdin', async () => { + const http = vi.fn(async () => ({ bytes: new TextEncoder().encode('{}') })); + const connect = vi.fn(async () => ({ http })); + const values = { 'client-id': 'a', 'host-id': 'b' }; + const now = vi.spyOn(Date, 'now').mockReturnValue(1000); + try { + await runHostLook([], { ...values, refresh: true }, {}, connect, 2000); + expect(http).toHaveBeenCalledWith('/api/botharness/appearance-control', { + action: 'request', + clientId: 'a', + expiresAt: 2000, + hostLook: { hostId: 'b', refresh: true }, + }); + connect.mockClear(); + await expect( + runHostLook( + [], + { ...values, 'patch-stdin': true, 'expected-revision': 'rev' }, + { + readStdin: async () => { + now.mockReturnValue(2001); + return '{"name":"Late"}'; + }, + }, + connect, + 2000, + ), + ).rejects.toMatchObject({ code: 'appearance-request-stale' }); + expect(connect).not.toHaveBeenCalled(); + } finally { + now.mockRestore(); + } +}); + +it.each(['host-unreachable', 'appearance-client-timeout', 'appearance-client-stale'])( + 'reports %s after submission as unknown outcome, not cancellation', + async (code) => { + const connect = async () => ({ + http: async () => { + throw new CliLiveError(code, code); + }, + }); + await expect( + runHostLook( + [], + { 'client-id': 'a', 'host-id': 'b', 'patch-stdin': true, 'expected-revision': 'rev' }, + { readStdin: async () => '{"name":"Studio"}' }, + connect, + Date.now() + 8000, + ), + ).rejects.toMatchObject({ + code: 'host-look-outcome-unknown', + message: expect.stringContaining('Read the same Client and Host'), + }); + }, +); diff --git a/scripts/cli-ui-inventory.json b/scripts/cli-ui-inventory.json index 50ad945824..9622c60a86 100644 --- a/scripts/cli-ui-inventory.json +++ b/scripts/cli-ui-inventory.json @@ -447,10 +447,9 @@ "storage:packages/client/src/client/remote-hosts.ts:setItem(REMOTE_HOSTS_STORAGE_KEY)", "storage:packages/client/src/client/remote-hosts.ts:setItem(HOST_TAB_STORAGE_KEY)", "storage:packages/client/src/client/remote-hosts.ts:removeItem(oldKey)", - "storage:packages/client/src/client/remote-hosts.ts:setItem(key)", - "storage:packages/client/src/client/host-looks.ts:setItem(HOST_LOOKS_STORAGE_KEY)" + "storage:packages/client/src/client/remote-hosts.ts:setItem(key)" ], - "exemption": "Per-browser connections, legacy-key migration, the tab filter and Host looks belong to that browser profile, not to any Host (spec #2178, ADR-0243: Host looks are device-local), so no Host CLI can read or set them. Permanent pairing removal also deletes its browser notification choice as automatic cleanup, not a separate setting. Browser notification reads and writes use the separately inventoried Client relay commands. The Remote Host's side is its existing device gateway: pair, unary calls, stream ticket and revoke.", + "exemption": "Per-browser connections, legacy-key migration, and the tab filter belong to that browser profile, not to any Host (spec #2178, ADR-0243: Host looks are device-local), so no Host CLI can read or set them. Permanent pairing removal also deletes its browser notification choice as automatic cleanup, not a separate setting. Browser notifications and Host looks use separately inventoried Client relay commands. The Remote Host's side is its existing device gateway: pair, unary calls, stream ticket and revoke.", "configurable": false }, { @@ -833,6 +832,15 @@ "appearance-clear-panels", "appearance-reset" ] + }, + { + "id": "device-local-host-look", + "description": "Browser-owned Host name, icon and colour; shared HostLooks locks persisted revision/merge, supports explicit refresh after read failure, and checks CLI absolute expiry before setItem. Early disconnect is unknown outcome with readback; no Host mirror.", + "scope": "Client / Host", + "surfaces": [ + "storage:packages/client/src/client/host-looks.ts:setItem(HOST_LOOKS_STORAGE_KEY)" + ], + "commands": ["appearance-clients", "host-look"] } ] }