From 96d5ec580c25ae0c1946d227b1feeb4fc7a2a9e3 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 09:12:58 +0900 Subject: [PATCH 1/2] feat(settings): share pins and device-independent settings across browsers Settings and sidebar pins lived only in each browser's localStorage, so a PC and a phone disagreed. The server now keeps one shared document in preferences.json (GET/PUT /api/preferences) and announces each write as a preferences event on /api/machines/events. Every setting is marked shared or device in SETTING_SCOPE, checked at build time. A page applies what the server holds once the app is in and never writes on load; a change sends only the shared settings that changed (and the pins when they did), which the server lays over what it holds, so a setting nobody changed stays each device's own. Events at or below the last seen revision, or while a local change is unsent, are ignored. A watching device is refused writes and keeps receiving. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ scripts/ci-browser.sh | 1 + scripts/ci-browser.test.ts | 2 +- scripts/preferences-sync-regression.ts | 105 +++++++++++++++++++++++++ server/AGENTS.md | 1 + server/device-access.contract.test.ts | 3 + server/index.ts | 6 +- server/machine-api.ts | 11 ++- server/preferences.test.ts | 49 ++++++++++++ server/preferences.ts | 61 ++++++++++++++ shared/machines.ts | 5 +- shared/preferences.ts | 43 ++++++++++ src/AGENTS.md | 1 + src/App.tsx | 5 ++ src/components/AgentSidebar.tsx | 12 +-- src/lib/api.ts | 14 ++++ src/lib/preferencesSync.test.ts | 21 +++++ src/lib/preferencesSync.ts | 104 ++++++++++++++++++++++++ src/lib/settings.test.ts | 11 ++- src/lib/settings.ts | 24 ++++++ src/lib/sidebarPins.ts | 26 ++++++ 21 files changed, 499 insertions(+), 13 deletions(-) create mode 100644 scripts/preferences-sync-regression.ts create mode 100644 server/preferences.test.ts create mode 100644 server/preferences.ts create mode 100644 shared/preferences.ts create mode 100644 src/lib/preferencesSync.test.ts create mode 100644 src/lib/preferencesSync.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4caa0215d4..d4bcf9cc23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Added +- **Shared settings and pins.** The browsers of one server, a PC's and a phone's, keep the same + pinned agents and the same settings that do not depend on the device: theme and colors, language, + the sidebar's rows, order, cards and pins, chat and usage-meter choices. A + change shows on the other open pages at once. Each device keeps its own terminal and chat font + sizes, key bar, shortcuts, density, alerts, microphone, the lens panes open in and the like. Opening a page never + overwrites anything: a server that holds no shared settings yet takes them from the first change. + A watching device follows the shared settings and cannot change them for everyone. - **Settings → Composer → Send queued messages automatically** (on by default). Turned off, a message sent while the agent works waits under the message box until you press **Send now**, instead of going by itself when the turn ends. A PC whose bridge is older than this release diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index 6e8bf4c6da..88ac70f47d 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -91,5 +91,6 @@ run_script scripts/narrow-pane-demo-regression.ts run_script scripts/touch-long-press-demo-regression.ts run_script scripts/phone-split-grid-demo-regression.ts run_script scripts/recent-folders-regression.ts +run_script scripts/preferences-sync-regression.ts run_script scripts/machine-dialog-regression.ts run_script scripts/machine-conflict-regression.ts diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index 4dfed710fa..1f50ad94c1 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -59,7 +59,7 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(38); + expect(rows(text)).toHaveLength(39); expect(rows(text).map((row) => row.script)).toContain("scripts/agy-chat-browser-qa.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/chat-fallback-browser-qa.ts"); diff --git a/scripts/preferences-sync-regression.ts b/scripts/preferences-sync-regression.ts new file mode 100644 index 0000000000..02ddc92632 --- /dev/null +++ b/scripts/preferences-sync-regression.ts @@ -0,0 +1,105 @@ +/** + * Two browsers of one server keep the shared settings and pins the same (lib/preferencesSync.ts): + * a theme changed in one shows in the other at once, the other's terminal font (this device's) + * does not move and is never sent, pins written elsewhere reach an open page, and opening a page + * on an empty server sends nothing. No herdr session is opened. + */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type BrowserContext, type Page } from "playwright-core"; + +await import("./test-herdr.ts"); +const { createServer } = await import("../server/index.ts"); +const { UsageService } = await import("../server/usage.ts"); + +const root = mkdtempSync(join(tmpdir(), "herdr-preferences-sync-")); +const server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state"), usage: new UsageService(undefined, []) }); +const origin = `http://127.0.0.1:${server.port}`; + +async function until(check: () => Promise, label: string): Promise { + const deadline = Date.now() + 10_000; + while (!(await check())) { + assert.ok(Date.now() < deadline, label); + await Bun.sleep(50); + } +} + +/** a browser of its own; `puts` collects what it sends to /api/preferences */ +async function browserAt(browser: Awaited>, own: Record = {}): Promise<{ context: BrowserContext; page: Page; puts: unknown[] }> { + const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, locale: "en-US" }); + await context.addInitScript((own) => { + // what the page hears on its event stream, for the pins below (a pin of a pane that is not an + // agent here is dropped by the sidebar as soon as it lands, so the stored list cannot tell) + const heard: unknown[] = []; + (window as unknown as { heardPreferences: unknown[] }).heardPreferences = heard; + const Source = window.EventSource; + window.EventSource = class extends Source { + constructor(url: string | URL, init?: EventSourceInit) { + super(url, init); + this.addEventListener("message", (event) => { + const data = JSON.parse((event as MessageEvent).data) as { type: string; preferences?: unknown }; + if (data.type === "preferences") heard.push(data.preferences); + }); + } + }; + if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme: "dark", terminalFontSize: 13, ...own })); + }, own); + const puts: unknown[] = []; + const page = await context.newPage(); + page.on("request", (request) => { if (request.method() === "PUT" && request.url().endsWith("/api/preferences")) puts.push(request.postDataJSON()); }); + await page.goto(origin); + await page.locator(".conn-live").waitFor({ state: "attached" }); + return { context, page, puts }; +} + +const theme = (page: Page) => page.evaluate(() => document.documentElement.dataset["theme"]); +const stored = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:settings") ?? "{}") as Record); + +try { + const browser = await chromium.launch({ executablePath: process.env["CHROME_PATH"] ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); + try { + const a = await browserAt(browser); + const b = await browserAt(browser); + await Bun.sleep(1_500); + assert.deepEqual([...a.puts, ...b.puts], [], "opening a page on an empty server sends nothing"); + + // A turns light: B follows while open, and only the shared settings went out + await a.page.goto(`${origin}/?settings=appearance`); + await a.page.getByRole("group", { name: "Theme", exact: true }).getByRole("button", { name: "Light", exact: true }).click(); + await until(async () => await theme(b.page) === "light", "B takes A's theme without a reload"); + assert.equal(a.puts.length >= 1, true); + assert.deepEqual((a.puts as { settings: Record }[]).map((body) => Object.keys(body.settings)), [["theme"]], "only the setting that changed is sent"); + + // A's terminal font is A's own: B keeps 13 + await a.page.goto(`${origin}/?settings=terminal`); + await a.page.getByRole("button", { name: "Increase terminal font size", exact: true }).click(); + await until(async () => (await stored(a.page))["terminalFontSize"] === 14, "A keeps its new font"); + await Bun.sleep(1_500); + assert.equal(a.puts.length, 1, "a device setting is never sent"); + assert.equal((await stored(b.page))["terminalFontSize"], 13, "B's terminal font does not move"); + + // pins written by another browser reach an open page + const pins = ["local:w9:p1"]; + const heardBefore = await a.page.evaluate(() => (window as unknown as { heardPreferences: unknown[] }).heardPreferences.length); + const status = await b.page.evaluate(async (body) => (await fetch("/api/preferences", { method: "PUT", headers: { "content-type": "application/json" }, body })).status, + JSON.stringify({ settings: { theme: "light" }, pins })); + assert.equal(status, 200); + await until(async () => await a.page.evaluate(([from, want]) => (window as unknown as { heardPreferences: { pins: string[] }[] }).heardPreferences.slice(from) + .some((preferences) => JSON.stringify(preferences.pins) === want), [heardBefore, JSON.stringify(pins)] as const), "A hears the pins"); + + // a page opened later starts from what the server holds, and keeps a shared setting nobody changed + const c = await browserAt(browser, { showThinking: false }); + await until(async () => await theme(c.page) === "light", "a new page takes the shared theme"); + assert.equal((await stored(c.page))["terminalFontSize"], 13, "and keeps its own terminal font"); + assert.equal((await stored(c.page))["showThinking"], false, "and a shared setting nobody changed stays its own"); + for (const opened of [a, b, c]) await opened.context.close(); + console.log("PASS shared settings and pins follow across browsers, device settings stay, an empty server is never written on load"); + } finally { + await browser.close(); + } +} finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); +} diff --git a/server/AGENTS.md b/server/AGENTS.md index bd7d619e7c..ecb991271d 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -82,6 +82,7 @@ Bun backend: HTTP API + WebSocket fan-out over herdr's unix-socket API, plus the | What an update brings | release-notes.ts | CHANGELOG.md sections plus `release-summaries.json`, newest first and bounded; `## [Unreleased]` is no release, and another level-two heading belongs to the notes it sits in | | Agent stores beyond claude/codex/omo | gjc-runtime.ts, pi.ts, claude-store.ts, omp.ts (transcript-records.ts and skill-activity.ts parse what they find) | each store lookup answers null rather than guessing which transcript a pane writes. pi's store moves with `PI_CODING_AGENT_SESSION_DIR` or `PI_CODING_AGENT_DIR`, and a session file is not written until the first answer, so an absent path can still be the pane's. gjc on Windows is found through the PC's process table, shared between polls for PROCESS_TABLE_MS; an empty table is a failed read, not an answer | | Paired devices | devices.ts + /api/devices* in index.ts | six-digit code valid ten minutes, one per pairing; the device's token is stored only as a hash in devices.json (0600, written whole), and revoking also closes that device's live streams | +| Shared preferences | preferences.ts + /api/preferences in index.ts; shared/preferences.ts | the settings every browser of this server shares (`SHARED_SETTING_KEYS`) and the sidebar pins, one document in preferences.json (0600, written whole) with a `revision` counter; a PUT carries the settings that changed (laid over the held ones, last write wins per setting) and the pins when they changed, and keeps only the shared names, at most 500 pins and 64 KB; a watching device reads it and is refused a PUT by the general read-only gate; each write goes to every open browser as a `preferences` event on /api/machines/events | - Transcript caches are keyed by file, so a pane's chat is remembered against what it read and `forgetPaneTranscriptState(paneId)` (conversation.ts) drops it from every reader (pi-tree, codex, claude-store, gjc-runtime, opencode) when the pane closes or ends. `forgetTranscriptState()` is the test-suite wipe and clears the same state plus the pane index. Any new transcript cache joins one of those two. - The shell and every static file carry the app's Content-Security-Policy from `serveStatic` (static.ts); `/api/fs/*` answers carry file-view.ts's own stricter one: scripts and connections are `'self'` only, styles carry `'unsafe-inline'` for the measured layout attributes and KaTeX. `HERDR_WEB_CSP=report-only` measures it without enforcing. A new third-party resource needs a directive there and a reason in the comment. diff --git a/server/device-access.contract.test.ts b/server/device-access.contract.test.ts index 21a734a9f2..aa7a3f9fe3 100644 --- a/server/device-access.contract.test.ts +++ b/server/device-access.contract.test.ts @@ -120,6 +120,9 @@ it("watch credentials cannot mutate HTTP state, read credential files, or elevat for (const path of ["pane/input", "workspace/create", "tab/create", "tab/rename", "tab/close", "machines/setup", "updates/install", "herdr/update"]) { expect((await fetch(`${base}/api/${path}`, { method: "POST", headers, body: "{}" })).status).toBe(403); } + // the shared settings: a watching device reads them, and cannot change them for every browser + expect((await fetch(`${base}/api/preferences`, { headers })).status).toBe(200); + expect((await fetch(`${base}/api/preferences`, { method: "PUT", headers, body: JSON.stringify({ settings: { theme: "light" }, pins: [] }) })).status).toBe(403); expect((await fetch(`${base}/api/fs/file?path=${encodeURIComponent(join(root, "devices.json"))}`, { headers })).status).toBe(403); // a PC's file is read without the origin check (#448, Android's Open button): the watch refusal still comes first, for GET and HEAD alike for (const method of ["GET", "HEAD"]) { diff --git a/server/index.ts b/server/index.ts index edb4ee9f57..1b496a0ca9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import { DEFAULT_PORT } from "../shared/protocol.ts"; import { DEVICE_COOKIE, authClient, handleAuthRequest, isAuthenticated, parseCookies, presentedToken, presentedTokenHeld, recordPresentedTokenFailure, requiresAuth, unauthorizedJson } from "./auth.ts"; import { cameThroughProxy, decideAccess, isLoopbackAddress, isServeOwnerRequest } from "./access.ts"; import { DeviceStore, handleDeviceRequest } from "./devices.ts"; +import { handlePreferencesRequest, PreferenceStore } from "./preferences.ts"; import { remoteAccess, TailnetIdentitySource } from "./tailscale.ts"; import { paneCommands } from "./commands.ts"; import { paneFiles } from "./files.ts"; @@ -525,6 +526,7 @@ export function createServer( const token = options.token ?? process.env["HERDR_WEB_TOKEN"] ?? ""; /** paired devices (server/devices.ts) and the PC's Tailscale login: the two ways in besides the token and this PC itself */ const devices = new DeviceStore(options.stateDir ?? defaultStateDir()); + const preferences = new PreferenceStore(options.stateDir ?? defaultStateDir()); const usage = options.usage ?? new UsageService(); const voice = options.voice ?? new VoiceService({ stateDir: options.stateDir ?? defaultStateDir(), env: process.env, fetch }); /** a login named here is taken as it is: a tagged node has none of its own to read (HERDR_WEB_TAILSCALE_OWNER) */ @@ -1698,7 +1700,7 @@ export function createServer( // a worktree made with an agent waits on git and then agent.start, up to 150 s on the PC bunServer.timeout(request, pathname === "/api/machines/events" ? 0 : pathname.endsWith("/worktree/create") ? 180 : 80); const deviceId = access.level === "full" ? access.device?.id : undefined; - const response = await handleMachineRequest(request, machines, deviceId ? (close) => devices.onRevoke(deviceId, close) : undefined); + const response = await handleMachineRequest(request, machines, deviceId ? (close) => devices.onRevoke(deviceId, close) : undefined, preferences); response.headers.set("cache-control", "no-store"); return response; } @@ -1742,6 +1744,8 @@ export function createServer( } if (pathname === "/api/herdr/update") return handleHerdrUpdateRequest(request, options.herdrUpdate); if (pathname === "/api/telemetry") return handleTelemetryRequest(request, options.telemetry); + // what every browser of this server shares; a watching device reads it and cannot change it (above) + if (pathname === "/api/preferences") return handlePreferencesRequest(request, preferences); if (pathname === "/api/usage") return handleUsageRequest(request, url, usage); // a long clip can keep the provider silent past Bun's 10 s idle limit before the first line diff --git a/server/machine-api.ts b/server/machine-api.ts index 473f69c87d..f87b50761e 100644 --- a/server/machine-api.ts +++ b/server/machine-api.ts @@ -1,4 +1,5 @@ -import type { SetupAction, SetupRequest } from "../shared/machines.ts"; +import type { MachineEvent, SetupAction, SetupRequest } from "../shared/machines.ts"; +import type { SharedPreferences } from "../shared/preferences.ts"; import { MachineManager } from "./machines.ts"; import { canSendSecret, sameOrigin } from "./machine-security.ts"; import { isJsonObject, jsonResponse } from "./http.ts"; @@ -21,7 +22,8 @@ function transportFailure(id: string, path: string, error: unknown): Response { : fail("machine_unavailable", "The PC connection was interrupted; retry after reconnecting", 502); } -export async function handleMachineRequest(request: Request, manager: MachineManager, onRevoke?: (close: () => void) => () => void): Promise { +export async function handleMachineRequest(request: Request, manager: MachineManager, onRevoke?: (close: () => void) => () => void, + preferences?: { subscribe(listener: (preferences: SharedPreferences) => void): () => void }): Promise { const url = new URL(request.url); // A PC's file opens as this PC's /api/fs/file does, from any navigation: reading it changes // nothing, and Chrome on Android shows a PDF in the viewer's frame as an "Open" button whose @@ -36,11 +38,12 @@ export async function handleMachineRequest(request: Request, manager: MachineMan if (!parts.length && request.method === "GET") return jsonResponse({ machines: manager.list() }); if (parts[0] === "events" && parts.length === 1 && request.method === "GET") { let stop: (() => void) | undefined; + let stopPreferences: (() => void) | undefined; let unwatchDevice: (() => void) | undefined; let heartbeat: ReturnType | undefined; let close = () => {}; const cleanup = () => { - stop?.(); unwatchDevice?.(); clearInterval(heartbeat); + stop?.(); stopPreferences?.(); unwatchDevice?.(); clearInterval(heartbeat); request.signal.removeEventListener("abort", close); }; const encoder = new TextEncoder(); @@ -52,6 +55,8 @@ export async function handleMachineRequest(request: Request, manager: MachineMan if (closed) return; const send = (text: string) => { if (closed) return; if ((controller.desiredSize ?? 0) < -4) { close(); return; } try { controller.enqueue(encoder.encode(text)); } catch { close(); } }; stop = manager.subscribe((event) => send(`data: ${JSON.stringify(event)}\n\n`)); + // the shared preferences ride the same stream: one connection per browser + stopPreferences = preferences?.subscribe((shared) => send(`data: ${JSON.stringify({ type: "preferences", preferences: shared } satisfies MachineEvent)}\n\n`)); if (closed) { cleanup(); return; } heartbeat = setInterval(() => send(": heartbeat\n\n"), 15_000); request.signal.addEventListener("abort", close, { once: true }); diff --git a/server/preferences.test.ts b/server/preferences.test.ts new file mode 100644 index 0000000000..9355d21cf4 --- /dev/null +++ b/server/preferences.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MAX_PINS } from "../shared/preferences.ts"; +import { handlePreferencesRequest, PreferenceStore } from "./preferences.ts"; + +const roots: string[] = []; +const root = (): string => { const dir = mkdtempSync(join(tmpdir(), "herdr-preferences-")); roots.push(dir); return dir; }; +afterEach(() => { for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +const put = (store: PreferenceStore, body: string) => handlePreferencesRequest(new Request("http://x/api/preferences", { method: "PUT", body }), store); + +describe("shared preferences", () => { + it("starts empty, keeps only the shared names, counts each write, and survives a restart", async () => { + const dir = root(); + const store = new PreferenceStore(dir); + expect(await (await handlePreferencesRequest(new Request("http://x/api/preferences"), store)).json()).toEqual({ revision: 0, settings: {}, pins: [] }); + const heard: number[] = []; + store.subscribe((preferences) => heard.push(preferences.revision)); + const first = await put(store, JSON.stringify({ settings: { theme: "light", terminalFontSize: 22, alertsOn: false, nonsense: 1 }, pins: ["local:w1:p1", "local:w1:p1", "pc:w2:p3"] })); + expect(first.status).toBe(200); + expect(await first.json()).toEqual({ revision: 1, settings: { theme: "light" }, pins: ["local:w1:p1", "pc:w2:p3"] }); + // a change lays its settings over what is held, and leaves the pins when it names none + await put(store, JSON.stringify({ settings: { agentPins: true } })); + expect(store.get()).toEqual({ revision: 2, settings: { theme: "light", agentPins: true }, pins: ["local:w1:p1", "pc:w2:p3"] }); + await put(store, JSON.stringify({ settings: { theme: "dark" }, pins: [] })); + expect(heard).toEqual([1, 2, 3]); + expect(JSON.parse(readFileSync(join(dir, "preferences.json"), "utf8"))).toMatchObject({ revision: 3, settings: { theme: "dark", agentPins: true }, pins: [] }); + expect(new PreferenceStore(dir).get()).toEqual({ revision: 3, settings: { theme: "dark", agentPins: true }, pins: [] }); + }); + + it("refuses what is not a preferences document", async () => { + const store = new PreferenceStore(root()); + expect((await put(store, "{")).status).toBe(400); + expect((await put(store, JSON.stringify({ settings: [], pins: [] }))).status).toBe(400); + expect((await put(store, JSON.stringify({ settings: {}, pins: [1] }))).status).toBe(400); + expect((await put(store, JSON.stringify({ settings: {}, pins: Array.from({ length: MAX_PINS + 1 }, (_, i) => `pc:${i}`) }))).status).toBe(400); + expect((await put(store, JSON.stringify({ settings: { quickReplies: ["x".repeat(70_000)] }, pins: [] }))).status).toBe(413); + expect((await handlePreferencesRequest(new Request("http://x/api/preferences", { method: "POST", body: "{}" }), store)).status).toBe(405); + expect(store.get().revision).toBe(0); + }); + + it("starts empty over a damaged file", () => { + const dir = root(); + writeFileSync(join(dir, "preferences.json"), "not json"); + expect(new PreferenceStore(dir).get()).toEqual({ revision: 0, settings: {}, pins: [] }); + }); +}); diff --git a/server/preferences.ts b/server/preferences.ts new file mode 100644 index 0000000000..4ab656d12a --- /dev/null +++ b/server/preferences.ts @@ -0,0 +1,61 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { MAX_PREFERENCES_BYTES, sharedPreferencesBody, type PreferencesChange, type SharedPreferences } from "../shared/preferences.ts"; +import { badRequest, isJsonObject, jsonResponse } from "./http.ts"; + +/** + * The preferences every browser of this server shares (shared/preferences.ts), kept in the state + * directory beside devices.json. One document: a browser sends the settings it changed (and the + * pins when they changed), laid over what is held, last write wins per setting; the others take + * the whole document from the event. + */ +export class PreferenceStore { + private current: SharedPreferences = { revision: 0, settings: {}, pins: [] }; + private readonly path: string; + private readonly listeners = new Set<(preferences: SharedPreferences) => void>(); + + constructor(stateDir: string) { + this.path = join(stateDir, "preferences.json"); + try { + const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8")); + const body = sharedPreferencesBody(parsed); + const revision = isJsonObject(parsed) && Number.isSafeInteger(parsed["revision"]) ? parsed["revision"] as number : 0; + if (body !== null) this.current = { revision: Math.max(0, revision), settings: body.settings, pins: body.pins ?? [] }; + } catch (error) { + // a missing file is a server that has shared nothing yet; a damaged one is replaced by the next write + if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Cannot read ${this.path}; shared preferences start empty`); + } + } + + get(): SharedPreferences { return this.current; } + + set(change: PreferencesChange): SharedPreferences { + this.current = { revision: this.current.revision + 1, settings: { ...this.current.settings, ...change.settings }, pins: change.pins ?? this.current.pins }; + mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); + const temporary = `${this.path}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(this.current, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, this.path); + for (const listener of [...this.listeners]) listener(this.current); + return this.current; + } + + subscribe(listener: (preferences: SharedPreferences) => void): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } +} + +/** /api/preferences: GET the shared document, PUT a new one. A watching device's PUT is refused before this (index.ts). */ +export async function handlePreferencesRequest(request: Request, store: PreferenceStore): Promise { + const reply = (body: unknown, status = 200) => jsonResponse(body, status, { "cache-control": "no-store" }); + if (request.method === "GET") return reply(store.get()); + if (request.method !== "PUT") return reply({ error: { code: "method_not_allowed", message: "Use GET or PUT /api/preferences" } }, 405); + const text = await request.text(); + if (new TextEncoder().encode(text).length > MAX_PREFERENCES_BYTES) return reply({ error: { code: "too_large", message: "Shared preferences are at most 64 KB" } }, 413); + let raw: unknown; + try { raw = JSON.parse(text); } catch { return badRequest("invalid_json", "The body is not JSON"); } + const body = sharedPreferencesBody(raw); + if (body === null) return badRequest("invalid_body", "Send { settings: object, pins?: string[] }"); + try { return reply(store.set(body)); } + catch (error) { return reply({ error: { code: "preferences_unwritable", message: error instanceof Error ? error.message : String(error) } }, 500); } +} diff --git a/shared/machines.ts b/shared/machines.ts index f0ee93482e..964b30af88 100644 --- a/shared/machines.ts +++ b/shared/machines.ts @@ -1,3 +1,4 @@ +import type { SharedPreferences } from "./preferences.ts"; import type { HealthAuth, ServerMessage, SessionSnapshot } from "./protocol.ts"; export const LOCAL_MACHINE = "local"; @@ -76,7 +77,9 @@ export interface SetupJob { progress?: SetupProgress | null; } export type SetupAction = { action: "answer"; challenge_id: string; answer: string } | { action: "approve" } | { action: "cancel" }; -export type MachineEvent = { type: "machines"; machines: Machine[] } | { type: "machine-message"; machine_id: string; message: ServerMessage }; +/** `preferences`: what every browser of this server shares changed (shared/preferences.ts) */ +export type MachineEvent = { type: "machines"; machines: Machine[] } | { type: "machine-message"; machine_id: string; message: ServerMessage } + | { type: "preferences"; preferences: SharedPreferences }; export interface BridgeHealth { ok: true; auth: HealthAuth; bridge_protocol: number } export interface BridgeIdentity { pid: number; diff --git a/shared/preferences.ts b/shared/preferences.ts new file mode 100644 index 0000000000..fda5e97176 --- /dev/null +++ b/shared/preferences.ts @@ -0,0 +1,43 @@ +/** + * What every browser of this server shares: the settings that do not depend on the device + * (src/lib/settings.ts SETTING_SCOPE says which) and the pinned agents. GET/PUT + * /api/preferences; a change reaches the other browsers as a `preferences` event on + * /api/machines/events. The values are checked where they are used (sanitizeSettings): the + * server keeps only the names below and the shape. A browser sends only the settings it changed, + * which the server lays over what it holds, so a setting nobody changed stays each device's own. + */ +export const SHARED_SETTING_KEYS = [ + "theme", "palette", "language", "sidebarRows", "agentOrder", "agentCards", "agentPins", "autoSendQueued", + "showThinking", "quickReplies", "showQuickReplies", "showSuggestionChip", "showUsage", "usageCount", "usagePace", + "usageGlance", "usageOrder", "usageHidden", "highlightCode", +] as const; +export type SharedSettingKey = (typeof SHARED_SETTING_KEYS)[number]; + +export interface SharedPreferences { + /** 0 until a browser first writes; each write adds one */ + revision: number; + settings: Partial>; + /** sidebar pin keys (`machine:pane`), in their order */ + pins: string[]; +} + +export const MAX_PINS = 500; +const MAX_PIN_LENGTH = 300; +/** a shared value is a few words or a short list; the whole document stays small */ +export const MAX_PREFERENCES_BYTES = 64 * 1024; + +const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +/** What a browser sends: the settings it changed, and the pins when they changed */ +export interface PreferencesChange { settings: SharedPreferences["settings"]; pins?: string[] } + +/** The settings and pins of a body or a stored file, cut to the shared names and the limits; null when the shape is wrong. */ +export function sharedPreferencesBody(raw: unknown): PreferencesChange | null { + if (!isObject(raw) || !isObject(raw["settings"]) || (raw["pins"] !== undefined && !Array.isArray(raw["pins"]))) return null; + const pins = raw["pins"] as unknown[] | undefined; + if (pins !== undefined && (pins.length > MAX_PINS || !pins.every((pin) => typeof pin === "string" && pin.length > 0 && pin.length <= MAX_PIN_LENGTH))) return null; + const given = raw["settings"]; + const settings: SharedPreferences["settings"] = {}; + for (const key of SHARED_SETTING_KEYS) if (key in given) settings[key] = given[key]; + return pins === undefined ? { settings } : { settings, pins: [...new Set(pins as string[])] }; +} diff --git a/src/AGENTS.md b/src/AGENTS.md index f7c08c2f24..5fef24dca5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -65,6 +65,7 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an | Sidebar badges | components/Sidebar.tsx `StatusBadge` + lib/status.ts | ONE vocabulary for every surface (sidebar, palette, composer status line): idle→READY, working→RUN, blocked→INPUT, done→DONE, unknown→`—` (`STATUS_WORD`, `knownStatus`), and a pane at rest with `background_wait`→BG (`paneStatus`, status `waiting`: the workspace row's rollup, the agent row, the palette, the tab dot and the composer's read-only word; send/queue/stop keep the raw status); the compact sidebar variant draws a glyph for RUN, INPUT, DONE and BG (RUN's arc, held still) and nothing for READY and unknown, but always renders the element with `data-status`, its label and a box (the browser scripts wait on it); background tasks are `.background-count`, not a `.badge`; the composer alone reads a blank state as READY (a shell is always ready); pushed statuses land through `applyPaneStatus` (lib/snapshot.ts) | | Notifications | lib/notifications.ts | tab alerts: policy re-exported from shared/notify-policy.ts; hidden-tab guard; shown via `registration.showNotification` (Android has no `new Notification()`), constructor only as fallback; skipped when this device has push (`pushOnRef`) | | Deep links | lib/deepLink.ts (pure) + App.tsx | the address names where the app is: `?machine&ws=-&pane&view&file&settings§ion&new&cwd&name&agent&sidebar` (`readLink`/`linkSearch`); App reads it once at load, settles it against the PC's roster once that is in (`resolveLink`: a slug another open workspace has wins over a reused id; a closed pane opens its workspace's row pane; a header note says what could not open), and writes it back from one effect: another pane or PC is `pushState` (Back returns to the last one), everything else `replaceState`; nothing is written while locked or before the roster is in; a pane picked before the link lands supersedes it; a link's lens is drawn as `notificationViewTarget`, never stored; Settings' own entries stay lib/settingsHistory.ts's, their address carrying `settings`/`section`; the New workspace dialog's fields reach the address 400 ms after they rest (Safari refuses bursts of history writes); a link never creates anything; a pane that mounts under an open modal does not take its focus (`modalOpen`, lib/focus.ts) | +| Shared settings and pins | lib/preferencesSync.ts (App, after the access gate) + `SETTING_SCOPE` in lib/settings.ts + lib/sidebarPins.ts store | every setting is `shared` or `device` in SETTING_SCOPE (the build fails on one that is neither; when in doubt, device); a server with a document gives it to a page on load, a load never writes; a change sends only the shared settings that changed (and the pins when they did) 600 ms after they rest, and the server lays them over what it holds, so a setting nobody changed stays each device's own; an event is ignored at or below the last revision seen, or while a change here is not yet sent (it goes out next and wins); a 403 (a watching device) stops sending and keeps taking what the others share; a 404 (an older server) turns sync off; the file reader (`?read=`) runs none of it | | Web push | lib/push.ts + App Alerts item (header More menu) + public/sw.js | `ensurePushSubscription` (SINGLE-FLIGHT: overlapping callers share one attempt, because two concurrent `subscribe()` calls make Chrome issue two subscriptions and keep only the later; idempotent; replaces a subscription made for another server key; runs on every load when permission is granted) and `removePushSubscription` (lock, before signOut); the Alerts item says off / pushed / this tab / in the app (permission not granted; a tap asks while it is unanswered, else switches off), and the More button carries a dot while alerts are off here; SW posts `{type:"select-pane", view:"chat"}` on a tapped pane alert, a closed app opens `/?pane=&view=chat`, a deep link like any other (lib/deepLink.ts), and App draws that pane's chat over its own lens (`notificationViewTarget`, render only: never in `lens` or `herdr-web-ui:view:`) until the pane or a lens is picked by hand; a pane with no agent keeps its own lens, and the in-app alert card (Droplet) opens the pane's own lens | | HTTP client | lib/api.ts | fetchSession / fetchHealth / authenticate / signOut; `ApiError` carries status + code | | App updates | lib/updates.ts + components/UpdateControls.tsx | authenticated status polling; Settings check/install + header notice; compare build-time Git revision to running server, offer Reload app explicitly so unsent drafts survive bridge updates | diff --git a/src/App.tsx b/src/App.tsx index a237e75c47..96504de366 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { AgentMark } from "./components/AgentMark.tsx"; import { NewSessionDialog, type NewTabTarget } from "./components/NewSessionDialog.tsx"; import { MovePaneMenu } from "./components/MovePaneMenu.tsx"; import { passingFolder, recentDirectories } from "./lib/recent-directories.ts"; +import { usePreferencesSync } from "./lib/preferencesSync.ts"; import { TabStrip } from "./components/TabStrip.tsx"; import { SettingsDialog } from "./components/SettingsDialog.tsx"; import { loadedOnSettingsEntry, onSettingsHistory, recordSettings, settingsEntry } from "./lib/settingsHistory.ts"; @@ -326,6 +327,9 @@ export function App() { const [pushOn, setPushOn] = useState(false); const pushOnRef = useRef(pushOn); pushOnRef.current = pushOn; + // the shared settings and pins, the same in every browser of this server, once the app is in + const receivePreferences = usePreferencesSync(locked === false); + const receivePreferencesRef = useRef(receivePreferences); receivePreferencesRef.current = receivePreferences; const lockedRef = useRef(locked); lockedRef.current = locked; // last-seen agent status per pane: the baseline that decides whether a push is news @@ -467,6 +471,7 @@ export function App() { setMachines((previous) => sameData(previous, payload.machines) ? previous : payload.machines); return; } + if (payload.type === "preferences") { receivePreferencesRef.current(payload.preferences); return; } const machine = machinesRef.current.find((m) => m.id === payload.machine_id); if (!machine) return; const message = payload.message; diff --git a/src/components/AgentSidebar.tsx b/src/components/AgentSidebar.tsx index f4fd1a60ac..598b15592f 100644 --- a/src/components/AgentSidebar.tsx +++ b/src/components/AgentSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { ChevronDown, ChevronRight, Folder, Star, Terminal } from "lucide-react"; import type { Machine } from "../../shared/machines.ts"; @@ -9,7 +9,7 @@ import { agentContext, agentTabName, groupRows, paneMark, sidebarAgents, type Si import { useSettings } from "../lib/settings.ts"; import { useSidebarActivity } from "../lib/sidebarActivity.tsx"; import { activityOrder } from "../lib/sidebarOrder.ts"; -import { loadSidebarPins, pruneSidebarPins, saveSidebarPins, sidebarPinKey, splitPinnedRows, toggleSidebarPin } from "../lib/sidebarPins.ts"; +import { currentSidebarPins, pruneSidebarPins, setSidebarPins, sidebarPinKey, splitPinnedRows, subscribeSidebarPins, toggleSidebarPin } from "../lib/sidebarPins.ts"; import { AgentMark, hasAgentMark } from "./AgentMark.tsx"; import { BackgroundBadge, displayPaneTitle, PlanBadge, StatusBadge } from "./Sidebar.tsx"; import "./AgentSidebar.css"; @@ -76,7 +76,8 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const listId = useId(); const [collapsed, setCollapsed] = useState(() => window.matchMedia?.(DRAWER_QUERY).matches === true); const { settings } = useSettings(); - const [pinned, setPinned] = useState(loadSidebarPins); + // shared with the other browsers of this server (lib/preferencesSync.ts) + const pinned = useSyncExternalStore(subscribeSidebarPins, currentSidebarPins, currentSidebarPins); const activity = useSidebarActivity(); // Settings → Agents order: herdr's order, or Activity within each PC (each herdr counts its own changes) const byActivity = settings.agentOrder === "activity"; @@ -89,7 +90,7 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat useEffect(() => { const live = new Set(rows.map(({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id))); const next = pruneSidebarPins(pinned, live, machines); - if (next.length !== pinned.length) { setPinned(next); saveSidebarPins(next); } + if (next.length !== pinned.length) setSidebarPins(next); }, [machines, rows, pinned]); // a pin moves the row between the pinned list and its card, so React draws a new star: a star // that had the keyboard focus hands it to its successor @@ -104,8 +105,7 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat const setPin = (key: string, star: HTMLButtonElement): void => { if (document.activeElement === star) refocusPin.current = key; const next = toggleSidebarPin(pinned, key); - setPinned(next); - saveSidebarPins(next); + setSidebarPins(next); }; const carded = settings.agentCards && !byActivity; const groups = useMemo(() => !carded ? null : groupRows(split.rest, ({ machine, entry }) => JSON.stringify([machine.id, entry.workspace.workspace_id])), [split, carded]); diff --git a/src/lib/api.ts b/src/lib/api.ts index 8caca2743b..8296b2b7a2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,3 +1,4 @@ +import type { PreferencesChange, SharedPreferences } from "../../shared/preferences.ts"; import { machinePath, type BridgeHealth, type HerdrIdentity, type Machine, type SetupAction, type SetupJob, type SetupRequest } from "../../shared/machines.ts"; import type { AgentKind, @@ -106,6 +107,19 @@ export async function requestHerdrUpdate(): Promise { } /** Anonymous install and update counts; null from a server that sends none (404). */ +/** What every browser of this server shares (shared/preferences.ts); null where the server predates it. */ +export async function fetchPreferences(): Promise { + try { return await getJson("/api/preferences"); } + catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } +} + +export async function savePreferences(body: PreferencesChange): Promise { + const url = "/api/preferences"; + const response = await fetch(url, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + if (!response.ok) throw await errorFrom(url, response); + return (await response.json()) as SharedPreferences; +} + export async function fetchTelemetry(): Promise { try { return await getJson("/api/telemetry"); } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } diff --git a/src/lib/preferencesSync.test.ts b/src/lib/preferencesSync.test.ts new file mode 100644 index 0000000000..4a70f0975f --- /dev/null +++ b/src/lib/preferencesSync.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test"; +import { DEFAULT_SETTINGS, sharedSettings } from "./settings.ts"; +import { preferencesChange, withShared } from "./preferencesSync.ts"; + +describe("preferences sync", () => { + it("sends only the shared settings that changed, and the pins only when they did", () => { + const base = { settings: sharedSettings(DEFAULT_SETTINGS), pins: ["local:w1:p1"] }; + expect(preferencesChange(base, { ...DEFAULT_SETTINGS, terminalFontSize: 20, alertsOn: false }, ["local:w1:p1"])).toBeNull(); + expect(preferencesChange(base, { ...DEFAULT_SETTINGS, theme: "light", terminalFontSize: 20 }, ["local:w1:p1"])).toEqual({ settings: { theme: "light" } }); + expect(preferencesChange(base, DEFAULT_SETTINGS, [])).toEqual({ settings: {}, pins: [] }); + expect(preferencesChange(base, { ...DEFAULT_SETTINGS, usageOrder: ["claude"] }, ["local:w1:p1"])).toEqual({ settings: { usageOrder: ["claude"] } }); + }); + + it("takes only the shared part, and an invalid value as the setting's default would be read", () => { + const mine = { ...DEFAULT_SETTINGS, theme: "light" as const, terminalFontSize: 20, alertsOn: false }; + const taken = withShared(mine, { settings: { theme: "dark", palette: "forest" } }); + expect([taken.theme, taken.palette, taken.terminalFontSize, taken.alertsOn]).toEqual(["dark", "forest", 20, false]); + const odd = withShared(DEFAULT_SETTINGS, { settings: { theme: "purple", agentCards: "yes" } }); + expect([odd.theme, odd.agentCards]).toEqual([DEFAULT_SETTINGS.theme, DEFAULT_SETTINGS.agentCards]); + }); +}); diff --git a/src/lib/preferencesSync.ts b/src/lib/preferencesSync.ts new file mode 100644 index 0000000000..ac4031e6f0 --- /dev/null +++ b/src/lib/preferencesSync.ts @@ -0,0 +1,104 @@ +/** + * Keeps the shared settings and the pins (shared/preferences.ts) the same in every browser of this + * server. Loaded once the app is in (never before the access gate): what the server holds is laid + * over this browser's settings; a setting nobody has changed since stays this browser's own, and + * opening a page writes nothing. A change here sends just what changed, once the settings rest; + * another browser's arrives as a `preferences` event. Last write wins, setting by setting. + */ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { PreferencesChange, SharedPreferences } from "../../shared/preferences.ts"; +import { ApiError, fetchPreferences, savePreferences } from "./api.ts"; +import { sanitizeSettings, sharedSettings, useSettings, type Settings } from "./settings.ts"; +import { currentSidebarPins, setSidebarPins, subscribeSidebarPins } from "./sidebarPins.ts"; + +const SEND_AFTER_MS = 600; +/** a send that failed on the way (the network, a restart) is tried again after this */ +const RETRY_MS = 5_000; + +type Shared = ReturnType; +interface Baseline { settings: Shared; pins: readonly string[] } + +/** The settings with the shared part of `remote` over them, as the browser will hold them. */ +export function withShared(settings: Settings, remote: Pick): Settings { + return sanitizeSettings({ ...settings, ...remote.settings }); +} + +/** What changed here since `base`: the shared settings that differ, and the pins when they do; null when nothing did. */ +export function preferencesChange(base: Baseline, settings: Settings, pins: readonly string[]): PreferencesChange | null { + const now = sharedSettings(settings); + const changed = Object.fromEntries(Object.entries(now).filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(base.settings[key as keyof Shared]))); + const pinsChanged = JSON.stringify(pins) !== JSON.stringify(base.pins); + if (Object.keys(changed).length === 0 && !pinsChanged) return null; + return pinsChanged ? { settings: changed, pins: [...pins] } : { settings: changed }; +} + +export function usePreferencesSync(enabled: boolean): (preferences: SharedPreferences) => void { + const { settings, update } = useSettings(); + const pins = useSyncExternalStore(subscribeSidebarPins, currentSidebarPins, currentSidebarPins); + const latest = useRef({ settings, pins }); + latest.current = { settings, pins }; + // what this browser held when it last matched the server; null until loaded + const base = useRef(null); + const revision = useRef(0); + // a server without the route: sync stands down, settings stay this browser's + const off = useRef(false); + // a watching device: it takes what the others share and sends nothing; a change of its own lasts until the next shared one + const readOnly = useRef(false); + const sending = useRef(false); + // the send effect runs again when this moves: after a send, or a failed one's wait + const [attempt, setAttempt] = useState(0); + + const apply = useCallback((remote: SharedPreferences) => { + const next = withShared(latest.current.settings, remote); + revision.current = Math.max(revision.current, remote.revision); + base.current = { settings: sharedSettings(next), pins: remote.pins }; + update(Object.fromEntries(Object.keys(remote.settings).map((key) => [key, next[key as keyof Settings]])) as Partial); + setSidebarPins(remote.pins); + }, [update]); + + // what another browser wrote; an older one, or this browser's own coming back, is already here + const receive = useCallback((remote: SharedPreferences) => { + if (off.current || base.current === null || remote.revision <= revision.current) return; + // a change made here and not yet sent wins: it goes out next, over this one + if (!readOnly.current && preferencesChange(base.current, latest.current.settings, latest.current.pins) !== null) return; + apply(remote); + }, [apply]); + + useEffect(() => { + if (!enabled || base.current !== null || off.current) return; + let cancelled = false; + fetchPreferences().then((remote) => { + if (cancelled) return; + if (remote === null) { off.current = true; return; } + if (remote.revision > 0) apply(remote); + else base.current = { settings: sharedSettings(latest.current.settings), pins: latest.current.pins }; + }).catch(() => { /* tried again on the next sign-in or load */ }); + return () => { cancelled = true; }; + }, [enabled, apply]); + + const change = base.current === null ? null : preferencesChange(base.current, settings, pins); + const pending = change === null ? null : JSON.stringify(change); + useEffect(() => { + if (!enabled || off.current || readOnly.current || pending === null || sending.current) return; + const timer = window.setTimeout(() => { + const body = JSON.parse(pending) as PreferencesChange; + const sent = { settings: sharedSettings(latest.current.settings), pins: latest.current.pins }; + sending.current = true; + savePreferences(body).then((saved) => { + revision.current = Math.max(revision.current, saved.revision); + base.current = sent; + sending.current = false; + // what changed while this was on its way goes out now + setAttempt((count) => count + 1); + }).catch((error: unknown) => { + sending.current = false; + if (error instanceof ApiError && error.status === 403) readOnly.current = true; + else if (error instanceof ApiError && error.status === 404) off.current = true; + else window.setTimeout(() => setAttempt((count) => count + 1), RETRY_MS); + }); + }, SEND_AFTER_MS); + return () => window.clearTimeout(timer); + }, [enabled, pending, attempt]); + + return receive; +} diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 748e0c81ee..e64dec9a7a 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -4,7 +4,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { FONT_FAMILY_MAX_CHARS } from "./fontFamily.ts"; import { DEFAULT_KEY_BAR_ITEMS, migrateKeyBarItems } from "./keyBar.ts"; -import { changedSettings, alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews, DICTATION_LANGUAGES, VOICE_BUTTONS, wantsVoiceInput } from "./settings.ts"; +import { SHARED_SETTING_KEYS } from "../../shared/preferences.ts"; +import { SETTING_SCOPE, changedSettings, alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews, DICTATION_LANGUAGES, VOICE_BUTTONS, wantsVoiceInput } from "./settings.ts"; it("keeps a tab's pane while out of use until this device turns the pause on", () => { expect(DEFAULT_SETTINGS.releasePaneAway).toBe(false); @@ -575,3 +576,11 @@ it("highlights code unless turned off, and keeps a stored choice only when it is expect(sanitizeSettings({ highlightCode: "no" }).highlightCode).toBe(true); expect(sanitizeSettings({}).highlightCode).toBe(true); }); + +describe("shared settings", () => { + it("shares exactly the settings SETTING_SCOPE marks shared", () => { + const shared = Object.entries(SETTING_SCOPE).filter(([, scope]) => scope === "shared").map(([key]) => key).sort(); + expect([...SHARED_SETTING_KEYS].sort() as string[]).toEqual(shared); + expect(Object.keys(SETTING_SCOPE).sort()).toEqual(Object.keys(DEFAULT_SETTINGS).sort()); + }); +}); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index dd8ad42fcd..ee7e2c0e08 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -6,6 +6,7 @@ */ import { REPORTED_SETTINGS, type ReportedSetting } from "../../shared/telemetry.ts"; +import { SHARED_SETTING_KEYS, type SharedSettingKey } from "../../shared/preferences.ts"; import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { LANGUAGE_SETTINGS, LOCALE_TAGS, resolveLanguage, setCurrentLanguage, type Language, type LanguageSetting } from "./i18n.ts"; import type { AlertPrefs, DoneAlerts } from "../../shared/notify-policy.ts"; @@ -157,6 +158,29 @@ export function changedSettings(settings: Settings): ReportedSetting[] { return REPORTED_SETTINGS.filter((name) => settings[name] !== DEFAULT_SETTINGS[name]); } +/** + * Which settings every browser of this server shares (shared/preferences.ts, lib/preferencesSync.ts) + * and which stay this device's: its screen, keyboard, terminal, alerts and microphone. A new setting + * must be placed here, or the build fails; when in doubt it is the device's. + */ +export const SETTING_SCOPE = { + theme: "shared", palette: "shared", language: "shared", sidebarRows: "shared", agentOrder: "shared", agentCards: "shared", + agentPins: "shared", autoSendQueued: "shared", showThinking: "shared", quickReplies: "shared", + showQuickReplies: "shared", showSuggestionChip: "shared", showUsage: "shared", usageCount: "shared", usagePace: "shared", + usageGlance: "shared", usageOrder: "shared", usageHidden: "shared", highlightCode: "shared", defaultView: "device", + terminalInputMode: "device", keyBarExtras: "device", keyBarItems: "device", shortcutOverrides: "device", density: "device", + quietOpenedDone: "device", terminalFontSize: "device", terminalCursorStyle: "device", terminalCursorBlink: "device", + terminalWheelSpeed: "device", terminalFontFamily: "device", paneClipboard: "device", releasePaneAway: "device", + narrowPaneFont: "device", chatFontSize: "device", chatFontFamily: "device", chatWidth: "device", enterSends: "device", + keepScreenOn: "device", alertsOn: "device", alertInput: "device", alertDone: "device", alertInApp: "device", alertSound: "device", + voiceInput: "device", voiceLanguage: "device", voicePolishChat: "device", voicePolishTerminal: "device", +} as const satisfies Record; + +/** The shared part of `settings`, by the names shared/preferences.ts lists. */ +export function sharedSettings(settings: Settings): Partial> { + return Object.fromEntries(SHARED_SETTING_KEYS.map((key) => [key, settings[key]])); +} + export const DEFAULT_SETTINGS: Settings = { terminalInputMode: "auto", keyBarExtras: ["alt"], diff --git a/src/lib/sidebarPins.ts b/src/lib/sidebarPins.ts index bbf89c1096..6c92aa6117 100644 --- a/src/lib/sidebarPins.ts +++ b/src/lib/sidebarPins.ts @@ -29,6 +29,32 @@ export function saveSidebarPins(keys: readonly string[]): void { catch { /* storage denied: pins last for the session */ } } +/** + * The pins this page holds, for the sidebar and for the sync with the other browsers + * (lib/preferencesSync.ts): set here, they are saved and every reader hears it. + */ +let pins: string[] | null = null; +const pinListeners = new Set<() => void>(); + +export function currentSidebarPins(): string[] { + pins ??= loadSidebarPins(); + return pins; +} + +export function setSidebarPins(keys: readonly string[]): void { + const next = [...new Set(keys)]; + const current = currentSidebarPins(); + if (next.length === current.length && next.every((key, index) => key === current[index])) return; + pins = next; + saveSidebarPins(next); + for (const listener of [...pinListeners]) listener(); +} + +export function subscribeSidebarPins(listener: () => void): () => void { + pinListeners.add(listener); + return () => { pinListeners.delete(listener); }; +} + export function toggleSidebarPin(keys: readonly string[], key: string): string[] { return keys.includes(key) ? keys.filter((current) => current !== key) : [...keys, key]; } From 08e6c8bedb443886e4360999ba97d8e976108736 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 12 Oct 2026 09:57:40 +0900 Subject: [PATCH 2/2] fix(preferences): converge the shared settings and pins across browsers Review findings (Codex, CodeRabbit) on the sync: - One path reads every document (the GET, an event, the answer to a save): a setting changed here and not yet sent stays and goes out next, a value this browser cannot read is left alone, the rest is taken. A document that arrives while a change is on its way is held and read after the answer, so a later write from elsewhere is never lost and an earlier one never undoes the change in flight. The baseline exists from the first render, so a change made before the first document arrives is kept and sent; a failed GET is retried. - The server sends its document when the event stream opens: a page that reconnects, or whose GET lost a race with an event, catches up. - Pins are absent from the document until a browser shares its own; a settings change no longer records an empty pin list that a reload would take. The browser that shares first carries its pins along, and the first pins a browser receives join its own, so none are lost in the move. After that the last write wins, as for settings. - Pins of panes gone from the server leave when the user next pins or unpins, not from an effect: a page that has not seen a pane yet (a PC still loading) must not delete everyone's pin of it. - The server keeps and announces a change only once it is on disk, a body announced over 64 KB is refused unread, and the document a change would make is held under the limit too (413). - A preferences event no longer invalidates the roster poll. Unit tests cover the reconcile rules; the browser regression adds the first-share pins, a write from elsewhere landing during an in-flight save, a later page taking the document, and a page whose GET fails catching up from the event stream. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +- scripts/preferences-sync-regression.ts | 97 ++++++++------ server/machine-api.ts | 9 +- server/preferences.test.ts | 43 +++++-- server/preferences.ts | 34 +++-- shared/preferences.ts | 7 +- src/App.tsx | 2 +- src/components/AgentSidebar.tsx | 13 +- src/lib/preferencesSync.test.ts | 87 ++++++++++--- src/lib/preferencesSync.ts | 167 +++++++++++++++++-------- 10 files changed, 331 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 975bab3e58..5ccb924217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa the sidebar's rows, order, cards and pins, chat and usage-meter choices. A change shows on the other open pages at once. Each device keeps its own terminal and chat font sizes, key bar, shortcuts, density, alerts, microphone, the lens panes open in and the like. Opening a page never - overwrites anything: a server that holds no shared settings yet takes them from the first change. - A watching device follows the shared settings and cannot change them for everyone. ([#854](https://github.com/devswha/herdr-web-ui/pull/854)) + overwrites anything: a server that holds no shared settings yet takes them from the first change, + and the browser that shares first brings its pins along. A page that lost its connection catches up + when it is back. A watching device follows the shared settings and cannot change them for everyone. ([#854](https://github.com/devswha/herdr-web-ui/pull/854)) ## [0.4.8] - 2026-10-12 diff --git a/scripts/preferences-sync-regression.ts b/scripts/preferences-sync-regression.ts index 02ddc92632..6b8672180d 100644 --- a/scripts/preferences-sync-regression.ts +++ b/scripts/preferences-sync-regression.ts @@ -1,8 +1,10 @@ /** * Two browsers of one server keep the shared settings and pins the same (lib/preferencesSync.ts): * a theme changed in one shows in the other at once, the other's terminal font (this device's) - * does not move and is never sent, pins written elsewhere reach an open page, and opening a page - * on an empty server sends nothing. No herdr session is opened. + * does not move and is never sent, the first browser to share carries its pins along and pins + * written elsewhere reach an open page, a change from elsewhere that lands while one of this + * browser's is on its way is kept, a page whose GET fails still catches up from the event stream, + * and opening a page on an empty server sends nothing. No herdr session is opened. */ import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; @@ -26,27 +28,17 @@ async function until(check: () => Promise, label: string): Promise>, own: Record = {}): Promise<{ context: BrowserContext; page: Page; puts: unknown[] }> { +interface Opened { context: BrowserContext; page: Page; puts: { settings: Record; pins?: string[] }[] } + +/** a browser of its own with `own` in its settings and `pins` pinned; `puts` collects what it sends to /api/preferences */ +async function browserAt(browser: Awaited>, own: Record = {}, pins: string[] = [], before?: (context: BrowserContext) => Promise): Promise { const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, locale: "en-US" }); - await context.addInitScript((own) => { - // what the page hears on its event stream, for the pins below (a pin of a pane that is not an - // agent here is dropped by the sidebar as soon as it lands, so the stored list cannot tell) - const heard: unknown[] = []; - (window as unknown as { heardPreferences: unknown[] }).heardPreferences = heard; - const Source = window.EventSource; - window.EventSource = class extends Source { - constructor(url: string | URL, init?: EventSourceInit) { - super(url, init); - this.addEventListener("message", (event) => { - const data = JSON.parse((event as MessageEvent).data) as { type: string; preferences?: unknown }; - if (data.type === "preferences") heard.push(data.preferences); - }); - } - }; + await context.addInitScript(([own, pins]) => { if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", theme: "dark", terminalFontSize: 13, ...own })); - }, own); - const puts: unknown[] = []; + if (!localStorage.getItem("herdr-web-ui:sidebar-agent-pins")) localStorage.setItem("herdr-web-ui:sidebar-agent-pins", JSON.stringify(pins)); + }, [own, pins] as const); + await before?.(context); + const puts: Opened["puts"] = []; const page = await context.newPage(); page.on("request", (request) => { if (request.method() === "PUT" && request.url().endsWith("/api/preferences")) puts.push(request.postDataJSON()); }); await page.goto(origin); @@ -56,21 +48,29 @@ async function browserAt(browser: Awaited>, o const theme = (page: Page) => page.evaluate(() => document.documentElement.dataset["theme"]); const stored = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:settings") ?? "{}") as Record); +const storedPins = (page: Page) => page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:sidebar-agent-pins") ?? "[]") as string[]); +const held = async () => await (await fetch(`${origin}/api/preferences`)).json() as { revision: number; settings: Record; pins?: string[] }; +const write = (page: Page, body: object) => page.evaluate(async (body) => (await fetch("/api/preferences", { method: "PUT", headers: { "content-type": "application/json" }, body })).status, JSON.stringify(body)); try { const browser = await chromium.launch({ executablePath: process.env["CHROME_PATH"] ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); try { - const a = await browserAt(browser); + // A has pinned an agent before this release; B has not. The pane need not exist here: a pin of a + // pane this page cannot see stays in the list (a PC still loading would look the same) + const a = await browserAt(browser, {}, ["local:w1:pa"]); const b = await browserAt(browser); await Bun.sleep(1_500); assert.deepEqual([...a.puts, ...b.puts], [], "opening a page on an empty server sends nothing"); + assert.deepEqual(await held(), { revision: 0, settings: {} }, "the server holds nothing, pins included"); - // A turns light: B follows while open, and only the shared settings went out + // A turns light: B follows while open, only the shared setting went out, and A's pins went with + // the first change since the server held none await a.page.goto(`${origin}/?settings=appearance`); await a.page.getByRole("group", { name: "Theme", exact: true }).getByRole("button", { name: "Light", exact: true }).click(); await until(async () => await theme(b.page) === "light", "B takes A's theme without a reload"); - assert.equal(a.puts.length >= 1, true); - assert.deepEqual((a.puts as { settings: Record }[]).map((body) => Object.keys(body.settings)), [["theme"]], "only the setting that changed is sent"); + assert.deepEqual(a.puts.map((body) => Object.keys(body.settings)), [["theme"]], "only the setting that changed is sent"); + assert.deepEqual(a.puts[0]?.pins, ["local:w1:pa"], "the first change carries this browser's pins"); + await until(async () => JSON.stringify(await storedPins(b.page)) === JSON.stringify(["local:w1:pa"]), "B takes A's pins"); // A's terminal font is A's own: B keeps 13 await a.page.goto(`${origin}/?settings=terminal`); @@ -80,22 +80,47 @@ try { assert.equal(a.puts.length, 1, "a device setting is never sent"); assert.equal((await stored(b.page))["terminalFontSize"], 13, "B's terminal font does not move"); - // pins written by another browser reach an open page - const pins = ["local:w9:p1"]; - const heardBefore = await a.page.evaluate(() => (window as unknown as { heardPreferences: unknown[] }).heardPreferences.length); - const status = await b.page.evaluate(async (body) => (await fetch("/api/preferences", { method: "PUT", headers: { "content-type": "application/json" }, body })).status, - JSON.stringify({ settings: { theme: "light" }, pins })); - assert.equal(status, 200); - await until(async () => await a.page.evaluate(([from, want]) => (window as unknown as { heardPreferences: { pins: string[] }[] }).heardPreferences.slice(from) - .some((preferences) => JSON.stringify(preferences.pins) === want), [heardBefore, JSON.stringify(pins)] as const), "A hears the pins"); + // pins written by another browser reach an open page, and stay (the last write wins now) + assert.equal(await write(b.page, { settings: {}, pins: ["local:w9:p1"] }), 200); + await until(async () => JSON.stringify(await storedPins(a.page)) === JSON.stringify(["local:w9:p1"]), "A takes the pins written elsewhere"); + + // a change from elsewhere that lands while A's own is on its way is kept, and A's still arrives + await a.page.route("**/api/preferences", async (route) => { + if (route.request().method() === "PUT") await Bun.sleep(1_500); + await route.continue(); + }); + await a.page.goto(`${origin}/?settings=appearance`); + await a.page.getByRole("group", { name: "Theme", exact: true }).getByRole("button", { name: "Dark", exact: true }).click(); + await Bun.sleep(900); + assert.equal(a.puts.length, 2, "A's theme is on its way"); + assert.equal(await write(b.page, { settings: { palette: "forest" } }), 200); + await until(async () => (await stored(a.page))["palette"] === "forest", "A takes B's palette while its own change is in flight"); + await until(async () => { const now = await held(); return now.settings["theme"] === "dark" && now.settings["palette"] === "forest"; }, "the server holds both changes"); + assert.equal((await stored(a.page))["theme"], "dark", "A keeps its own change"); + await until(async () => await theme(b.page) === "dark", "B takes A's theme"); + await Bun.sleep(1_500); + assert.equal(a.puts.length, 2, "A sends nothing back for what it took"); + await a.page.unrouteAll({ behavior: "wait" }); // a page opened later starts from what the server holds, and keeps a shared setting nobody changed const c = await browserAt(browser, { showThinking: false }); - await until(async () => await theme(c.page) === "light", "a new page takes the shared theme"); + await until(async () => await theme(c.page) === "dark" && (await stored(c.page))["palette"] === "forest", "a new page takes the shared theme and palette"); assert.equal((await stored(c.page))["terminalFontSize"], 13, "and keeps its own terminal font"); assert.equal((await stored(c.page))["showThinking"], false, "and a shared setting nobody changed stays its own"); - for (const opened of [a, b, c]) await opened.context.close(); - console.log("PASS shared settings and pins follow across browsers, device settings stay, an empty server is never written on load"); + assert.deepEqual(await storedPins(c.page), ["local:w9:p1"], "and takes the shared pins"); + + // a page whose GET fails catches up from the event stream, which opens with the document + const d = await browserAt(browser, {}, [], async (context) => { + await context.route("**/api/preferences", async (route) => { + if (route.request().method() === "GET") await route.abort(); + else await route.continue(); + }); + }); + await until(async () => (await stored(d.page))["palette"] === "forest", "a page without the GET takes the document from the event stream"); + await Bun.sleep(1_500); + assert.deepEqual([...c.puts, ...d.puts], [], "pages opened later send nothing"); + for (const opened of [a, b, c, d]) await opened.context.close(); + console.log("PASS shared settings and pins follow across browsers, device settings stay, concurrent changes both land, an empty server is never written on load"); } finally { await browser.close(); } diff --git a/server/machine-api.ts b/server/machine-api.ts index f87b50761e..66144135af 100644 --- a/server/machine-api.ts +++ b/server/machine-api.ts @@ -23,7 +23,7 @@ function transportFailure(id: string, path: string, error: unknown): Response { } export async function handleMachineRequest(request: Request, manager: MachineManager, onRevoke?: (close: () => void) => () => void, - preferences?: { subscribe(listener: (preferences: SharedPreferences) => void): () => void }): Promise { + preferences?: { get(): SharedPreferences; subscribe(listener: (preferences: SharedPreferences) => void): () => void }): Promise { const url = new URL(request.url); // A PC's file opens as this PC's /api/fs/file does, from any navigation: reading it changes // nothing, and Chrome on Android shows a PDF in the viewer's frame as an "Open" button whose @@ -55,8 +55,11 @@ export async function handleMachineRequest(request: Request, manager: MachineMan if (closed) return; const send = (text: string) => { if (closed) return; if ((controller.desiredSize ?? 0) < -4) { close(); return; } try { controller.enqueue(encoder.encode(text)); } catch { close(); } }; stop = manager.subscribe((event) => send(`data: ${JSON.stringify(event)}\n\n`)); - // the shared preferences ride the same stream: one connection per browser - stopPreferences = preferences?.subscribe((shared) => send(`data: ${JSON.stringify({ type: "preferences", preferences: shared } satisfies MachineEvent)}\n\n`)); + // the shared preferences ride the same stream, one connection per browser; the document + // opens it, so a page that reconnects (or whose GET lost a race) holds the latest + const sendPreferences = (shared: SharedPreferences) => send(`data: ${JSON.stringify({ type: "preferences", preferences: shared } satisfies MachineEvent)}\n\n`); + stopPreferences = preferences?.subscribe(sendPreferences); + if (preferences) sendPreferences(preferences.get()); if (closed) { cleanup(); return; } heartbeat = setInterval(() => send(": heartbeat\n\n"), 15_000); request.signal.addEventListener("abort", close, { once: true }); diff --git a/server/preferences.test.ts b/server/preferences.test.ts index 9355d21cf4..1a56c3e28a 100644 --- a/server/preferences.test.ts +++ b/server/preferences.test.ts @@ -9,25 +9,28 @@ const roots: string[] = []; const root = (): string => { const dir = mkdtempSync(join(tmpdir(), "herdr-preferences-")); roots.push(dir); return dir; }; afterEach(() => { for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); }); -const put = (store: PreferenceStore, body: string) => handlePreferencesRequest(new Request("http://x/api/preferences", { method: "PUT", body }), store); +const put = (store: PreferenceStore, body: string, headers: Record = {}) => handlePreferencesRequest(new Request("http://x/api/preferences", { method: "PUT", body, headers }), store); describe("shared preferences", () => { it("starts empty, keeps only the shared names, counts each write, and survives a restart", async () => { const dir = root(); const store = new PreferenceStore(dir); - expect(await (await handlePreferencesRequest(new Request("http://x/api/preferences"), store)).json()).toEqual({ revision: 0, settings: {}, pins: [] }); + // no pins until a browser shares its own: an empty list would be taken for everyone's + expect(await (await handlePreferencesRequest(new Request("http://x/api/preferences"), store)).json()).toEqual({ revision: 0, settings: {} }); const heard: number[] = []; store.subscribe((preferences) => heard.push(preferences.revision)); + const settingsOnly = await put(store, JSON.stringify({ settings: { showThinking: false } })); + expect(await settingsOnly.json()).toEqual({ revision: 1, settings: { showThinking: false } }); const first = await put(store, JSON.stringify({ settings: { theme: "light", terminalFontSize: 22, alertsOn: false, nonsense: 1 }, pins: ["local:w1:p1", "local:w1:p1", "pc:w2:p3"] })); expect(first.status).toBe(200); - expect(await first.json()).toEqual({ revision: 1, settings: { theme: "light" }, pins: ["local:w1:p1", "pc:w2:p3"] }); + expect(await first.json()).toEqual({ revision: 2, settings: { showThinking: false, theme: "light" }, pins: ["local:w1:p1", "pc:w2:p3"] }); // a change lays its settings over what is held, and leaves the pins when it names none await put(store, JSON.stringify({ settings: { agentPins: true } })); - expect(store.get()).toEqual({ revision: 2, settings: { theme: "light", agentPins: true }, pins: ["local:w1:p1", "pc:w2:p3"] }); + expect(store.get()).toEqual({ revision: 3, settings: { showThinking: false, theme: "light", agentPins: true }, pins: ["local:w1:p1", "pc:w2:p3"] }); await put(store, JSON.stringify({ settings: { theme: "dark" }, pins: [] })); - expect(heard).toEqual([1, 2, 3]); - expect(JSON.parse(readFileSync(join(dir, "preferences.json"), "utf8"))).toMatchObject({ revision: 3, settings: { theme: "dark", agentPins: true }, pins: [] }); - expect(new PreferenceStore(dir).get()).toEqual({ revision: 3, settings: { theme: "dark", agentPins: true }, pins: [] }); + expect(heard).toEqual([1, 2, 3, 4]); + expect(JSON.parse(readFileSync(join(dir, "preferences.json"), "utf8"))).toMatchObject({ revision: 4, settings: { showThinking: false, theme: "dark", agentPins: true }, pins: [] }); + expect(new PreferenceStore(dir).get()).toEqual({ revision: 4, settings: { showThinking: false, theme: "dark", agentPins: true }, pins: [] }); }); it("refuses what is not a preferences document", async () => { @@ -37,13 +40,37 @@ describe("shared preferences", () => { expect((await put(store, JSON.stringify({ settings: {}, pins: [1] }))).status).toBe(400); expect((await put(store, JSON.stringify({ settings: {}, pins: Array.from({ length: MAX_PINS + 1 }, (_, i) => `pc:${i}`) }))).status).toBe(400); expect((await put(store, JSON.stringify({ settings: { quickReplies: ["x".repeat(70_000)] }, pins: [] }))).status).toBe(413); + // a body that says it is too large is refused unread + expect((await put(store, "{}", { "content-length": String(70_000) })).status).toBe(413); expect((await handlePreferencesRequest(new Request("http://x/api/preferences", { method: "POST", body: "{}" }), store)).status).toBe(405); expect(store.get().revision).toBe(0); }); + it("keeps the whole document under the limit, not only each change", async () => { + const store = new PreferenceStore(root()); + expect((await put(store, JSON.stringify({ settings: { quickReplies: ["x".repeat(40_000)] } }))).status).toBe(200); + const over = await put(store, JSON.stringify({ settings: { usageOrder: ["y".repeat(40_000)] } })); + expect(over.status).toBe(413); + expect(store.get()).toMatchObject({ revision: 1 }); + expect(store.get().settings["usageOrder"]).toBeUndefined(); + }); + + it("holds and tells a change only once it is on disk", async () => { + const dir = root(); + const file = join(dir, "state"); + writeFileSync(file, "a file where the state directory should be"); + const store = new PreferenceStore(file); + const heard: number[] = []; + store.subscribe((preferences) => heard.push(preferences.revision)); + const failed = await put(store, JSON.stringify({ settings: { theme: "light" } })); + expect(failed.status).toBe(500); + expect(await (await handlePreferencesRequest(new Request("http://x/api/preferences"), store)).json()).toEqual({ revision: 0, settings: {} }); + expect(heard).toEqual([]); + }); + it("starts empty over a damaged file", () => { const dir = root(); writeFileSync(join(dir, "preferences.json"), "not json"); - expect(new PreferenceStore(dir).get()).toEqual({ revision: 0, settings: {}, pins: [] }); + expect(new PreferenceStore(dir).get()).toEqual({ revision: 0, settings: {} }); }); }); diff --git a/server/preferences.ts b/server/preferences.ts index 4ab656d12a..43e1e76dd2 100644 --- a/server/preferences.ts +++ b/server/preferences.ts @@ -3,14 +3,21 @@ import { dirname, join } from "node:path"; import { MAX_PREFERENCES_BYTES, sharedPreferencesBody, type PreferencesChange, type SharedPreferences } from "../shared/preferences.ts"; import { badRequest, isJsonObject, jsonResponse } from "./http.ts"; +const byteLength = (text: string): number => new TextEncoder().encode(text).length; + +/** The document a change would make is over MAX_PREFERENCES_BYTES; nothing was kept. */ +export class PreferencesTooLarge extends Error { + constructor() { super(`Shared preferences are at most ${MAX_PREFERENCES_BYTES / 1024} KB`); } +} + /** * The preferences every browser of this server shares (shared/preferences.ts), kept in the state * directory beside devices.json. One document: a browser sends the settings it changed (and the * pins when they changed), laid over what is held, last write wins per setting; the others take - * the whole document from the event. + * the whole document from the event. A change is held, and told, only once it is on disk. */ export class PreferenceStore { - private current: SharedPreferences = { revision: 0, settings: {}, pins: [] }; + private current: SharedPreferences = { revision: 0, settings: {} }; private readonly path: string; private readonly listeners = new Set<(preferences: SharedPreferences) => void>(); @@ -20,7 +27,7 @@ export class PreferenceStore { const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8")); const body = sharedPreferencesBody(parsed); const revision = isJsonObject(parsed) && Number.isSafeInteger(parsed["revision"]) ? parsed["revision"] as number : 0; - if (body !== null) this.current = { revision: Math.max(0, revision), settings: body.settings, pins: body.pins ?? [] }; + if (body !== null) this.current = { revision: Math.max(0, revision), settings: body.settings, ...(body.pins === undefined ? {} : { pins: body.pins }) }; } catch (error) { // a missing file is a server that has shared nothing yet; a damaged one is replaced by the next write if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Cannot read ${this.path}; shared preferences start empty`); @@ -30,11 +37,15 @@ export class PreferenceStore { get(): SharedPreferences { return this.current; } set(change: PreferencesChange): SharedPreferences { - this.current = { revision: this.current.revision + 1, settings: { ...this.current.settings, ...change.settings }, pins: change.pins ?? this.current.pins }; + const pins = change.pins ?? this.current.pins; + const next: SharedPreferences = { revision: this.current.revision + 1, settings: { ...this.current.settings, ...change.settings }, ...(pins === undefined ? {} : { pins }) }; + const text = `${JSON.stringify(next, null, 2)}\n`; + if (byteLength(JSON.stringify(next)) > MAX_PREFERENCES_BYTES) throw new PreferencesTooLarge(); mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); const temporary = `${this.path}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify(this.current, null, 2)}\n`, { mode: 0o600 }); + writeFileSync(temporary, text, { mode: 0o600 }); renameSync(temporary, this.path); + this.current = next; for (const listener of [...this.listeners]) listener(this.current); return this.current; } @@ -45,17 +56,24 @@ export class PreferenceStore { } } -/** /api/preferences: GET the shared document, PUT a new one. A watching device's PUT is refused before this (index.ts). */ +/** /api/preferences: GET the shared document, PUT a change to it. A watching device's PUT is refused before this (index.ts). */ export async function handlePreferencesRequest(request: Request, store: PreferenceStore): Promise { const reply = (body: unknown, status = 200) => jsonResponse(body, status, { "cache-control": "no-store" }); + const tooLarge = () => reply({ error: { code: "too_large", message: new PreferencesTooLarge().message } }, 413); if (request.method === "GET") return reply(store.get()); if (request.method !== "PUT") return reply({ error: { code: "method_not_allowed", message: "Use GET or PUT /api/preferences" } }, 405); + // a body announced over the limit is refused unread; one that lies about its length is measured + const announced = Number(request.headers.get("content-length")); + if (Number.isFinite(announced) && announced > MAX_PREFERENCES_BYTES) return tooLarge(); const text = await request.text(); - if (new TextEncoder().encode(text).length > MAX_PREFERENCES_BYTES) return reply({ error: { code: "too_large", message: "Shared preferences are at most 64 KB" } }, 413); + if (byteLength(text) > MAX_PREFERENCES_BYTES) return tooLarge(); let raw: unknown; try { raw = JSON.parse(text); } catch { return badRequest("invalid_json", "The body is not JSON"); } const body = sharedPreferencesBody(raw); if (body === null) return badRequest("invalid_body", "Send { settings: object, pins?: string[] }"); try { return reply(store.set(body)); } - catch (error) { return reply({ error: { code: "preferences_unwritable", message: error instanceof Error ? error.message : String(error) } }, 500); } + catch (error) { + if (error instanceof PreferencesTooLarge) return tooLarge(); + return reply({ error: { code: "preferences_unwritable", message: error instanceof Error ? error.message : String(error) } }, 500); + } } diff --git a/shared/preferences.ts b/shared/preferences.ts index fda5e97176..e21f801096 100644 --- a/shared/preferences.ts +++ b/shared/preferences.ts @@ -16,14 +16,15 @@ export type SharedSettingKey = (typeof SHARED_SETTING_KEYS)[number]; export interface SharedPreferences { /** 0 until a browser first writes; each write adds one */ revision: number; + /** only the settings some browser has shared: one nobody changed is absent, and stays each device's own */ settings: Partial>; - /** sidebar pin keys (`machine:pane`), in their order */ - pins: string[]; + /** sidebar pin keys (`machine:pane`), in their order; absent until a browser first shares its pins */ + pins?: string[]; } export const MAX_PINS = 500; const MAX_PIN_LENGTH = 300; -/** a shared value is a few words or a short list; the whole document stays small */ +/** a shared value is a few words or a short list; a change and the whole document it makes stay small */ export const MAX_PREFERENCES_BYTES = 64 * 1024; const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/App.tsx b/src/App.tsx index 96504de366..28adfb33c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -464,6 +464,7 @@ export function App() { events.onmessage = (event) => { let payload: MachineEvent; try { payload = JSON.parse(event.data); } catch { return; } + if (payload.type === "preferences") { receivePreferencesRef.current(payload.preferences); return; } // A poll started before this event can carry an older roster or pane status. snapshotRequests.current.invalidate(); if (payload.type === "machines") { @@ -471,7 +472,6 @@ export function App() { setMachines((previous) => sameData(previous, payload.machines) ? previous : payload.machines); return; } - if (payload.type === "preferences") { receivePreferencesRef.current(payload.preferences); return; } const machine = machinesRef.current.find((m) => m.id === payload.machine_id); if (!machine) return; const message = payload.message; diff --git a/src/components/AgentSidebar.tsx b/src/components/AgentSidebar.tsx index 5f3e2f85c2..a991f75946 100644 --- a/src/components/AgentSidebar.tsx +++ b/src/components/AgentSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { ChevronDown, ChevronRight, Folder, Star, Terminal } from "lucide-react"; import type { Machine } from "../../shared/machines.ts"; @@ -89,11 +89,6 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat // pins lead: alone, the pinned rows stand first; in cards, each stays in its own card, first there, // and a card that holds one comes before the cards that hold none const split = useMemo(() => splitPinnedRows(rows, settings.agentPins ? pinned : NO_PINS, ({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id)), [rows, pinned, settings.agentPins]); - useEffect(() => { - const live = new Set(rows.map(({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id))); - const next = pruneSidebarPins(pinned, live, machines); - if (next.length !== pinned.length) setSidebarPins(next); - }, [machines, rows, pinned]); // a pin moves the row, and maybe its card, so React may draw a new star: a star that had the // keyboard focus hands it to its successor const sectionRef = useRef(null); @@ -106,8 +101,10 @@ export function AgentSidebar({ machines, selectedMachineId, selectedPaneId, stat }, [pinned]); const setPin = (key: string, star: HTMLButtonElement): void => { if (document.activeElement === star) refocusPin.current = key; - const next = toggleSidebarPin(pinned, key); - setSidebarPins(next); + // pins of panes gone from this server leave with a change of the user's, never by themselves: the + // list is shared, and a pane this page has not seen yet (a PC still loading) is not gone + const live = new Set(rows.map(({ machine, entry }) => sidebarPinKey(machine.id, entry.pane.pane_id))); + setSidebarPins(toggleSidebarPin(pruneSidebarPins(pinned, live, machines), key)); }; const carded = settings.agentCards && !byActivity; const groups = useMemo(() => !carded ? null : groupRows([...split.pinned, ...split.rest], ({ machine, entry }) => JSON.stringify([machine.id, entry.workspace.workspace_id])), [split, carded]); diff --git a/src/lib/preferencesSync.test.ts b/src/lib/preferencesSync.test.ts index 4a70f0975f..91e0cb9d04 100644 --- a/src/lib/preferencesSync.test.ts +++ b/src/lib/preferencesSync.test.ts @@ -1,21 +1,78 @@ import { describe, expect, it } from "bun:test"; -import { DEFAULT_SETTINGS, sharedSettings } from "./settings.ts"; -import { preferencesChange, withShared } from "./preferencesSync.ts"; +import { DEFAULT_SETTINGS, sharedSettings, type Settings } from "./settings.ts"; +import { preferencesChange, takePreferences, type Baseline } from "./preferencesSync.ts"; + +const base = (settings: Settings, pins: readonly string[] | null): Baseline => ({ settings: sharedSettings(settings), pins }); +const light: Settings = { ...DEFAULT_SETTINGS, theme: "light" }; describe("preferences sync", () => { it("sends only the shared settings that changed, and the pins only when they did", () => { - const base = { settings: sharedSettings(DEFAULT_SETTINGS), pins: ["local:w1:p1"] }; - expect(preferencesChange(base, { ...DEFAULT_SETTINGS, terminalFontSize: 20, alertsOn: false }, ["local:w1:p1"])).toBeNull(); - expect(preferencesChange(base, { ...DEFAULT_SETTINGS, theme: "light", terminalFontSize: 20 }, ["local:w1:p1"])).toEqual({ settings: { theme: "light" } }); - expect(preferencesChange(base, DEFAULT_SETTINGS, [])).toEqual({ settings: {}, pins: [] }); - expect(preferencesChange(base, { ...DEFAULT_SETTINGS, usageOrder: ["claude"] }, ["local:w1:p1"])).toEqual({ settings: { usageOrder: ["claude"] } }); - }); - - it("takes only the shared part, and an invalid value as the setting's default would be read", () => { - const mine = { ...DEFAULT_SETTINGS, theme: "light" as const, terminalFontSize: 20, alertsOn: false }; - const taken = withShared(mine, { settings: { theme: "dark", palette: "forest" } }); - expect([taken.theme, taken.palette, taken.terminalFontSize, taken.alertsOn]).toEqual(["dark", "forest", 20, false]); - const odd = withShared(DEFAULT_SETTINGS, { settings: { theme: "purple", agentCards: "yes" } }); - expect([odd.theme, odd.agentCards]).toEqual([DEFAULT_SETTINGS.theme, DEFAULT_SETTINGS.agentCards]); + const known = base(DEFAULT_SETTINGS, ["local:w1:p1"]); + expect(preferencesChange(known, { settings: { ...DEFAULT_SETTINGS, terminalFontSize: 20, alertsOn: false }, pins: ["local:w1:p1"] })).toBeNull(); + expect(preferencesChange(known, { settings: { ...light, terminalFontSize: 20 }, pins: ["local:w1:p1"] })).toEqual({ settings: { theme: "light" } }); + expect(preferencesChange(known, { settings: DEFAULT_SETTINGS, pins: [] })).toEqual({ settings: {}, pins: [] }); + expect(preferencesChange(known, { settings: { ...DEFAULT_SETTINGS, usageOrder: ["claude"] }, pins: ["local:w1:p1"] })).toEqual({ settings: { usageOrder: ["claude"] } }); + }); + + it("while the server has held no pins, this browser's ride along with its first change, and alone they wait", () => { + const unknown = base(DEFAULT_SETTINGS, null); + expect(preferencesChange(unknown, { settings: DEFAULT_SETTINGS, pins: ["local:w1:p1"] })).toBeNull(); + expect(preferencesChange(unknown, { settings: light, pins: ["local:w1:p1"] })).toEqual({ settings: { theme: "light" }, pins: ["local:w1:p1"] }); + }); + + it("takes the shared settings the server holds and leaves the device's own", () => { + const mine: Settings = { ...light, terminalFontSize: 20, alertsOn: false }; + const taken = takePreferences(base(mine, null), { settings: mine, pins: [] }, { revision: 3, settings: { theme: "dark", palette: "forest" } }, null); + expect(taken.patch).toEqual({ theme: "dark", palette: "forest" }); + expect(taken.pins).toBeNull(); + expect(taken.base).toEqual({ settings: { ...sharedSettings(mine), theme: "dark", palette: "forest" }, pins: null }); + // nothing to send once taken + expect(preferencesChange(taken.base, { settings: { ...mine, ...taken.patch }, pins: [] })).toBeNull(); + }); + + it("keeps a change made here and not yet sent, which then goes out over the server's value", () => { + // the page opened with dark, the user chose light before the document arrived + const taken = takePreferences(base(DEFAULT_SETTINGS, null), { settings: light, pins: [] }, { revision: 1, settings: { theme: "dark", showThinking: false } }, null); + expect(taken.patch).toEqual({ showThinking: false }); + expect(taken.base.settings["theme"]).toBe("dark"); + expect(preferencesChange(taken.base, { settings: { ...light, showThinking: false }, pins: [] })).toEqual({ settings: { theme: "light" }, pins: [] }); + }); + + it("lets a later write from elsewhere win over a value already sent, and keeps the rest of the document", () => { + // light is on its way; meanwhile another browser set the palette, and then the theme back to dark + const inFlight = { settings: { theme: "light" } }; + const concurrent = takePreferences(base(DEFAULT_SETTINGS, []), { settings: light, pins: [] }, { revision: 2, settings: { theme: "light", palette: "forest" }, pins: [] }, inFlight); + expect(concurrent.patch).toEqual({ theme: "light", palette: "forest" }); + const overtaken = takePreferences(base(DEFAULT_SETTINGS, []), { settings: light, pins: [] }, { revision: 3, settings: { theme: "dark" }, pins: [] }, inFlight); + expect(overtaken.patch).toEqual({ theme: "dark" }); + expect(preferencesChange(overtaken.base, { settings: { ...light, theme: "dark" }, pins: [] })).toBeNull(); + }); + + it("leaves a value this browser cannot read, and does not send its own back", () => { + const taken = takePreferences(base(DEFAULT_SETTINGS, null), { settings: DEFAULT_SETTINGS, pins: [] }, { revision: 1, settings: { theme: "purple", agentCards: "yes", palette: "forest" } }, null); + expect(taken.patch).toEqual({ palette: "forest" }); + expect(preferencesChange(taken.base, { settings: { ...DEFAULT_SETTINGS, palette: "forest" }, pins: [] })).toBeNull(); + }); + + it("joins this browser's pins to the first the server holds, then follows the last write", () => { + const first = takePreferences(base(DEFAULT_SETTINGS, null), { settings: DEFAULT_SETTINGS, pins: ["local:w1:p1"] }, { revision: 1, settings: {}, pins: ["pc:w2:p3", "local:w1:p1"] }, null); + expect(first.pins).toEqual(["pc:w2:p3", "local:w1:p1"]); + expect(first.base.pins).toEqual(["pc:w2:p3", "local:w1:p1"]); + const joined = takePreferences(base(DEFAULT_SETTINGS, null), { settings: DEFAULT_SETTINGS, pins: ["local:w1:p1"] }, { revision: 1, settings: {}, pins: ["pc:w2:p3"] }, null); + expect(joined.pins).toEqual(["pc:w2:p3", "local:w1:p1"]); + // the join is this browser's change: it goes out + expect(preferencesChange(joined.base, { settings: DEFAULT_SETTINGS, pins: joined.pins! })).toEqual({ settings: {}, pins: ["pc:w2:p3", "local:w1:p1"] }); + const later = takePreferences(base(DEFAULT_SETTINGS, ["pc:w2:p3"]), { settings: DEFAULT_SETTINGS, pins: ["pc:w2:p3"] }, { revision: 2, settings: {}, pins: [] }, null); + expect(later.pins).toEqual([]); + // a pin made here and not yet sent stays + const pinning = takePreferences(base(DEFAULT_SETTINGS, ["pc:w2:p3"]), { settings: DEFAULT_SETTINGS, pins: ["pc:w2:p3", "local:w1:p1"] }, { revision: 2, settings: {}, pins: [] }, null); + expect(pinning.pins).toBeNull(); + expect(pinning.base.pins).toEqual([]); + }); + + it("a watching device takes everything", () => { + const taken = takePreferences(base(DEFAULT_SETTINGS, ["a:b"]), { settings: light, pins: ["a:b", "c:d"] }, { revision: 5, settings: { theme: "dark" }, pins: [] }, null, true); + expect(taken.patch).toEqual({ theme: "dark" }); + expect(taken.pins).toEqual([]); }); }); diff --git a/src/lib/preferencesSync.ts b/src/lib/preferencesSync.ts index ac4031e6f0..20e45fd821 100644 --- a/src/lib/preferencesSync.ts +++ b/src/lib/preferencesSync.ts @@ -1,104 +1,173 @@ /** * Keeps the shared settings and the pins (shared/preferences.ts) the same in every browser of this * server. Loaded once the app is in (never before the access gate): what the server holds is laid - * over this browser's settings; a setting nobody has changed since stays this browser's own, and - * opening a page writes nothing. A change here sends just what changed, once the settings rest; - * another browser's arrives as a `preferences` event. Last write wins, setting by setting. + * over this browser's settings, setting by setting; a setting nobody has shared yet stays this + * browser's own, and opening a page writes nothing. A change here sends just what changed, once the + * settings rest; another browser's arrives as a `preferences` event, and the server sends its whole + * document each time the event stream opens, so a reconnected page catches up. Last write wins, + * setting by setting: a change made here and not yet sent stays, and goes out over what arrived. */ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; -import type { PreferencesChange, SharedPreferences } from "../../shared/preferences.ts"; +import { SHARED_SETTING_KEYS, type PreferencesChange, type SharedPreferences, type SharedSettingKey } from "../../shared/preferences.ts"; import { ApiError, fetchPreferences, savePreferences } from "./api.ts"; import { sanitizeSettings, sharedSettings, useSettings, type Settings } from "./settings.ts"; import { currentSidebarPins, setSidebarPins, subscribeSidebarPins } from "./sidebarPins.ts"; const SEND_AFTER_MS = 600; -/** a send that failed on the way (the network, a restart) is tried again after this */ +/** a request that failed on the way (the network, a restart) is tried again after this */ const RETRY_MS = 5_000; -type Shared = ReturnType; -interface Baseline { settings: Shared; pins: readonly string[] } +type Shared = Partial>; +/** What this browser last knew the server to hold; `pins` is null while the server has held none. */ +export interface Baseline { settings: Shared; pins: readonly string[] | null } +/** What this browser holds. */ +export interface Local { settings: Settings; pins: readonly string[] } -/** The settings with the shared part of `remote` over them, as the browser will hold them. */ -export function withShared(settings: Settings, remote: Pick): Settings { - return sanitizeSettings({ ...settings, ...remote.settings }); +const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +/** + * What changed here since `base`: the shared settings that differ, and the pins when they do; null + * when nothing did. While the server has held no pins, this browser's ride along with its first change, + * so the pins of the browser that shares first are not lost. + */ +export function preferencesChange(base: Baseline, local: Local): PreferencesChange | null { + const now = sharedSettings(local.settings); + const settings = Object.fromEntries(Object.entries(now).filter(([key, value]) => !same(value, base.settings[key as SharedSettingKey]))); + const pinsChanged = base.pins !== null && !same(local.pins, base.pins); + if (Object.keys(settings).length === 0 && !pinsChanged) return null; + return pinsChanged || base.pins === null ? { settings, pins: [...local.pins] } : { settings }; +} + +export interface Taken { + base: Baseline; + /** the settings to set here */ + patch: Partial; + /** the pins to set here; null leaves them */ + pins: string[] | null; } -/** What changed here since `base`: the shared settings that differ, and the pins when they do; null when nothing did. */ -export function preferencesChange(base: Baseline, settings: Settings, pins: readonly string[]): PreferencesChange | null { - const now = sharedSettings(settings); - const changed = Object.fromEntries(Object.entries(now).filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(base.settings[key as keyof Shared]))); - const pinsChanged = JSON.stringify(pins) !== JSON.stringify(base.pins); - if (Object.keys(changed).length === 0 && !pinsChanged) return null; - return pinsChanged ? { settings: changed, pins: [...pins] } : { settings: changed }; +/** + * What a document from the server does here. Each setting it holds replaces this browser's, except + * one changed here since `base` and not yet sent (it goes out next and wins), and one this browser + * cannot read (a newer release's value stays the others'). `inFlight` is the change the document + * answers: a value the server took is no longer this browser's own, so the document's stands (a + * document from before the change landed is held until the answer, lib/preferencesSync.ts). With + * `force` (a watching device, which sends nothing) every value is taken. The + * baseline takes the server's values either way. The first pins the server holds join this browser's: + * nobody loses pins when a server begins sharing them; after that the last write wins. + */ +export function takePreferences(base: Baseline, local: Local, remote: SharedPreferences, inFlight: PreferencesChange | null, force = false): Taken { + const settings: Shared = { ...base.settings }; + const patch: Record = {}; + for (const key of SHARED_SETTING_KEYS) { + if (!(key in remote.settings)) continue; + const value = remote.settings[key]; + if (!same(sanitizeSettings({ ...local.settings, [key]: value })[key], value)) continue; + const sent = inFlight !== null && key in inFlight.settings && same(inFlight.settings[key], local.settings[key]); + const edited = !same(local.settings[key], base.settings[key]) && !sent; + settings[key] = value; + if (force || !edited) patch[key] = value; + } + let pins: string[] | null = null; + if (remote.pins !== undefined) { + if (base.pins === null && !force) pins = [...remote.pins, ...local.pins.filter((pin) => !remote.pins!.includes(pin))]; + else { + const sent = inFlight?.pins !== undefined && same(inFlight.pins, local.pins); + const edited = !same(local.pins, base.pins) && !sent; + if (force || !edited) pins = [...remote.pins]; + } + } + return { base: { settings, pins: remote.pins === undefined ? base.pins : [...remote.pins] }, patch: patch as Partial, pins }; } export function usePreferencesSync(enabled: boolean): (preferences: SharedPreferences) => void { const { settings, update } = useSettings(); const pins = useSyncExternalStore(subscribeSidebarPins, currentSidebarPins, currentSidebarPins); - const latest = useRef({ settings, pins }); + const latest = useRef({ settings, pins }); latest.current = { settings, pins }; - // what this browser held when it last matched the server; null until loaded - const base = useRef(null); + // what this browser last knew the server to hold; until a document arrives, what it holds itself + const base = useRef({ settings: sharedSettings(settings), pins: null }); const revision = useRef(0); + // a document has arrived (the GET, or the event stream's opening): sending begins then + const loadedRef = useRef(false); + const [loaded, setLoaded] = useState(false); // a server without the route: sync stands down, settings stay this browser's const off = useRef(false); // a watching device: it takes what the others share and sends nothing; a change of its own lasts until the next shared one const readOnly = useRef(false); - const sending = useRef(false); + const inFlight = useRef(null); + // the newest document that arrived while a change was on its way: whether it is older or newer + // than that change shows once the answer, with its revision, is here + const held = useRef(null); // the send effect runs again when this moves: after a send, or a failed one's wait const [attempt, setAttempt] = useState(0); + const [loadAttempt, setLoadAttempt] = useState(0); + // the baseline is a ref: a document that changes nothing here still moves what is pending + const [, setTaken] = useState(0); - const apply = useCallback((remote: SharedPreferences) => { - const next = withShared(latest.current.settings, remote); + const absorb = useCallback((remote: SharedPreferences) => { + if (off.current || (loadedRef.current && remote.revision <= revision.current)) return; revision.current = Math.max(revision.current, remote.revision); - base.current = { settings: sharedSettings(next), pins: remote.pins }; - update(Object.fromEntries(Object.keys(remote.settings).map((key) => [key, next[key as keyof Settings]])) as Partial); - setSidebarPins(remote.pins); + const taken = takePreferences(base.current, latest.current, remote, inFlight.current, readOnly.current); + base.current = taken.base; + // a second document in the same tick compares against what this one set, not the last render + latest.current = { settings: sanitizeSettings({ ...latest.current.settings, ...taken.patch }), pins: taken.pins ?? latest.current.pins }; + if (Object.keys(taken.patch).length > 0) update(taken.patch); + if (taken.pins !== null) setSidebarPins(taken.pins); + loadedRef.current = true; + setLoaded(true); + setTaken((count) => count + 1); }, [update]); - // what another browser wrote; an older one, or this browser's own coming back, is already here const receive = useCallback((remote: SharedPreferences) => { - if (off.current || base.current === null || remote.revision <= revision.current) return; - // a change made here and not yet sent wins: it goes out next, over this one - if (!readOnly.current && preferencesChange(base.current, latest.current.settings, latest.current.pins) !== null) return; - apply(remote); - }, [apply]); + if (inFlight.current === null) { absorb(remote); return; } + if (held.current === null || remote.revision > held.current.revision) held.current = remote; + }, [absorb]); + + // the change is answered or lost: what arrived meanwhile is read now (an older document is dropped by its revision) + const settled = useCallback(() => { + inFlight.current = null; + const arrived = held.current; + held.current = null; + if (arrived !== null) absorb(arrived); + }, [absorb]); useEffect(() => { - if (!enabled || base.current !== null || off.current) return; + if (!enabled || off.current || loaded) return; let cancelled = false; + let retry: number | undefined; fetchPreferences().then((remote) => { if (cancelled) return; if (remote === null) { off.current = true; return; } - if (remote.revision > 0) apply(remote); - else base.current = { settings: sharedSettings(latest.current.settings), pins: latest.current.pins }; - }).catch(() => { /* tried again on the next sign-in or load */ }); - return () => { cancelled = true; }; - }, [enabled, apply]); + receive(remote); + }).catch(() => { if (!cancelled) retry = window.setTimeout(() => setLoadAttempt((count) => count + 1), RETRY_MS); }); + return () => { cancelled = true; window.clearTimeout(retry); }; + }, [enabled, loaded, loadAttempt, receive]); - const change = base.current === null ? null : preferencesChange(base.current, settings, pins); + const change = loaded ? preferencesChange(base.current, { settings, pins }) : null; const pending = change === null ? null : JSON.stringify(change); useEffect(() => { - if (!enabled || off.current || readOnly.current || pending === null || sending.current) return; + if (!enabled || !loaded || off.current || readOnly.current || pending === null || inFlight.current !== null) return; + let retry: number | undefined; const timer = window.setTimeout(() => { const body = JSON.parse(pending) as PreferencesChange; - const sent = { settings: sharedSettings(latest.current.settings), pins: latest.current.pins }; - sending.current = true; + inFlight.current = body; savePreferences(body).then((saved) => { - revision.current = Math.max(revision.current, saved.revision); - base.current = sent; - sending.current = false; + // the answer is the whole document as this change left it: one from elsewhere that landed + // before comes with it, one that landed after was held and is read now + absorb(saved); + settled(); // what changed while this was on its way goes out now setAttempt((count) => count + 1); }).catch((error: unknown) => { - sending.current = false; + settled(); if (error instanceof ApiError && error.status === 403) readOnly.current = true; else if (error instanceof ApiError && error.status === 404) off.current = true; - else window.setTimeout(() => setAttempt((count) => count + 1), RETRY_MS); + else retry = window.setTimeout(() => setAttempt((count) => count + 1), RETRY_MS); }); }, SEND_AFTER_MS); - return () => window.clearTimeout(timer); - }, [enabled, pending, attempt]); + return () => { window.clearTimeout(timer); window.clearTimeout(retry); }; + }, [enabled, loaded, pending, attempt, absorb, settled]); return receive; }