feat(ui): save appearance preferences per user profile (#130340)

* feat(ui): save appearance preferences per user profile

When a Control UI connection is bound to an authenticated user profile
(trusted-proxy, Tailscale, GitHub identity), theme, theme mode, and accent
become per-profile: stored in the existing user_preferences KV store via
users.prefs.set, overriding gateway-wide ui.prefs, with live cross-device
updates over a new additive users.prefs.changed event scoped to the same
merged profile. Restore default deletes only the profile key and falls back
to the gateway-wide value. talk.config projects the caller's profile accent
for native clients. Profile-less connections (token/password/none auth)
keep the existing gateway-config behavior byte-identically.

Release-note context: on multi-person gateways appearance choices are now
personal and follow you across devices; an admin restyling their own UI no
longer reskins the whole team.

* chore(protocol): allowlist users.prefs.changed for mobile clients

iOS and Android resolve the profile accent through talk.config on connect
and config refresh; live per-profile appearance push on natives is a named
follow-up.

* chore(protocol): regenerate Swift/Kotlin models for users.prefs.changed

* fix(ui): honor explicit defaults and identity switches for profile appearance

Addresses the three ClawSweeper review findings:
- resetValue for profile-bound appearance keys is the deletion fallback
  (gateway value), so an explicit product-default selection persists as a
  profile write instead of being misclassified as a reset.
- An identity switch between two known scopes forces a full appearance
  reconcile and clears appearance keys the returning identity never set,
  so a shared browser cannot keep the previous profile's look (boot keeps
  the last-seen shortcut).
- talk.config joins the profile-dependent dispatch gate so a pending
  GitHub identity sync completes before the profile accent is projected;
  token clients pass through untouched.

* chore(ui): satisfy assertion-safety ratchet and docs formatting

* refactor(ui): extract server-prefs storage primitives

server-prefs.ts crossed the 700-line cap after the review fixes; the
stateless localStorage persistence primitives move to
server-prefs-storage.ts (no behavior change). Shrink-prunes the
assertion-safety baseline for the moved casts.

* fix(ui): keep imported custom themes out of profile storage

Custom palettes are browser-local by contract, so a profile must never
carry theme=custom to a browser that cannot render it. The wire contract
drops custom from the storable theme values (readers self-heal any stored
value), a profile-bound custom selection stays retained browser-local
(including the offline-queued path), and the exhaustive theme test now
encodes the exception.
This commit is contained in:
Peter Steinberger
2026-08-26 16:48:26 -07:00
committed by GitHub
parent e9dc4b9d1a
commit a4c01c1a77
36 changed files with 1457 additions and 189 deletions
@@ -821,6 +821,7 @@ enum class GatewayEvent(
DevicePairResolved("device.pair.resolved"),
DevicePairSetupCompleted("device.pair.setup.completed"),
DevicePairSetupDeliveryUncertain("device.pair.setup.deliveryUncertain"),
UsersPrefsChanged("users.prefs.changed"),
SkillsChanged("skills.changed"),
VoicewakeChanged("voicewake.changed"),
VoicewakeRoutingChanged("voicewake.routing.changed"),
@@ -11255,6 +11255,24 @@ public struct AuditListResult: Codable, Sendable {
}
}
public struct UsersPrefsChangedEvent: Codable, Sendable {
public let profileid: String
public let keys: [String]
public init(
profileid: String,
keys: [String])
{
self.profileid = profileid
self.keys = keys
}
private enum CodingKeys: String, CodingKey {
case profileid = "profileId"
case keys
}
}
public struct TaskSuggestion: Codable, Sendable {
public let id: String
public let title: String
+1 -1
View File
@@ -3949,7 +3949,7 @@ ui/src/app/native-window-drag.ts 1
ui/src/app/overlays.ts 1
ui/src/app/question-prompt.ts 2
ui/src/app/server-prefs-state.ts 14
ui/src/app/server-prefs.ts 17
ui/src/app/server-prefs.ts 15
ui/src/app/settings.ts 9
ui/src/app/stale-chunk-reload.ts 1
ui/src/app/startup-settings.ts 1
@@ -1,5 +1,5 @@
{
"startupJsGzipBytes": 341333,
"reason": "Tide, Beacon, and Phosphor themes: theme-name wiring, three appearance options, locale strings, and two font stylesheet mappings",
"startupJsGzipBytes": 342930,
"reason": "per-profile appearance preferences: profile-scoped resolution, users.prefs routing, and live cross-device sync on the boot path; measured 342875 B on the tide/beacon/phosphor base, pinned with jitter headroom",
"updatedAt": "2026-08-26"
}
+6
View File
@@ -30,6 +30,12 @@ OpenClaw supplies exact trailers and the ordered contributor list in the model c
Turning **Git co-author credit** off stops attribution for future runs. It does not rewrite commits that already contain the public trailer.
## Profile appearance preferences
When a Control UI connection is bound to an authenticated Gateway profile, its theme, theme mode, and accent color are stored per profile in the existing `user_preferences` table in the shared state database. Those choices follow that person across devices without changing appearance for other people on the same Gateway.
Profile theme and theme mode preferences override their gateway-wide `ui.prefs` settings and otherwise fall back to the active theme's defaults. The imported custom theme is the exception: its palette lives only in the browser that imported it, so selecting it stays browser-local and never follows the profile. Accent precedence is the profile's `ui.accent` preference, gateway-wide `ui.prefs.accent`, `ui.seamColor`, and finally the active theme's default accent. Restoring a default clears only the profile preference. Connections without an authenticated profile, including token, password, or unauthenticated connections, keep the existing gateway-wide appearance behavior unchanged. Language, chat preferences, and sidebar entries continue using gateway configuration.
## Write directives, not observations
Each entry has a metadata line followed by one imperative directive:
+2 -2
View File
@@ -153,11 +153,11 @@ The Appearance panel has the built-in Claw, Knot, Dash, Absolutely, Tide, Beacon
Imported themes are stored only in the current browser profile; they are not written to gateway config and do not sync across devices. Replacing the imported theme updates the one local slot; clearing it switches back to Claw if the imported theme was active.
Choose an **Accent color** preset or custom color in Appearance to override the active theme's accent. This preference takes precedence over the operator-configured `ui.seamColor`. **Restore default** clears the preference and restores `ui.seamColor` when configured, or the theme's own accent otherwise.
Choose an **Accent color** preset or custom color in Appearance to override the active theme's accent. For an authenticated Gateway profile, the accent precedence is the profile's `ui.accent` preference, the gateway-wide `ui.prefs.accent` setting, the operator-configured `ui.seamColor`, and finally the active theme's default. **Restore default** clears only that profile's preference, leaving the gateway-wide settings unchanged. Connections without an authenticated profile keep the existing gateway-wide preference behavior.
Appearance also has a Text size setting. It applies to chat text, composer text, tool cards, and chat sidebars, and keeps text inputs at least 16px so mobile Safari does not auto-zoom on focus.
Theme, theme mode, accent color, language, and chat display preferences sync through the gateway config (`ui.prefs`), so they follow you across devices and agents can change them through the approval gate — connected clients apply changes live via the gateway's `config.changed` notice. Each browser keeps a local mirror for instant boot. Text size remains browser-local. An explicitly read-only connection applies preference changes only in that browser and does not attempt a config write. Changes made while offline remain queued until a later connection can write config; on a read-only reconnect, they continue to behave as browser-local preferences. See [Configuration reference](/gateway/configuration-reference#ui).
When your connection is bound to an authenticated Gateway profile, theme, theme mode, and accent color are saved to that profile instead of the gateway config. They follow you across devices without changing anyone else's appearance, override gateway-wide `ui.prefs` values, and update your connected clients live. Connections without an authenticated profile continue syncing these preferences through the gateway config exactly as before. Language and chat display preferences remain gateway-config preferences for every connection. Each browser keeps a local mirror for instant boot, and text size remains browser-local. An explicitly read-only connection applies preference changes only in that browser. Changes made while offline remain queued until a later connection can write their applicable preferences; on a read-only reconnect, they continue to behave as browser-local preferences. See [Configuration reference](/gateway/configuration-reference#ui).
## OpenClaw system care
@@ -331,6 +331,8 @@ export {
AuditListParamsSchema,
AuditListResultSchema,
GIT_COAUTHOR_PREFERENCE_KEY,
normalizeUiAppearancePreference,
UI_APPEARANCE_PREFERENCE_KEYS,
UserProfileSchema,
UsersLinkEmailParamsSchema,
UsersLinkEmailResultSchema,
@@ -338,6 +340,7 @@ export {
UsersListResultSchema,
UsersPrefsGetParamsSchema,
UsersPrefsGetResultSchema,
UsersPrefsChangedEventSchema,
UsersPrefsSetParamsSchema,
UsersPrefsSetResultSchema,
UsersSelfParamsSchema,
@@ -5,6 +5,7 @@ import * as config from "./config.js";
import * as openclaw from "./openclaw.js";
import * as taskSuggestions from "./task-suggestions.js";
import * as tasks from "./tasks.js";
import * as users from "./users.js";
import * as wizard from "./wizard.js";
export const OperationsProtocolSchemas = {
@@ -28,6 +29,7 @@ export const OperationsProtocolSchemas = {
AuditEvent: audit.AuditEventSchema,
AuditListParams: audit.AuditListParamsSchema,
AuditListResult: audit.AuditListResultSchema,
UsersPrefsChangedEvent: users.UsersPrefsChangedEventSchema,
TaskSuggestion: taskSuggestions.TaskSuggestionSchema,
TaskSuggestionEvent: taskSuggestions.TaskSuggestionEventSchema,
TaskSuggestionResolution: taskSuggestions.TaskSuggestionResolutionSchema,
@@ -0,0 +1,41 @@
export const UI_APPEARANCE_PREFERENCE_KEYS = {
theme: "ui.theme",
themeMode: "ui.themeMode",
accent: "ui.accent",
} as const;
export type UiAppearancePreferenceKey =
(typeof UI_APPEARANCE_PREFERENCE_KEYS)[keyof typeof UI_APPEARANCE_PREFERENCE_KEYS];
// Wire-contract list of profile-storable theme names. The Control UI derives
// its synced-theme handling from this tuple; a theme shipped in the UI but
// missing here would silently drop that profile preference on read.
// "custom" is deliberately absent: imported palettes are browser-local, so a
// custom selection must never follow the profile to a browser that cannot
// render it — it stays device-local instead.
export const UI_APPEARANCE_THEME_VALUES = [
"claw",
"knot",
"dash",
"absolutely",
"tide",
"beacon",
"phosphor",
] as const;
const UI_APPEARANCE_THEMES = new Set<string>(UI_APPEARANCE_THEME_VALUES);
const UI_APPEARANCE_THEME_MODES = new Set(["light", "dark", "system"]);
export function normalizeUiAppearancePreference(
key: UiAppearancePreferenceKey,
value: unknown,
): string | undefined {
if (typeof value !== "string") {
return undefined;
}
if (key === UI_APPEARANCE_PREFERENCE_KEYS.accent) {
return /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : undefined;
}
const allowedValues =
key === UI_APPEARANCE_PREFERENCE_KEYS.theme ? UI_APPEARANCE_THEMES : UI_APPEARANCE_THEME_MODES;
return allowedValues.has(value) ? value : undefined;
}
@@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";
import {
GatewayErrorDetailCodes,
GatewayErrorDetailsSchema,
normalizeUiAppearancePreference,
UI_APPEARANCE_PREFERENCE_KEYS,
UserPrefsLimitExceededErrorDetailsSchema,
UserProfileSchema,
UsersPrefsChangedEventSchema,
UsersPrefsGetResultSchema,
UsersPrefsSetResultSchema,
validateUsersPrefsGetParams,
@@ -13,6 +16,45 @@ import {
} from "../index.js";
describe("user preference protocol schemas", () => {
it("normalizes only supported profile appearance values and canonicalizes accent colors", () => {
expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.theme, "absolutely")).toBe(
"absolutely",
);
expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.themeMode, "system")).toBe(
"system",
);
expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.accent, "#A1b2C3")).toBe(
"#a1b2c3",
);
for (const [key, value] of [
[UI_APPEARANCE_PREFERENCE_KEYS.theme, "unsupported"],
[UI_APPEARANCE_PREFERENCE_KEYS.themeMode, "automatic"],
[UI_APPEARANCE_PREFERENCE_KEYS.accent, "#abc"],
[UI_APPEARANCE_PREFERENCE_KEYS.accent, "#12345g"],
[UI_APPEARANCE_PREFERENCE_KEYS.accent, { color: "#123456" }],
[UI_APPEARANCE_PREFERENCE_KEYS.theme, 42],
] as const) {
expect(normalizeUiAppearancePreference(key, value)).toBeUndefined();
}
});
it("bounds profile preference change events to their owning profile and written keys", () => {
expect(
Value.Check(UsersPrefsChangedEventSchema, {
profileId: "profile-1",
keys: [UI_APPEARANCE_PREFERENCE_KEYS.accent],
}),
).toBe(true);
expect(Value.Check(UsersPrefsChangedEventSchema, { profileId: "", keys: [] })).toBe(false);
expect(
Value.Check(UsersPrefsChangedEventSchema, {
profileId: "profile-1",
keys: Array.from({ length: 33 }, (_, index) => `key-${index}`),
}),
).toBe(false);
});
it("bounds self-scoped preference requests", () => {
const entries = Object.fromEntries(
Array.from({ length: 32 }, (_, index) => [`key-${index}`, { index }]),
@@ -8,6 +8,11 @@ export const USER_PREFS_ENTRY_LIMIT = 32;
export const USER_PREFS_PROFILE_KEY_LIMIT = 128;
export const USER_PREFS_VALUE_BYTES = 4 * 1024;
export const GIT_COAUTHOR_PREFERENCE_KEY = "git.coauthor.enabled";
export {
normalizeUiAppearancePreference,
UI_APPEARANCE_PREFERENCE_KEYS,
type UiAppearancePreferenceKey,
} from "./ui-appearance-preferences.js";
const UserProfileIdSchema = Type.String({ minLength: 1, maxLength: 128 });
const UserProfileDisplayNameSchema = Type.String({ maxLength: 256 });
@@ -92,6 +97,13 @@ export const UsersPrefsSetResultSchema = Type.Union([
closedObject({ status: Type.Literal("ok") }),
closedObject({ status: Type.Literal("no_durable_identity") }),
]);
export const UsersPrefsChangedEventSchema = closedObject({
profileId: UserProfileIdSchema,
keys: Type.Array(UserPreferenceKeySchema, {
maxItems: USER_PREFS_ENTRY_LIMIT,
uniqueItems: true,
}),
});
export type UserProfile = Static<typeof UserProfileSchema>;
export type UserProfileGitHubIdentity = Static<typeof UserProfileGitHubIdentitySchema>;
@@ -111,3 +123,4 @@ export type UsersPrefsGetParams = Static<typeof UsersPrefsGetParamsSchema>;
export type UsersPrefsGetResult = Static<typeof UsersPrefsGetResultSchema>;
export type UsersPrefsSetParams = Static<typeof UsersPrefsSetParamsSchema>;
export type UsersPrefsSetResult = Static<typeof UsersPrefsSetResultSchema>;
export type UsersPrefsChangedEvent = Static<typeof UsersPrefsChangedEventSchema>;
@@ -33,6 +33,7 @@
"terminal.exit": "Embedded terminal is a web/desktop surface; iOS has no terminal client.",
"ui.command": "Web Control UI-only layout commands; iOS does not consume them.",
"update.available": "Gateway self-update notices do not apply to iOS; app updates ship via the App Store.",
"users.prefs.changed": "iOS resolves the profile accent through talk.config on connect and config refresh; live per-profile appearance push is a named follow-up.",
"voicewake.routing.changed": "iOS only consumes voicewake.changed trigger updates; routing changes are not surfaced."
},
"android": {
@@ -70,6 +71,7 @@
"terminal.data": "Embedded terminal is a web/desktop surface; Android has no terminal client.",
"terminal.exit": "Embedded terminal is a web/desktop surface; Android has no terminal client.",
"ui.command": "Web Control UI-only layout commands; Android does not consume them.",
"users.prefs.changed": "Android resolves the profile accent through talk.config on connect and config refresh; live per-profile appearance push is a named follow-up.",
"voicewake.routing.changed": "Android reads voicewake state on demand via voicewake.get; no push consumer yet."
}
}
+4
View File
@@ -36,6 +36,10 @@ type CoreGatewayMethodSpecRow = readonly [
const PROFILE_DEPENDENT_CORE_METHODS = new Set([
"agent.wait",
// talk.config projects the caller's profile accent; without this gate a
// client asking during the post-hello GitHub identity sync window would get
// the gateway-wide accent instead. Profile-less clients pass through.
"talk.config",
"ui.command",
"users.linkEmail",
"users.setAvatar",
+3
View File
@@ -132,6 +132,9 @@ describe("gateway method registry", () => {
}
expect(registry.requiresAuthenticatedProfile("users.self")).toBe(false);
expect(registry.requiresAuthenticatedProfile("status")).toBe(false);
// talk.config projects the caller's profile accent; a pending GitHub
// identity sync must complete before the handler runs.
expect(registry.requiresAuthenticatedProfile("talk.config")).toBe(true);
for (const method of listCoreGatewayMethodNames().filter(isSessionProfileDependentMethod)) {
expect(registry.requiresAuthenticatedProfile(method), method).toBe(true);
}
+24 -16
View File
@@ -58,24 +58,32 @@ function makeClient(
};
}
describe("skills event scope guards", () => {
it("delivers skill invalidations only to read-capable operators", () => {
const pairing = makeClient("pairing", "operator", ["operator.pairing"]);
const node = makeClient("node", "node", ["operator.read"]);
const read = makeClient("read", "operator", ["operator.read"]);
const write = makeClient("write", "operator", ["operator.write"]);
const admin = makeClient("admin", "operator", ["operator.admin"]);
const clients = new Set([pairing, node, read, write, admin].map((entry) => entry.client));
const { broadcast } = createGatewayBroadcaster({ clients });
describe("read-capable operator event scope guards", () => {
it.each(["skills.changed", "users.prefs.changed"] as const)(
"delivers %s only to read-capable operators",
(event) => {
const pairing = makeClient("pairing", "operator", ["operator.pairing"]);
const node = makeClient("node", "node", ["operator.read"]);
const read = makeClient("read", "operator", ["operator.read"]);
const write = makeClient("write", "operator", ["operator.write"]);
const admin = makeClient("admin", "operator", ["operator.admin"]);
const clients = new Set([pairing, node, read, write, admin].map((entry) => entry.client));
const { broadcast } = createGatewayBroadcaster({ clients });
broadcast("skills.changed", { reason: "remote-node" });
broadcast(
event,
event === "users.prefs.changed"
? { profileId: "profile-1", keys: ["ui.accent"] }
: { reason: "remote-node" },
);
expect(pairing.socket.events).toEqual([]);
expect(node.socket.events).toEqual([]);
expect(read.socket.events).toEqual(["skills.changed"]);
expect(write.socket.events).toEqual(["skills.changed"]);
expect(admin.socket.events).toEqual(["skills.changed"]);
});
expect(pairing.socket.events).toEqual([]);
expect(node.socket.events).toEqual([]);
expect(read.socket.events).toEqual([event]);
expect(write.socket.events).toEqual([event]);
expect(admin.socket.events).toEqual([event]);
},
);
});
describe("device setup event scope guards", () => {
+1
View File
@@ -70,6 +70,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
// Hash-only change notice after a persisted config write; content stays
// behind the operator-scoped config.get.
"config.changed": [READ_SCOPE],
"users.prefs.changed": [READ_SCOPE],
"skills.changed": [READ_SCOPE],
"voicewake.changed": [READ_SCOPE],
"voicewake.routing.changed": [READ_SCOPE],
+4
View File
@@ -29,6 +29,10 @@ describe("GATEWAY_EVENTS", () => {
expect(GATEWAY_EVENTS).toContain("skills.changed");
});
it("advertises profile-scoped preference invalidation updates", () => {
expect(GATEWAY_EVENTS).toContain("users.prefs.changed");
});
it("advertises portal replace-set updates", () => {
expect(GATEWAY_EVENTS).toContain("portal.changed");
});
+1
View File
@@ -78,6 +78,7 @@ export const GATEWAY_EVENTS = [
"device.pair.resolved",
"device.pair.setup.completed",
"device.pair.setup.deliveryUncertain",
"users.prefs.changed",
"skills.changed",
"voicewake.changed",
"voicewake.routing.changed",
@@ -332,6 +332,7 @@ type GatewayTransportContext = {
ensureSandboxHostPort?: () => Promise<number>;
broadcast: GatewayBroadcastFn;
broadcastToConnIds: GatewayBroadcastToConnIdsFn;
getClientConnIds?: (filter?: (client: GatewayClient) => boolean) => ReadonlySet<string>;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
nodeSubscribe: (nodeId: string, sessionKey: string, connId?: string) => void;
+43 -3
View File
@@ -15,7 +15,9 @@ import { talkHandlers } from "./talk.js";
const mocks = vi.hoisted(() => ({
getRuntimeConfig: vi.fn<() => OpenClawConfig>(),
getUserPreferences: vi.fn<() => Record<string, unknown>>(() => ({})),
readConfigFileSnapshot: vi.fn(),
resolveUserProfileId: vi.fn((profileId: string) => profileId),
canonicalizeSpeechProviderId: vi.fn((providerId: string | undefined) => providerId),
getSpeechProvider: vi.fn(),
listSpeechProviders: vi.fn(() => []),
@@ -95,6 +97,16 @@ vi.mock("../../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
vi.mock("../../state/user-preferences.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../state/user-preferences.js")>()),
getUserPreferences: mocks.getUserPreferences,
}));
vi.mock("../../state/user-profiles.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../state/user-profiles.js")>()),
resolveUserProfileId: mocks.resolveUserProfileId,
}));
vi.mock("../../tts/provider-registry.js", () => ({
canonicalizeSpeechProviderId: mocks.canonicalizeSpeechProviderId,
getSpeechProvider: mocks.getSpeechProvider,
@@ -1118,10 +1130,30 @@ describe("talk.config handler", () => {
},
);
it("prefers the user accent over the operator seam color", async () => {
it.each([
{
name: "prefers the authenticated profile accent over gateway appearance defaults",
profileId: "profile-1",
profileAccent: "#A1B2C3",
expectedAccent: "#a1b2c3",
},
{
name: "ignores malformed authenticated profile accents",
profileId: "profile-1",
profileAccent: "not-a-color",
expectedAccent: "#52c99a",
},
{
name: "keeps profile-less callers on their existing gateway accent path",
expectedAccent: "#52c99a",
},
])("$name", async ({ profileId, profileAccent, expectedAccent }) => {
markTalkOwnerCold("tts");
const runtimeConfig = createTalkConfig("healthy-talk-key");
mocks.getSpeechProvider.mockReturnValue({ id: "acme" });
mocks.getUserPreferences.mockReturnValue(
profileAccent === undefined ? {} : { "ui.accent": profileAccent },
);
mocks.readConfigFileSnapshot.mockResolvedValue({
config: { ...runtimeConfig, ui: { seamColor: "#123456", prefs: { accent: "#52c99a" } } },
});
@@ -1129,13 +1161,21 @@ describe("talk.config handler", () => {
await callTalkHandler("talk.config", {
params: {},
client: { connect: { scopes: ["operator.read"] } },
client: {
connect: { scopes: ["operator.read"] },
...(profileId ? { authenticatedUserProfile: { profileId } } : {}),
},
respond,
context: { getRuntimeConfig: () => runtimeConfig },
});
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(respond.mock.calls[0]?.[1]?.config?.ui).toEqual({ seamColor: "#52c99a" });
expect(respond.mock.calls[0]?.[1]?.config?.ui).toEqual({ seamColor: expectedAccent });
if (profileId) {
expect(mocks.getUserPreferences).toHaveBeenCalledWith(profileId, ["ui.accent"]);
} else {
expect(mocks.getUserPreferences).not.toHaveBeenCalled();
}
});
it("projects the runtime realtime transport when source config is invalid", async () => {
+16 -3
View File
@@ -9,7 +9,9 @@ import {
ErrorCodes,
errorShape,
missingScopeErrorShape,
normalizeUiAppearancePreference,
type TalkSpeakParams,
UI_APPEARANCE_PREFERENCE_KEYS,
validateTalkCatalogParams,
validateTalkConfigParams,
validateTalkModeParams,
@@ -35,6 +37,8 @@ import {
assertSecretOwnerAvailable,
isSecretOwnerAvailable,
} from "../../secrets/runtime-degraded-state.js";
import { getUserPreferences } from "../../state/user-preferences.js";
import { resolveUserProfileId } from "../../state/user-profiles.js";
import { resolveTalkSessionAgentId } from "../../talk/agent-target.js";
import {
canonicalizeRealtimeVoiceProviderId,
@@ -802,9 +806,18 @@ export const talkHandlers: GatewayRequestHandlers = {
configPayload.session = { mainKey: sessionMainKey };
}
// User accent wins over the operator seam color, matching Control UI
// precedence (ui.prefs.accent -> ui.seamColor -> theme default).
const seamColor = snapshot.config.ui?.prefs?.accent ?? snapshot.config.ui?.seamColor;
const profileId = client?.authenticatedUserProfile?.profileId;
const canonicalProfileId = profileId ? resolveUserProfileId(profileId) : undefined;
const accentKey = UI_APPEARANCE_PREFERENCE_KEYS.accent;
const profileAccent = canonicalProfileId
? normalizeUiAppearancePreference(
accentKey,
getUserPreferences(canonicalProfileId, [accentKey])[accentKey],
)
: undefined;
// Profile accent overrides gateway prefs, then the gateway seam color and theme default.
const seamColor =
profileAccent ?? snapshot.config.ui?.prefs?.accent ?? snapshot.config.ui?.seamColor;
if (typeof seamColor === "string") {
configPayload.ui = { seamColor };
}
@@ -1,14 +1,16 @@
import { afterEach, expect, test } from "vitest";
import { afterEach, expect, test, vi } from "vitest";
import { GatewayErrorDetailCodes } from "../../../packages/gateway-protocol/src/index.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { ensureProfileForEmail, linkEmail } from "../../state/user-profiles.js";
import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import type { GatewayClient } from "./types.js";
import { usersHandlers } from "./users.js";
async function invokePreferenceMethod(
method: "users.prefs.get" | "users.prefs.set",
params: Record<string, unknown>,
profileId?: string,
context: Record<string, unknown> = {},
) {
let result: { ok: boolean; payload?: unknown; error?: unknown } | undefined;
await usersHandlers[method]!({
@@ -17,7 +19,7 @@ async function invokePreferenceMethod(
respond: (ok, payload, error) => {
result = { ok, payload, error };
},
context: {} as never,
context: context as never,
client: {
connect: { scopes: ["operator.admin"] },
...(profileId ? { authenticatedUserProfile: { profileId } } : {}),
@@ -68,16 +70,70 @@ test("users.prefs remains self-scoped across durable identities", async () => {
});
test("users.prefs returns a typed result without a durable identity", async () => {
expect(await invokePreferenceMethod("users.prefs.get", {})).toMatchObject({
const context = { broadcastToConnIds: vi.fn(), getClientConnIds: vi.fn() };
expect(await invokePreferenceMethod("users.prefs.get", {}, undefined, context)).toMatchObject({
ok: true,
payload: { status: "no_durable_identity" },
});
expect(
await invokePreferenceMethod("users.prefs.set", { entries: { theme: "claw" } }),
await invokePreferenceMethod(
"users.prefs.set",
{ entries: { theme: "claw" } },
undefined,
context,
),
).toMatchObject({
ok: true,
payload: { status: "no_durable_identity" },
});
expect(context.getClientConnIds).not.toHaveBeenCalled();
expect(context.broadcastToConnIds).not.toHaveBeenCalled();
});
test("users.prefs.set notifies only connections belonging to the same merged profile", async () => {
const state = await createOpenClawTestState({
layout: "state-only",
prefix: "users-prefs-events-",
});
try {
const retired = ensureProfileForEmail("retired@example.test");
const owner = ensureProfileForEmail("owner@example.test");
const other = ensureProfileForEmail("other@example.test");
linkEmail("retired@example.test", owner.id);
const connectedClients = [
{ connId: "owner", authenticatedUserProfile: { profileId: owner.id } },
{ connId: "merged", authenticatedUserProfile: { profileId: retired.id } },
{ connId: "other", authenticatedUserProfile: { profileId: other.id } },
{ connId: "unbound" },
];
const broadcastToConnIds = vi.fn();
const context = {
broadcastToConnIds,
getClientConnIds: (filter: (client: GatewayClient) => boolean) =>
new Set(
connectedClients
.filter((client) => filter(client as GatewayClient))
.map((client) => client.connId),
),
};
expect(
await invokePreferenceMethod(
"users.prefs.set",
{ entries: { "ui.accent": "#A1B2C3", "ui.theme": null } },
retired.id,
context,
),
).toMatchObject({ ok: true, payload: { status: "ok" } });
expect(broadcastToConnIds).toHaveBeenCalledExactlyOnceWith(
"users.prefs.changed",
{ profileId: owner.id, keys: ["ui.accent", "ui.theme"] },
new Set(["owner", "merged"]),
);
} finally {
await state.cleanup();
}
});
test("users.prefs.set returns typed profile quota details", async () => {
+20 -1
View File
@@ -198,7 +198,7 @@ export const usersHandlers: GatewayRequestHandlers = {
respond(false, undefined, profileError(error));
}
},
"users.prefs.set": ({ client, params, respond }) => {
"users.prefs.set": ({ client, context, params, respond }) => {
if (!validateUsersPrefsSetParams(params)) {
respond(
false,
@@ -254,6 +254,25 @@ export const usersHandlers: GatewayRequestHandlers = {
return;
}
respond(true, { status: "ok" }, undefined);
const keys = Object.keys(params.entries);
if (keys.length === 0) {
return;
}
const connIds = context.getClientConnIds?.((connectedClient) => {
const connectedProfileId = connectedClient.authenticatedUserProfile?.profileId;
return Boolean(
connectedProfileId &&
(connectedProfileId === canonicalProfileId ||
resolveUserProfileId(connectedProfileId) === canonicalProfileId),
);
});
if (connIds?.size) {
context.broadcastToConnIds(
"users.prefs.changed",
{ profileId: canonicalProfileId, keys },
connIds,
);
}
} catch (error) {
respond(false, undefined, profileError(error));
}
+1 -1
View File
@@ -48,7 +48,7 @@ Light mode uses a warm paper palette: ivory backgrounds, warm gray borders (`#e8
| `--accent-glow` | `rgba(255,92,92,0.2)` | Focus rings, glow effects | Not as background |
| `--primary` | `#d13c3c` | Filled primary buttons (white text, ~4.8:1 AA) | Not interchangeable with `--accent` |
The synced `ui.prefs.accent` preference overrides `ui.seamColor`, which overrides the active theme's default accent. Both overrides update the accent, primary, and focus token families together; `--accent-foreground` and `--primary-foreground` automatically switch between dark and white ink according to the accent's relative luminance. Clearing the preference restores the configured seam color or theme defaults.
For connections bound to an authenticated Gateway profile, the profile's `ui.accent` preference overrides the gateway-wide `ui.prefs.accent` setting, which overrides `ui.seamColor`, which overrides the active theme's default accent. Connections without an authenticated profile retain the existing gateway-wide precedence. Every override updates the accent, primary, and focus token families together; `--accent-foreground` and `--primary-foreground` automatically switch between dark and white ink according to the accent's relative luminance. Clearing a profile preference restores the gateway-wide accent, configured seam color, or theme defaults without changing anyone else's appearance.
## Accent 2 (Teal)
+1
View File
@@ -459,6 +459,7 @@ class OpenClawShell
const runtimeConfig = this.context?.runtimeConfig;
if (prefs && runtimeConfig) {
pushServerUiPrefs(runtimeConfig, prefs, {
profile: this.context?.gateway.snapshot,
afterCommit: ({ needsRefresh, retainedLocal }) =>
this.reconcileCommittedServerUiPrefs(runtimeConfig, needsRefresh, retainedLocal),
});
+165
View File
@@ -0,0 +1,165 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
import { ShellGatewayOwner, type ShellGatewayHost } from "./app-shell-gateway.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "./context.ts";
import { resetServerUiPrefsSync } from "./server-prefs.ts";
import { loadSettings, patchSettings } from "./settings.ts";
function createProfileAppearanceGateway(profileId: string | null) {
const request = vi.fn(async () => ({
status: "ok",
entries: { "ui.accent": "#336699" },
}));
const client = {
gatewayUrl: "ws://profile.test",
request,
} as unknown as GatewayBrowserClient;
const snapshot = {
client,
phase: "connected",
sessionKey: "",
selfUser: profileId ? { id: profileId } : null,
hello: { auth: { role: "operator", scopes: ["operator.write"] } },
} as ApplicationGatewaySnapshot;
const refreshTheme = vi.fn();
const context = {
gateway: {
connection: { gatewayUrl: "ws://profile.test" },
snapshot,
},
runtimeConfig: {
canPatch: false,
ensureLoaded: vi.fn(async () => undefined),
runExternalMutation: vi.fn(),
state: {
client,
connected: true,
configSnapshot: { config: { ui: { prefs: { accent: "#ff0000" } } } },
},
},
theme: { refresh: refreshTheme, recordServerSelection: vi.fn() },
} as unknown as ApplicationContext;
const host = {
context,
activeSessionKey: "",
agentRosterRefreshTimer: null,
agentsListClient: null,
agentsListSource: null,
criticalNoticeRuntime: null,
lastLocalePrefSignature: null,
outboxStoreImport: { load: vi.fn(async () => undefined) },
previousGatewayPhase: null,
routeState: {},
runtimeConfigClient: null,
runtimeConfigSource: null,
sessionKeyClient: null,
sidebarWorkboardRuntime: null,
syncSidebarWorkboard: vi.fn(),
} as unknown as ShellGatewayHost;
return {
context,
host,
owner: new ShellGatewayOwner(host),
refreshTheme,
request,
snapshot,
};
}
describe("ShellGatewayOwner profile appearance integration", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", createStorageMock());
resetServerUiPrefsSync();
});
afterEach(() => {
resetServerUiPrefsSync();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("never requests durable profile preferences for an identity-free connection", () => {
const { owner, request, snapshot } = createProfileAppearanceGateway(null);
owner.synchronizeGateway(snapshot);
owner.handleGatewayEvent({
type: "event",
event: "users.prefs.changed",
payload: { profileId: "someone-else", keys: ["ui.accent"] },
});
expect(request).not.toHaveBeenCalled();
});
it("loads profile appearance when authenticated presence appears on an existing connection", async () => {
const { owner, request, snapshot } = createProfileAppearanceGateway(null);
owner.synchronizeGateway(snapshot);
snapshot.selfUser = { id: "profile-owner" };
owner.synchronizeGateway(snapshot);
await vi.waitFor(() => expect(loadSettings().accent).toBe("#336699"));
expect(request).toHaveBeenCalledOnce();
expect(request).toHaveBeenCalledWith("users.prefs.get", {
keys: ["ui.theme", "ui.themeMode", "ui.accent"],
});
});
it("republishes profile provenance even when its appearance matches the browser mirror", async () => {
patchSettings({ accent: "#336699" });
const { owner, refreshTheme, snapshot } = createProfileAppearanceGateway("profile-owner");
owner.synchronizeGateway(snapshot);
await vi.waitFor(() => expect(refreshTheme).toHaveBeenCalledOnce());
expect(loadSettings().accent).toBe("#336699");
});
it("reuses cached profile preferences across unrelated gateway config snapshots", async () => {
const { context, owner, request, snapshot } = createProfileAppearanceGateway("profile-owner");
owner.synchronizeGateway(snapshot);
await vi.waitFor(() => expect(loadSettings().accent).toBe("#336699"));
request.mockClear();
const configState = context.runtimeConfig.state as {
configSnapshot: { config: unknown };
};
configState.configSnapshot = {
config: { ui: { prefs: { accent: "#884422" } }, agents: { defaults: {} } },
};
owner.reconcileServerUiPrefs(context.runtimeConfig);
expect(request).not.toHaveBeenCalled();
expect(loadSettings().accent).toBe("#336699");
});
it("refreshes only matching profile-change events and republishes the resolved appearance", async () => {
const { owner, refreshTheme, request, snapshot } =
createProfileAppearanceGateway("profile-owner");
owner.synchronizeGateway(snapshot);
await vi.waitFor(() => expect(loadSettings().accent).toBe("#336699"));
request.mockClear();
refreshTheme.mockClear();
request.mockResolvedValueOnce({ status: "ok", entries: { "ui.accent": "#224466" } });
owner.handleGatewayEvent({
type: "event",
event: "users.prefs.changed",
payload: { profileId: "other-profile", keys: ["ui.accent"] },
});
expect(request).not.toHaveBeenCalled();
owner.handleGatewayEvent({
type: "event",
event: "users.prefs.changed",
payload: { profileId: "profile-owner", keys: ["ui.accent"] },
});
await vi.waitFor(() => expect(loadSettings().accent).toBe("#224466"));
expect(request).toHaveBeenCalledOnce();
expect(refreshTheme).toHaveBeenCalledOnce();
});
});
+91 -1
View File
@@ -11,9 +11,11 @@ import {
import { i18n, isSupportedLocale } from "../i18n/index.ts";
import type { ShellRouteState } from "./app-host-route-state.ts";
import type { ApplicationContext } from "./context.ts";
import { hasOperatorWriteAccess } from "./operator-access.ts";
import {
applyServerUiPrefs,
flushServerUiPrefs,
refreshProfileAppearancePrefs,
resetServerUiPrefsSync,
resolveServerUiPrefState,
} from "./server-prefs.ts";
@@ -87,6 +89,12 @@ function diffAgentRoster(
}
export class ShellGatewayOwner {
private runtimeConfigProfileId: string | null = null;
private profileAppearanceSource: {
client: GatewayBrowserClient;
profileId: string;
} | null = null;
constructor(private readonly host: ShellGatewayHost) {}
reconcileServerUiPrefs(runtimeConfig: ApplicationContext["runtimeConfig"]): void {
@@ -98,6 +106,7 @@ export class ShellGatewayOwner {
const scope = context.gateway.connection.gatewayUrl;
applyServerUiPrefs(snapshot.config, {
scope,
profileId: context.gateway.snapshot?.selfUser?.id,
onThemeChanged: (theme) => context.theme.recordServerSelection(theme, scope),
onApplied: (patch) => {
if (patch.sidebarEntries !== undefined) {
@@ -106,6 +115,7 @@ export class ShellGatewayOwner {
context.theme.refresh();
},
});
this.refreshProfileAppearancePrefs(context);
const localePref = resolveServerUiPrefState(snapshot.config, "locale", scope);
const localePrefSignature = JSON.stringify([scope, localePref.overridden, localePref.value]);
if (localePrefSignature === this.host.lastLocalePrefSignature) {
@@ -174,6 +184,22 @@ export class ShellGatewayOwner {
this.scheduleAgentRosterRefresh();
return;
}
if (event.event === "users.prefs.changed") {
const context = this.host.context;
const profileId = context?.gateway.snapshot.selfUser?.id;
const payload = event.payload;
if (
context &&
profileId &&
payload &&
typeof payload === "object" &&
"profileId" in payload &&
payload.profileId === profileId
) {
this.refreshProfileAppearancePrefs(context, true);
}
return;
}
if (event.event !== "ui.command" || !event.payload) {
return;
}
@@ -264,6 +290,10 @@ export class ShellGatewayOwner {
this.updateGatewaySessionKey(snapshot);
this.ensureAgentsList(snapshot);
this.ensureRuntimeConfig(snapshot);
const context = this.host.context;
if (context) {
this.refreshProfileAppearancePrefs(context);
}
if (previousPhase !== "connected" && snapshot.phase === "connected") {
i18n.retryPendingLocale();
}
@@ -281,17 +311,24 @@ export class ShellGatewayOwner {
// Config-gated sidebar routes require the snapshot before any settings page opens.
if (snapshot.phase !== "connected" || !snapshot.client || !runtimeConfig) {
this.host.runtimeConfigClient = null;
this.runtimeConfigProfileId = null;
this.profileAppearanceSource = null;
return;
}
const profileId = snapshot.selfUser?.id ?? null;
if (
this.host.runtimeConfigClient === snapshot.client &&
this.host.runtimeConfigSource === runtimeConfig
this.host.runtimeConfigSource === runtimeConfig &&
this.runtimeConfigProfileId === profileId
) {
return;
}
this.host.runtimeConfigClient = snapshot.client;
this.host.runtimeConfigSource = runtimeConfig;
this.runtimeConfigProfileId = profileId;
flushServerUiPrefs(runtimeConfig, {
profileId,
canWrite: hasOperatorWriteAccess(snapshot.hello?.auth ?? null),
afterCommit: ({ needsRefresh, retainedLocal }) =>
this.reconcileCommittedServerUiPrefs(runtimeConfig, needsRefresh, retainedLocal),
});
@@ -335,6 +372,57 @@ export class ShellGatewayOwner {
}
}
private refreshProfileAppearancePrefs(context: ApplicationContext<RouteId>, force = false): void {
const snapshot = context.gateway.snapshot;
const profileId = snapshot?.selfUser?.id;
if (!profileId) {
return;
}
const client = snapshot.client;
const configObject = context.runtimeConfig.state.configSnapshot?.config;
if (snapshot.phase !== "connected" || !client || !configObject) {
return;
}
const previous = this.profileAppearanceSource;
if (!force && previous?.client === client && previous.profileId === profileId) {
return;
}
const source = { client, profileId };
this.profileAppearanceSource = source;
const scope = context.gateway.connection.gatewayUrl;
const remainsCurrent = () =>
this.host.context === context &&
context.gateway.snapshot.client === client &&
context.gateway.snapshot.selfUser?.id === profileId &&
this.profileAppearanceSource === source;
void refreshProfileAppearancePrefs({
client,
profileId,
configObject,
scope,
onApplied: () => {
if (remainsCurrent()) {
context.theme.refresh();
}
},
onThemeChanged: (theme) => {
if (remainsCurrent()) {
context.theme.recordServerSelection(theme, scope);
}
},
})
.then((applied) => {
if (!applied && remainsCurrent()) {
context.theme.refresh();
}
})
.catch((error: unknown) => {
if (remainsCurrent()) {
console.error("[gateway] profile appearance preference refresh failed:", error);
}
});
}
reset(): void {
void this.host.criticalNoticeRuntime?.then((runtime) => runtime.resetCriticalObserverTracker());
this.host.agentsListClient = null;
@@ -342,6 +430,8 @@ export class ShellGatewayOwner {
this.host.sessionKeyClient = null;
this.host.runtimeConfigClient = null;
this.host.runtimeConfigSource = null;
this.runtimeConfigProfileId = null;
this.profileAppearanceSource = null;
this.host.previousGatewayPhase = null;
if (this.host.agentRosterRefreshTimer !== null) {
globalThis.clearTimeout(this.host.agentRosterRefreshTimer);
+96
View File
@@ -0,0 +1,96 @@
import {
normalizeUiAppearancePreference,
UI_APPEARANCE_PREFERENCE_KEYS,
} from "../../../packages/gateway-protocol/src/schema/ui-appearance-preferences.ts";
import type {
UsersPrefsGetResult,
UsersPrefsSetResult,
} from "../../../packages/gateway-protocol/src/schema/users.ts";
import { GatewayRequestError, type GatewayBrowserClient } from "../api/gateway.ts";
import type { RuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts";
import { isAppearancePref, type ServerUiPrefs } from "./server-prefs-state.ts";
type ProfileAppearancePrefs = { profileId: string; scope: string; prefs: ServerUiPrefs };
let profileAppearancePrefs: ProfileAppearancePrefs | null = null;
let profilePreferencesRequestId = 0;
export function resolveProfilePreferenceScope(scope: string, profileId?: string | null): string {
return profileId ? `${scope}:profile:${profileId}` : scope;
}
export function resolveProfileAppearancePrefs(
scope: string,
profileId?: string | null,
): ServerUiPrefs | null {
return profileId &&
profileAppearancePrefs?.profileId === profileId &&
profileAppearancePrefs.scope === scope
? profileAppearancePrefs.prefs
: null;
}
export function resolveProfileAppearanceProfileId(scope: string): string | null {
return profileAppearancePrefs?.scope === scope ? profileAppearancePrefs.profileId : null;
}
export function resetProfileAppearancePrefs(): void {
profileAppearancePrefs = null;
profilePreferencesRequestId += 1;
}
export async function loadProfileAppearancePrefs(
client: GatewayBrowserClient,
profileId: string,
scope: string,
): Promise<boolean> {
const requestId = ++profilePreferencesRequestId;
const result = await client.request<UsersPrefsGetResult>("users.prefs.get", {
keys: Object.values(UI_APPEARANCE_PREFERENCE_KEYS),
});
if (requestId !== profilePreferencesRequestId || result.status !== "ok") {
return false;
}
const prefs: ServerUiPrefs = {};
for (const [key, preferenceKey] of Object.entries(UI_APPEARANCE_PREFERENCE_KEYS)) {
if (!isAppearancePref(key)) {
continue;
}
const value = normalizeUiAppearancePreference(preferenceKey, result.entries[preferenceKey]);
if (value !== undefined) {
Object.assign(prefs, { [key]: value });
}
}
profileAppearancePrefs = { profileId, scope, prefs };
return true;
}
export async function writeProfileAppearancePrefs(
client: GatewayBrowserClient | null,
batch: ServerUiPrefs,
canDispatch: boolean,
): Promise<Awaited<ReturnType<RuntimeConfigCapability["runExternalMutation"]>>> {
if (!client || !canDispatch) {
return { ok: false, reason: "unavailable", error: "Profile preferences are unavailable." };
}
const entries = Object.fromEntries(
Object.entries(batch).flatMap(([key, value]) =>
isAppearancePref(key) ? [[UI_APPEARANCE_PREFERENCE_KEYS[key], value]] : [],
),
);
try {
const result = await client.request<UsersPrefsSetResult>("users.prefs.set", { entries });
return result.status === "ok"
? { ok: true, value: result, refresh: { ok: true } }
: { ok: false, reason: "rejected", error: "Profile preferences are unavailable." };
} catch (error) {
const rejected =
error instanceof GatewayRequestError &&
(error.gatewayCode === "INVALID_REQUEST" || error.gatewayCode === "FORBIDDEN");
return {
ok: false,
reason: rejected ? "rejected" : "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
+34 -17
View File
@@ -1,4 +1,5 @@
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { UI_APPEARANCE_THEME_VALUES } from "../../../packages/gateway-protocol/src/schema/ui-appearance-preferences.ts";
import { normalizeSidebarEntries } from "../app-navigation.ts";
import { isSupportedLocale } from "../i18n/index.ts";
import {
@@ -12,16 +13,19 @@ import {
} from "./settings.ts";
import type { ThemeMode, ThemeName } from "./theme.ts";
const THEMES: ReadonlySet<ThemeName> = new Set([
"claw",
"knot",
"dash",
"absolutely",
"tide",
"beacon",
"phosphor",
// Derived from the wire contract so a theme the profile store rejects can never
// be offered here; new Set<ThemeName> makes an unknown protocol name a type error.
// "custom" is config-syncable (honored only by browsers with an imported
// palette) but intentionally not profile-storable, so it is appended here
// rather than added to the wire contract.
const THEMES: ReadonlySet<ThemeName> = new Set<ThemeName>([
...UI_APPEARANCE_THEME_VALUES,
"custom",
]);
export function isAppearancePref(key: string): key is "theme" | "themeMode" | "accent" {
return key === "theme" || key === "themeMode" || key === "accent";
}
const THEME_MODES: ReadonlySet<ThemeMode> = new Set(["light", "dark", "system"]);
type SyncedPrefSpec<T> = {
@@ -119,7 +123,7 @@ export type ResettableServerUiPrefKey =
export type SyncedPrefValue<K extends SyncedPrefKey> =
ReturnType<(typeof SYNCED_PREFS)[K]["extract"]> extends (infer T) | undefined ? T : never;
export type ServerUiPrefs = { [K in SyncedPrefKey]?: SyncedPrefValue<K> | null };
export type ServerUiPrefProvenance = "default" | "pending" | "synced" | "device-local";
export type ServerUiPrefProvenance = "default" | "pending" | "synced" | "profile" | "device-local";
export type ServerUiPrefState<T> = {
overridden: boolean;
provenance: ServerUiPrefProvenance;
@@ -172,6 +176,7 @@ export function resolveServerUiPrefStateFromSnapshot<K extends SyncedPrefKey>(
shadowPrefs: ServerUiPrefs | null,
settings: UiSettings,
canSync?: boolean | null,
profilePrefs?: ServerUiPrefs | null,
): ServerUiPrefState<SyncedPrefValue<K>> {
const specification = SYNCED_PREFS[key];
const localValue = specification.local(settings) as SyncedPrefValue<K> | undefined;
@@ -191,10 +196,19 @@ export function resolveServerUiPrefStateFromSnapshot<K extends SyncedPrefKey>(
};
};
const prefs = asRecord(asRecord(asRecord(configObject)?.ui)?.prefs);
const serverValue =
const configValue =
prefs && Object.hasOwn(prefs, key)
? (specification.extract(prefs[key]) as SyncedPrefValue<K> | undefined)
: undefined;
const profileValue = profilePrefs?.[key] ?? undefined;
const serverValue = profileValue ?? configValue;
const isProfileValue = profileValue !== undefined;
// With a profile active, reset deletes the profile key (even when none exists
// yet), so the reset target is what that deletion falls back to — the gateway
// value. Using the product default here misclassifies an explicit selection of
// the product default as a reset and silently drops the user's choice.
const resetsProfileKey = profilePrefs != null && isAppearancePref(key);
const resetValue = resetsProfileKey ? (configValue ?? productDefault) : productDefault;
const canApplyServerValue =
serverValue !== undefined &&
(!specification.canApply ||
@@ -214,16 +228,19 @@ export function resolveServerUiPrefStateFromSnapshot<K extends SyncedPrefKey>(
}
const shadowValue = shadowPrefs[key];
if (shadowValue === null) {
return { ...localState(productDefault), provenance: "pending" };
return { ...localState(resetValue), provenance: "pending" };
}
return {
overridden: true,
provenance: "pending",
resetValue: productDefault,
resetValue,
value: shadowValue as SyncedPrefValue<K>,
};
}
if (!prefs || !Object.hasOwn(prefs, key) || serverValue === undefined) {
if ((!prefs || !Object.hasOwn(prefs, key)) && !isProfileValue) {
return localState(productDefault);
}
if (serverValue === undefined) {
return localState(productDefault);
}
if (!canApplyServerValue) {
@@ -234,16 +251,16 @@ export function resolveServerUiPrefStateFromSnapshot<K extends SyncedPrefKey>(
// the value, so Restore default still removes the server override.
return {
overridden: true,
provenance: "synced",
resetValue: productDefault,
provenance: isProfileValue ? "profile" : "synced",
resetValue,
value: localValue,
};
}
if (prefValuesEqual(localValue, serverValue)) {
return {
overridden: true,
provenance: "synced",
resetValue: productDefault,
provenance: isProfileValue ? "profile" : "synced",
resetValue,
value: serverValue,
};
}
+93
View File
@@ -0,0 +1,93 @@
// localStorage persistence primitives for the synced-prefs engine. Stateless:
// every helper is (root, scope)-parameterized; scope adoption, pending shadows,
// and reconcile state stay in server-prefs.ts.
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { SYNCED_PREFS, type ServerUiPrefs, type SyncedPrefKey } from "./server-prefs-state.ts";
// Last server value this client reconciled against, persisted per gateway scope. Applying only on
// a server delta keeps an unpushable local edit (viewer scope) from being reverted by every later
// snapshot, including the first snapshot after reload or reconnect carrying the same old value.
export const LAST_SEEN_KEY = "openclaw.control.serverPrefs.v1";
// Pending keys are local edits not yet acknowledged by the gateway. They shadow reconciliation so
// snapshots cannot revert unacked edits, and persist so offline edits replay after reload/reconnect.
export const PENDING_KEY = "openclaw.control.serverPrefs.pending.v1";
// Connected read-only edits never enter the replay outbox. Retain only their keys until the next
// snapshot establishes a LAST_SEEN baseline, then normal server-delta reconciliation resumes.
const RETAINED_LOCAL_KEY = "openclaw.control.serverPrefs.retained-local.v1";
function readStorageState(
root: string,
scope: string,
): { available: boolean; value: string | null } {
try {
const storage = globalThis.localStorage;
if (!storage) {
return { available: false, value: null };
}
return { available: true, value: storage.getItem(`${root}:${scope}`) };
} catch {
return { available: false, value: null };
}
}
export function readStorage(root: string, scope: string): string | null {
return readStorageState(root, scope).value;
}
export function writeStorage(root: string, scope: string, value: string | null): boolean {
try {
const storage = globalThis.localStorage;
if (!storage) {
return false;
}
const key = `${root}:${scope}`;
if (value === null) {
storage.removeItem(key);
} else {
storage.setItem(key, value);
}
return true;
} catch {
// Quota/security failures degrade to in-memory tracking for this session.
return false;
}
}
export function parseStoredPrefs(raw: string | null): ServerUiPrefs | null {
try {
const prefs = asRecord(JSON.parse(raw ?? "null"));
// SAFETY: consumers re-validate per key against SYNCED_PREFS extractors.
return prefs && Object.keys(prefs).length ? (prefs as ServerUiPrefs) : null;
} catch {
return null;
}
}
export function readStoredPrefs(
root: string,
scope: string,
): { available: boolean; prefs: ServerUiPrefs | null } {
const stored = readStorageState(root, scope);
return {
available: stored.available,
prefs: parseStoredPrefs(stored.value),
};
}
export function readRetainedLocalKeys(scope: string): Set<SyncedPrefKey> {
const stored = parseStoredPrefs(readStorage(RETAINED_LOCAL_KEY, scope));
if (!stored) {
return new Set();
}
const keys = Object.keys(stored).filter((key) => Object.hasOwn(SYNCED_PREFS, key));
// SAFETY: filtered against SYNCED_PREFS, so every key is a SyncedPrefKey.
return new Set(keys as SyncedPrefKey[]);
}
export function writeRetainedLocalKeys(scope: string, keys: ReadonlySet<SyncedPrefKey>): void {
writeStorage(
RETAINED_LOCAL_KEY,
scope,
keys.size ? JSON.stringify(Object.fromEntries([...keys].map((key) => [key, true]))) : null,
);
}
+324
View File
@@ -0,0 +1,324 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
normalizeUiAppearancePreference,
UI_APPEARANCE_PREFERENCE_KEYS,
} from "../../../packages/gateway-protocol/src/schema/ui-appearance-preferences.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
import { waitForFast } from "../test-helpers/wait-for.ts";
import { resolveServerUiPrefStateFromSnapshot } from "./server-prefs-state.ts";
import { configWithPrefs, createServerPrefsWriter } from "./server-prefs.test-support.ts";
import {
changedServerUiPrefs,
pushServerUiPrefs,
refreshProfileAppearancePrefs,
resetServerUiPref,
resetServerUiPrefsSync,
resolveServerUiPrefState,
} from "./server-prefs.ts";
import { loadSettings, patchSettings } from "./settings.ts";
import type { ThemeName } from "./theme.ts";
const profileId = "profile-ada";
const scope = "ws://profiles";
beforeEach(() => {
vi.stubGlobal("localStorage", createStorageMock());
resetServerUiPrefsSync();
});
afterEach(() => {
resetServerUiPrefsSync();
vi.unstubAllGlobals();
});
describe("profile-bound appearance preferences", () => {
it("stores every Control UI theme name the profile wire contract knows", () => {
// Record<ThemeName, boolean> turns a theme added to the UI but missing from
// this table into a compile error, and the loop turns a wire-contract
// mismatch into a runtime failure — a mismatch silently drops profile
// themes. "custom" is the deliberate exception: its palette is
// browser-local, so the selection must never follow the profile.
const profileStorable: Record<ThemeName, boolean> = {
claw: true,
knot: true,
dash: true,
absolutely: true,
tide: true,
beacon: true,
phosphor: true,
custom: false,
};
for (const [theme, storable] of Object.entries(profileStorable)) {
expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.theme, theme)).toBe(
storable ? theme : undefined,
);
}
});
it("keeps a profile-bound custom theme selection in this browser only", async () => {
const request = vi.fn(async () => ({ status: "ok" as const }));
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
const afterCommit = vi.fn();
pushServerUiPrefs(
writer,
{ theme: "custom", accent: "#123456" },
{ profileId, canWrite: true, afterCommit },
);
// The accent still syncs; the custom theme is retained browser-local and
// never reaches users.prefs.set.
await waitForFast(() =>
expect(request).toHaveBeenCalledExactlyOnceWith("users.prefs.set", {
entries: { "ui.accent": "#123456" },
}),
);
expect(afterCommit).toHaveBeenCalledWith({ needsRefresh: false, retainedLocal: true });
});
it("overlays profile appearance values without changing anonymous snapshot resolution", () => {
const config = configWithPrefs({ theme: "claw" });
const settings = { ...loadSettings(), theme: "knot" as const };
expect(
resolveServerUiPrefStateFromSnapshot(config, "theme", null, settings, true, {
theme: "knot",
}),
).toEqual({
overridden: true,
provenance: "profile",
resetValue: "claw",
value: "knot",
});
expect(resolveServerUiPrefStateFromSnapshot(config, "theme", null, settings, true)).toEqual({
overridden: true,
provenance: "device-local",
resetValue: "claw",
value: "knot",
});
});
it("normalizes profile overrides above config while rejecting malformed stored values", async () => {
const config = configWithPrefs({ theme: "claw", themeMode: "dark", accent: "#123456" });
const request = vi.fn(async () => ({
status: "ok" as const,
entries: {
"ui.theme": "knot",
"ui.themeMode": { mode: "light" },
"ui.accent": "#AbC123",
},
}));
const writer = createServerPrefsWriter(request, scope);
const onApplied = vi.fn();
await refreshProfileAppearancePrefs({
client: writer.state.client!,
profileId,
configObject: config,
scope,
onApplied,
});
expect(request).toHaveBeenCalledExactlyOnceWith("users.prefs.get", {
keys: ["ui.theme", "ui.themeMode", "ui.accent"],
});
expect(onApplied).toHaveBeenCalledWith({ theme: "knot", themeMode: "dark", accent: "#abc123" });
expect(resolveServerUiPrefState(config, "theme", scope, loadSettings(), { profileId })).toEqual(
{ overridden: true, provenance: "profile", resetValue: "claw", value: "knot" },
);
expect(
resolveServerUiPrefState(config, "themeMode", scope, loadSettings(), { profileId })
.provenance,
).toBe("synced");
expect(
resolveServerUiPrefState(config, "accent", scope, loadSettings(), { profileId }),
).toEqual({
overridden: true,
provenance: "profile",
resetValue: "#123456",
value: "#abc123",
});
});
it("writes profile-bound appearance without requiring config-admin access", async () => {
const request = vi.fn(async () => ({ status: "ok" as const }));
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
pushServerUiPrefs(writer, { theme: "knot" }, { profileId, canWrite: true });
await waitForFast(() =>
expect(request).toHaveBeenCalledExactlyOnceWith("users.prefs.set", {
entries: { "ui.theme": "knot" },
}),
);
});
it("keeps pending local edits above incoming profile updates", async () => {
let releaseWrite!: (value: unknown) => void;
const write = new Promise<unknown>((resolve) => {
releaseWrite = resolve;
});
let profileTheme = "knot";
const request = vi.fn(async (method: string) =>
method === "users.prefs.get"
? { status: "ok" as const, entries: { "ui.theme": profileTheme } }
: await write,
);
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
const config = configWithPrefs({ theme: "claw" });
const options = {
client: writer.state.client!,
profileId,
configObject: config,
scope,
onApplied: vi.fn(),
};
await refreshProfileAppearancePrefs(options);
patchSettings({ theme: "dash" });
pushServerUiPrefs(writer, { theme: "dash" }, { profileId, canWrite: true });
profileTheme = "absolutely";
await refreshProfileAppearancePrefs(options);
expect(loadSettings().theme).toBe("dash");
expect(
resolveServerUiPrefState(config, "theme", scope, loadSettings(), { profileId }),
).toMatchObject({ provenance: "pending", value: "dash" });
releaseWrite({ status: "ok" });
await waitForFast(() => expect(request).toHaveBeenCalledTimes(3));
});
it("restores gateway defaults by deleting only the profile preference", async () => {
const config = configWithPrefs({ theme: "claw" });
const request = vi.fn(async (method: string) =>
method === "users.prefs.get"
? { status: "ok" as const, entries: { "ui.theme": "knot" } }
: { status: "ok" as const },
);
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
Object.assign(writer.state, { configSnapshot: { config } });
await refreshProfileAppearancePrefs({
client: writer.state.client!,
profileId,
configObject: config,
scope,
onApplied: vi.fn(),
});
const previous = loadSettings();
const state = resolveServerUiPrefState(config, "theme", scope, previous, { profileId });
const next = resetServerUiPref("theme", state, scope);
expect(next.theme).toBe("claw");
expect(changedServerUiPrefs(previous, next)).toEqual({ theme: null });
const afterCommit = vi.fn();
pushServerUiPrefs(writer, { theme: null }, { profileId, canWrite: true, afterCommit });
await waitForFast(() => expect(afterCommit).toHaveBeenCalledOnce());
expect(request).toHaveBeenLastCalledWith("users.prefs.set", { entries: { "ui.theme": null } });
expect(
resolveServerUiPrefState(config, "theme", scope, loadSettings(), { profileId }),
).toMatchObject({ provenance: "synced", value: "claw" });
expect(request.mock.calls.some(([method]) => method === "config.patch")).toBe(false);
});
it("keeps read-only profile edits device-local without attempting a profile write", async () => {
const config = configWithPrefs({ theme: "claw" });
const request = vi.fn(async () => ({ status: "ok" as const, entries: {} }));
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
await refreshProfileAppearancePrefs({
client: writer.state.client!,
profileId,
configObject: config,
scope,
onApplied: vi.fn(),
});
patchSettings({ theme: "knot" });
const afterCommit = vi.fn();
pushServerUiPrefs(writer, { theme: "knot" }, { profileId, canWrite: false, afterCommit });
expect(request).toHaveBeenCalledOnce();
expect(afterCommit).toHaveBeenCalledWith({ needsRefresh: false, retainedLocal: true });
expect(
resolveServerUiPrefState(config, "theme", scope, loadSettings(), {
profileId,
canSync: false,
}),
).toMatchObject({ provenance: "device-local", value: "knot" });
});
it("targets reset at the gateway value so an explicit product-default choice persists", async () => {
// With an empty profile over a gateway theme of Dash, resetValue must be the
// deletion fallback ("dash"); a product-default resetValue would classify an
// explicit Claw selection as a reset and silently drop the user's choice.
const config = configWithPrefs({ theme: "dash" });
const request = vi.fn(async (method: string) =>
method === "users.prefs.get"
? { status: "ok" as const, entries: {} }
: { status: "ok" as const },
);
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
await refreshProfileAppearancePrefs({
client: writer.state.client!,
profileId,
configObject: config,
scope,
onApplied: vi.fn(),
});
const state = resolveServerUiPrefState(config, "theme", scope, loadSettings(), { profileId });
expect(state).toMatchObject({ provenance: "synced", resetValue: "dash", value: "dash" });
// The explicit Claw choice is a profile write, never a null reset.
patchSettings({ theme: "claw" });
pushServerUiPrefs(writer, { theme: "claw" }, { profileId, canWrite: true });
await waitForFast(() =>
expect(request).toHaveBeenLastCalledWith("users.prefs.set", {
entries: { "ui.theme": "claw" },
}),
);
// Resetting from synced provenance with a profile bound lands on the
// gateway value locally, matching what the profile-key deletion resolves to.
const reset = resetServerUiPref("theme", state, scope);
expect(reset.theme).toBe("dash");
});
it("reapplies the returning profile's appearance after an identity switch", async () => {
// A→B→A in one browser: per-scope last-seen state must not skip re-applying
// A's values while the DOM still shows B's.
const config = configWithPrefs({});
const prefsByProfile: Record<string, Record<string, string>> = {
"profile-a": { "ui.theme": "knot" },
"profile-b": { "ui.theme": "dash", "ui.accent": "#123456" },
};
let activeProfile = "profile-a";
const request = vi.fn(async () => ({
status: "ok" as const,
entries: prefsByProfile[activeProfile],
}));
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
const refresh = (nextProfile: string) => {
activeProfile = nextProfile;
return refreshProfileAppearancePrefs({
client: writer.state.client!,
profileId: nextProfile,
configObject: config,
scope,
onApplied: vi.fn(),
});
};
await refresh("profile-a");
expect(loadSettings().theme).toBe("knot");
await refresh("profile-b");
expect(loadSettings().theme).toBe("dash");
expect(loadSettings().accent).toBe("#123456");
await refresh("profile-a");
expect(loadSettings().theme).toBe("knot");
// B's accent must not linger on A even though A's scope never recorded one.
expect(loadSettings().accent).toBeUndefined();
});
});
+218 -131
View File
@@ -2,11 +2,21 @@
// the approval gate and other devices pick them up. The localStorage mirror gives instant boot and
// stays authoritative when this client cannot write config (viewer scope, offline). Pending local
// intent shadows server snapshots until the hash-free LWW ack; failed pushes degrade device-local.
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { RuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts";
import type { ApplicationGatewaySnapshot } from "./gateway.ts";
import { hasOperatorWriteAccess } from "./operator-access.ts";
import {
loadProfileAppearancePrefs,
resetProfileAppearancePrefs,
resolveProfileAppearanceProfileId,
resolveProfileAppearancePrefs,
resolveProfilePreferenceScope,
writeProfileAppearancePrefs,
} from "./server-prefs-profile.ts";
import {
extractServerUiPrefs,
isAppearancePref,
prefValuesEqual,
resolveServerUiPrefStateFromSnapshot,
serverPrefsLocalPatch,
@@ -18,6 +28,16 @@ import {
type SyncedPrefKey,
type SyncedPrefValue,
} from "./server-prefs-state.ts";
import {
LAST_SEEN_KEY,
PENDING_KEY,
parseStoredPrefs,
readRetainedLocalKeys,
readStorage,
readStoredPrefs,
writeRetainedLocalKeys,
writeStorage,
} from "./server-prefs-storage.ts";
import { loadSettings, patchSettings, type UiSettings } from "./settings.ts";
import type { ThemeName } from "./theme.ts";
@@ -25,12 +45,19 @@ type ServerUiPrefsWriter = Pick<RuntimeConfigCapability, "canPatch" | "runExtern
readonly state: {
readonly client: GatewayBrowserClient | null;
readonly connected: boolean;
readonly configSnapshot?: { readonly config?: unknown } | null;
};
};
type ServerUiPrefsCommit = {
needsRefresh: boolean;
retainedLocal?: boolean;
};
type ServerUiPrefsPushHooks = {
afterCommit?: (commit: ServerUiPrefsCommit) => void;
profileId?: string | null;
canWrite?: boolean;
profile?: Pick<ApplicationGatewaySnapshot, "selfUser" | "hello"> | null;
};
export type { ServerUiPrefProvenance, ServerUiPrefState } from "./server-prefs-state.ts";
export function resolveServerUiPrefState<K extends SyncedPrefKey>(
@@ -38,16 +65,20 @@ export function resolveServerUiPrefState<K extends SyncedPrefKey>(
key: K,
scope = "",
settings = loadSettings(),
options: { canSync?: boolean | null } = {},
options: { canSync?: boolean | null; profileId?: string | null } = {},
): ServerUiPrefState<SyncedPrefValue<K>> {
const effectiveScope = resolveProfilePreferenceScope(scope, options.profileId);
const shadowPrefs =
scope === pendingScope ? pendingPrefs : parseStoredPrefs(readStorage(PENDING_KEY, scope));
effectiveScope === pendingScope
? pendingPrefs
: parseStoredPrefs(readStorage(PENDING_KEY, effectiveScope));
return resolveServerUiPrefStateFromSnapshot(
configObject,
key,
shadowPrefs,
settings,
options.canSync,
resolveProfileAppearancePrefs(scope, options.profileId),
);
}
/** Synced-key delta between two local settings snapshots, for the push path. */
@@ -78,16 +109,6 @@ export function changedServerUiPrefs(previous: UiSettings, next: UiSettings): Se
}
return Object.keys(prefs).length > 0 ? prefs : null;
}
// Last server value this client reconciled against, persisted per gateway scope. Applying only on
// a server delta keeps an unpushable local edit (viewer scope) from being reverted by every later
// snapshot, including the first snapshot after reload or reconnect carrying the same old value.
const LAST_SEEN_KEY = "openclaw.control.serverPrefs.v1";
// Pending keys are local edits not yet acknowledged by the gateway. They shadow reconciliation so
// snapshots cannot revert unacked edits, and persist so offline edits replay after reload/reconnect.
const PENDING_KEY = "openclaw.control.serverPrefs.pending.v1";
// Connected read-only edits never enter the replay outbox. Retain only their keys until the next
// snapshot establishes a LAST_SEEN baseline, then normal server-delta reconciliation resumes.
const RETAINED_LOCAL_KEY = "openclaw.control.serverPrefs.retained-local.v1";
const CONFLICT_REDRAIN_DELAY_MS = 1_000;
const MAX_CONFLICT_REDRAINS = 5;
const requestedServerUiPrefResets = new Set<SyncedPrefKey>();
@@ -98,6 +119,8 @@ let pendingPrefs: ServerUiPrefs | null = null;
let pendingPersistedKeys = new Set<SyncedPrefKey>();
let pushWriter: ServerUiPrefsWriter | null = null;
let pushScope = "";
let pushProfileId: string | null = null;
let pushCanWrite = false;
let pushAfterCommit: ((commit: ServerUiPrefsCommit) => void) | undefined;
let pushDraining = false;
let drainRequested = false;
@@ -116,74 +139,6 @@ function clearConflictRedrain(): void {
}
consecutiveConflictRedrains = 0;
}
function readStorageState(
root: string,
scope: string,
): { available: boolean; value: string | null } {
try {
const storage = globalThis.localStorage;
if (!storage) {
return { available: false, value: null };
}
return { available: true, value: storage.getItem(`${root}:${scope}`) };
} catch {
return { available: false, value: null };
}
}
function readStorage(root: string, scope: string): string | null {
return readStorageState(root, scope).value;
}
function writeStorage(root: string, scope: string, value: string | null): boolean {
try {
const storage = globalThis.localStorage;
if (!storage) {
return false;
}
const key = `${root}:${scope}`;
if (value === null) {
storage.removeItem(key);
} else {
storage.setItem(key, value);
}
return true;
} catch {
// Quota/security failures degrade to in-memory tracking for this session.
return false;
}
}
function parseStoredPrefs(raw: string | null): ServerUiPrefs | null {
try {
const prefs = asRecord(JSON.parse(raw ?? "null"));
return prefs && Object.keys(prefs).length ? (prefs as ServerUiPrefs) : null;
} catch {
return null;
}
}
function readStoredPrefs(
root: string,
scope: string,
): { available: boolean; prefs: ServerUiPrefs | null } {
const stored = readStorageState(root, scope);
return {
available: stored.available,
prefs: parseStoredPrefs(stored.value),
};
}
function readRetainedLocalKeys(scope: string): Set<SyncedPrefKey> {
const stored = parseStoredPrefs(readStorage(RETAINED_LOCAL_KEY, scope));
return new Set(
stored
? (Object.keys(stored).filter((key) => Object.hasOwn(SYNCED_PREFS, key)) as SyncedPrefKey[])
: [],
);
}
function writeRetainedLocalKeys(scope: string, keys: ReadonlySet<SyncedPrefKey>): void {
writeStorage(
RETAINED_LOCAL_KEY,
scope,
keys.size ? JSON.stringify(Object.fromEntries([...keys].map((key) => [key, true]))) : null,
);
}
function updateRetainedLocalKeys(
scope: string,
keys: readonly SyncedPrefKey[],
@@ -303,8 +258,11 @@ export function resetServerUiPrefsSync() {
pendingPrefs = pushWriter = null;
pendingPersistedKeys.clear();
pushScope = "";
pushProfileId = null;
pushCanWrite = false;
lastReconciledScope = "";
lastReconciledConfigObject = null;
resetProfileAppearancePrefs();
requestedServerUiPrefResets.clear();
requestedDeviceLocalPrefResets.clear();
}
@@ -315,6 +273,8 @@ export function resetServerUiPref<K extends ResettableServerUiPrefKey>(
scope = pendingScope,
): UiSettings {
const specification = SYNCED_PREFS[key];
const activeProfile = isAppearancePref(key) ? resolveProfileAppearanceProfileId(scope) : null;
const effectiveScope = resolveProfilePreferenceScope(scope, activeProfile);
const reset = specification.reset;
if (!reset) {
throw new Error(`Server UI preference is not resettable: ${key}`);
@@ -326,26 +286,45 @@ export function resetServerUiPref<K extends ResettableServerUiPrefKey>(
if (!write) {
throw new Error(`Server UI preference cannot restore a retained local value: ${key}`);
}
cancelPendingKeys(scope, [key]);
updateRetainedLocalKeys(scope, [key], false);
cancelPendingKeys(effectiveScope, [key]);
updateRetainedLocalKeys(effectiveScope, [key], false);
requestedDeviceLocalPrefResets.add(key);
return patchSettings(write(state.resetValue));
}
requestedServerUiPrefResets.add(key);
// Profile-bound reset deletes the profile key and lands on the gateway value,
// so the local settings must move to state.resetValue (that fallback), not the
// product default the generic reset would apply.
if (activeProfile && state) {
// SAFETY: SYNCED_PREFS pairs each key's write() with that key's own value type.
const write = specification.write as
| ((value: SyncedPrefValue<K> | undefined) => Partial<UiSettings>)
| undefined;
if (write) {
return patchSettings(write(state.resetValue));
}
}
return patchSettings(reset(loadSettings()));
}
export function applyServerUiPrefs(
configObject: unknown,
hooks: {
scope?: string;
profileId?: string | null;
onApplied: (patch: Partial<UiSettings>) => void;
onThemeChanged?: (theme: ThemeName | null) => void;
},
): boolean {
const scope = hooks.scope ?? "";
const gatewayScope = hooks.scope ?? "";
const scope = resolveProfilePreferenceScope(gatewayScope, hooks.profileId);
if (scope === lastReconciledScope && configObject === lastReconciledConfigObject) {
return false;
}
// Last-seen state is per profile scope but the rendered settings are a
// singleton: after an identity switch (A→B→A) an unchanged last-seen does not
// mean the DOM shows this profile's values, so a switch between two known
// scopes forces a full reconcile. Boot keeps the shortcut (mirror is current).
const scopeChanged = lastReconciledScope !== "" && scope !== lastReconciledScope;
const recordReconciledObject = () => {
lastReconciledScope = scope;
lastReconciledConfigObject = configObject;
@@ -353,10 +332,13 @@ export function applyServerUiPrefs(
const shadowPrefs =
scope === pendingScope ? pendingPrefs : parseStoredPrefs(readStorage(PENDING_KEY, scope));
const retainedLocalKeys = readRetainedLocalKeys(scope);
const prefs = extractServerUiPrefs(configObject);
const prefs = {
...extractServerUiPrefs(configObject),
...resolveProfileAppearancePrefs(gatewayScope, hooks.profileId),
};
const key = JSON.stringify(prefs);
const lastSeenRaw = readStorage(LAST_SEEN_KEY, scope);
if (key === lastSeenRaw) {
if (!scopeChanged && key === lastSeenRaw) {
if (retainedLocalKeys.size) {
updateRetainedLocalKeys(scope, [...retainedLocalKeys], false);
}
@@ -371,7 +353,7 @@ export function applyServerUiPrefs(
if (
!(shadowPrefs && prefKey in shadowPrefs) &&
!retainedLocalKeys.has(prefKey) &&
(lastSeenRaw === null || !prefValuesEqual(prefs[prefKey], lastSeen[prefKey]))
(scopeChanged || lastSeenRaw === null || !prefValuesEqual(prefs[prefKey], lastSeen[prefKey]))
) {
(changed as Record<string, unknown>)[prefKey] = prefs[prefKey];
}
@@ -386,6 +368,22 @@ export function applyServerUiPrefs(
(changed as Record<string, unknown>)[prefKey] = null;
}
}
if (scopeChanged) {
// The previous identity may have rendered appearance values this scope has
// never seen (absent from both prefs and this scope's last-seen); clear
// them back to defaults so the new identity never wears the old one's look.
for (const prefKey of SYNCED_PREF_KEYS) {
if (
isAppearancePref(prefKey) &&
!(prefKey in prefs) &&
!(shadowPrefs && prefKey in shadowPrefs) &&
!retainedLocalKeys.has(prefKey) &&
SYNCED_PREFS[prefKey].clearable
) {
(changed as Record<string, unknown>)[prefKey] = null;
}
}
}
writeStorage(LAST_SEEN_KEY, scope, key);
if (retainedLocalKeys.size) {
updateRetainedLocalKeys(scope, [...retainedLocalKeys], false);
@@ -407,12 +405,30 @@ export function applyServerUiPrefs(
hooks.onApplied(patch);
return true;
}
export async function refreshProfileAppearancePrefs(options: {
client: GatewayBrowserClient;
profileId: string;
configObject: unknown;
scope?: string;
onApplied: (patch: Partial<UiSettings>) => void;
onThemeChanged?: (theme: ThemeName | null) => void;
}): Promise<boolean> {
const scope = options.scope ?? options.client.gatewayUrl;
if (!(await loadProfileAppearancePrefs(options.client, options.profileId, scope))) {
return false;
}
lastReconciledConfigObject = null;
return applyServerUiPrefs(options.configObject, { ...options, scope });
}
export function isApplyingServerUiPrefs(): boolean {
return applyingServerPrefs;
}
function adoptPushWriter(writer: ServerUiPrefsWriter): void {
const scope = writer.state.client?.gatewayUrl ?? "";
if (pushWriter === writer && pushScope === scope) {
function adoptPushWriter(writer: ServerUiPrefsWriter, hooks: ServerUiPrefsPushHooks): void {
const profileId = hooks.profileId ?? hooks.profile?.selfUser?.id ?? null;
const scope = resolveProfilePreferenceScope(writer.state.client?.gatewayUrl ?? "", profileId);
pushCanWrite = hooks.canWrite ?? hasOperatorWriteAccess(hooks.profile?.hello?.auth ?? null);
if (pushWriter === writer && pushScope === scope && pushProfileId === profileId) {
return;
}
// Reconcile the scope being left before moving pre-connection intent forward.
@@ -429,6 +445,7 @@ function adoptPushWriter(writer: ServerUiPrefsWriter): void {
pushEpoch += 1;
pushWriter = writer;
pushScope = scope;
pushProfileId = profileId;
pushDraining = false;
adoptPendingScope(scope, true);
if (scope && unscopedPending && Object.keys(unscopedPending).length) {
@@ -468,6 +485,7 @@ function scheduleConflictRedrain(writer: ServerUiPrefsWriter, epoch: number): vo
}
}, CONFLICT_REDRAIN_DELAY_MS);
}
async function drainPendingPrefs(writer: ServerUiPrefsWriter, epoch: number): Promise<void> {
while (pendingPrefs) {
if (pushWriter !== writer || pushEpoch !== epoch) {
@@ -477,47 +495,93 @@ async function drainPendingPrefs(writer: ServerUiPrefsWriter, epoch: number): Pr
if (!pendingPrefs) {
return;
}
const batch = { ...pendingPrefs };
if (pushProfileId && pendingPrefs.theme === "custom") {
// Offline-queued custom theme reaching a profile connection: browser-local
// by contract, so retain it here instead of syncing it to the profile.
cancelPendingKeys(pendingScope, ["theme"]);
updateRetainedLocalKeys(pendingScope, ["theme"], true);
continue;
}
const profileBatch: ServerUiPrefs = {};
if (pushProfileId && pushCanWrite) {
for (const key of SYNCED_PREF_KEYS) {
if (isAppearancePref(key) && Object.hasOwn(pendingPrefs, key)) {
Object.assign(profileBatch, { [key]: pendingPrefs[key] });
}
}
}
const useProfile = Boolean(profileBatch && Object.keys(profileBatch).length);
const batch = useProfile ? profileBatch : { ...pendingPrefs };
const afterCommit = pushAfterCommit;
for (let attempt = 0; attempt < 2; attempt += 1) {
if (pushWriter !== writer || pushEpoch !== epoch) {
return;
}
const result = await writer.runExternalMutation(
(client) =>
// ui.prefs is a deliberately narrow hashless LWW surface enforced by
// hasHashlessPatchLwwStructure in the gateway. Serialization still
// matters: a pending whole-config save must commit before this merge.
client.request("config.patch", {
raw: JSON.stringify({ ui: { prefs: batch } }),
...(batch.sidebarEntries !== undefined
? { replacePaths: ["ui.prefs.sidebarEntries"] }
: {}),
note: "control-ui prefs sync",
}),
{
waitForWritesResumed: true,
canDispatch: () => {
if (writer.canPatch === false) {
return false;
}
reconcilePersistedPendingPrefs();
if (batchIsCurrent(batch)) {
return true;
}
drainRequested = Boolean(pendingPrefs);
return false;
},
dispatchError: "Access changed before preferences could sync.",
},
);
const result = useProfile
? await writeProfileAppearancePrefs(
writer.state.client,
batch,
writer.state.connected && pushCanWrite && batchIsCurrent(batch),
)
: await writer.runExternalMutation(
(client) =>
// ui.prefs is a deliberately narrow hashless LWW surface enforced by
// hasHashlessPatchLwwStructure in the gateway. Serialization still
// matters: a pending whole-config save must commit before this merge.
client.request("config.patch", {
raw: JSON.stringify({ ui: { prefs: batch } }),
...(batch.sidebarEntries !== undefined
? { replacePaths: ["ui.prefs.sidebarEntries"] }
: {}),
note: "control-ui prefs sync",
}),
{
waitForWritesResumed: true,
canDispatch: () => {
if (writer.canPatch === false) {
return false;
}
reconcilePersistedPendingPrefs();
if (batchIsCurrent(batch)) {
return true;
}
drainRequested = Boolean(pendingPrefs);
return false;
},
dispatchError: "Access changed before preferences could sync.",
},
);
if (pushWriter !== writer || pushEpoch !== epoch) {
return;
}
if (result.ok) {
removeBatch(batch);
const lastSeen = parseStoredPrefs(readStorage(LAST_SEEN_KEY, pendingScope)) ?? {};
writeStorage(LAST_SEEN_KEY, pendingScope, JSON.stringify({ ...lastSeen, ...batch }));
const nextLastSeen = { ...lastSeen, ...batch };
const profilePrefs = resolveProfileAppearancePrefs(
writer.state.client?.gatewayUrl ?? "",
pushProfileId,
);
if (useProfile && profilePrefs) {
const configPrefs = extractServerUiPrefs(writer.state.configSnapshot?.config);
for (const key of SYNCED_PREF_KEYS) {
if (!Object.hasOwn(batch, key)) {
continue;
}
if (batch[key] === null) {
delete profilePrefs[key];
if (configPrefs[key] === undefined) {
delete nextLastSeen[key];
} else {
Object.assign(nextLastSeen, { [key]: configPrefs[key] });
}
} else {
Object.assign(profilePrefs, { [key]: batch[key] });
}
}
lastReconciledConfigObject = null;
}
writeStorage(LAST_SEEN_KEY, pendingScope, JSON.stringify(nextLastSeen));
settlePendingStorage(batch);
clearConflictRedrain();
if (pushWriter !== writer || pushEpoch !== epoch) {
@@ -570,7 +634,11 @@ function startPendingDrain(writer: ServerUiPrefsWriter): void {
if (!pendingPrefs) {
return;
}
if (writer.state.connected && writer.canPatch === false) {
if (
writer.state.connected &&
writer.canPatch === false &&
!(pushProfileId && pushCanWrite && Object.keys(pendingPrefs).some(isAppearancePref))
) {
return;
}
pushDraining = true;
@@ -590,30 +658,49 @@ function startPendingDrain(writer: ServerUiPrefsWriter): void {
export function pushServerUiPrefs(
writer: ServerUiPrefsWriter,
prefs: ServerUiPrefs,
hooks: { afterCommit?: (commit: ServerUiPrefsCommit) => void } = {},
hooks: ServerUiPrefsPushHooks = {},
): void {
adoptPushWriter(writer);
adoptPushWriter(writer, hooks);
clearConflictRedrain();
pushAfterCommit = hooks.afterCommit;
if (writer.state.connected && writer.canPatch === false) {
const keys = SYNCED_PREF_KEYS.filter((key) => Object.hasOwn(prefs, key));
const blockedKeys = writer.state.connected
? keys.filter((key) => {
if (pushProfileId && isAppearancePref(key)) {
// Imported custom palettes are browser-local by contract; a profile
// must never carry a theme another browser cannot render.
return !pushCanWrite || (key === "theme" && prefs.theme === "custom");
}
return writer.canPatch === false;
})
: [];
if (blockedKeys.length) {
// A connected read-only edit is intentionally browser-local. Supersede only
// same-key offline intent so a later authorization cannot replay stale input.
const keys = Object.keys(prefs) as SyncedPrefKey[];
cancelPendingKeys(pendingScope, keys);
updateRetainedLocalKeys(pendingScope, keys, true);
cancelPendingKeys(pendingScope, blockedKeys);
updateRetainedLocalKeys(pendingScope, blockedKeys, true);
hooks.afterCommit?.({ needsRefresh: false, retainedLocal: true });
return;
if (blockedKeys.length === keys.length) {
return;
}
}
const writablePrefs = blockedKeys.length
? Object.fromEntries(
Object.entries(prefs).filter(
([key]) => !blockedKeys.some((blockedKey) => blockedKey === key),
),
)
: prefs;
reconcilePersistedPendingPrefs();
pendingPrefs = { ...pendingPrefs, ...prefs };
pendingPrefs = { ...pendingPrefs, ...writablePrefs };
mergePendingIntoStorage();
startPendingDrain(writer);
}
export function flushServerUiPrefs(
writer: ServerUiPrefsWriter,
hooks: { afterCommit?: (commit: ServerUiPrefsCommit) => void } = {},
hooks: ServerUiPrefsPushHooks = {},
): void {
adoptPushWriter(writer);
adoptPushWriter(writer, hooks);
clearConflictRedrain();
pushEpoch += 1;
pushDraining = drainRequested = false;
+1
View File
@@ -1854,6 +1854,7 @@ export const en: TranslationMap = {
xl: "XL",
xxl: "XXL",
},
profileSyncedHint: "Saved to your profile — follows you on every device.",
syncedHint: "Synced across your devices through the gateway.",
syncPendingHint: "Waiting to sync through the gateway.",
notifications: {
+99 -1
View File
@@ -9,7 +9,11 @@ import type {
ApplicationGateway,
ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { changedServerUiPrefs, resetServerUiPrefsSync } from "../../app/server-prefs.ts";
import {
changedServerUiPrefs,
refreshProfileAppearancePrefs,
resetServerUiPrefsSync,
} from "../../app/server-prefs.ts";
import { loadSettings } from "../../app/settings.ts";
import * as modelCatalogStore from "../../lib/model-catalog-store.ts";
import {
@@ -24,6 +28,7 @@ import {
configSelectionFromSearch,
extractQuickSettingsSecurity,
} from "./config-page.ts";
import { serverUiPrefProvenanceHint } from "./view-appearance-preferences.ts";
import type { ConfigViewState } from "./view.ts";
const switchActiveRealtimeTalkCameras =
@@ -118,6 +123,99 @@ describe("extractQuickSettingsSecurity", () => {
});
describe("ConfigPage synced preference provenance", () => {
it.each([
{
label: "lets a profile-bound operator write appearance without config admin access",
selfUser: { id: "profile-owner" },
scopes: ["operator.write"],
canPatch: false,
appearanceCanSync: true,
localeCanSync: false,
},
{
label: "keeps read-only profile appearance device-local even when config patching is exposed",
selfUser: { id: "profile-viewer" },
scopes: ["operator.read"],
canPatch: true,
appearanceCanSync: false,
localeCanSync: true,
},
{
label: "preserves config-patch authorization when no profile is bound",
selfUser: null,
scopes: ["operator.write"],
canPatch: false,
appearanceCanSync: false,
localeCanSync: false,
},
])("$label", ({ selfUser, scopes, canPatch, appearanceCanSync, localeCanSync }) => {
const page = new ConfigPage() as unknown as {
context: ApplicationContext;
serverUiPrefsCanSync: (key?: "theme" | "themeMode" | "accent") => boolean | null;
};
page.context = {
gateway: {
snapshot: { selfUser, hello: { auth: { role: "operator", scopes } } },
},
runtimeConfig: { state: { connected: true }, canPatch },
} as unknown as ApplicationContext;
expect(page.serverUiPrefsCanSync("theme")).toBe(appearanceCanSync);
expect(page.serverUiPrefsCanSync("themeMode")).toBe(appearanceCanSync);
expect(page.serverUiPrefsCanSync("accent")).toBe(appearanceCanSync);
expect(page.serverUiPrefsCanSync()).toBe(localeCanSync);
});
it("describes profile-owned appearance without changing gateway or device-local hints", () => {
expect(serverUiPrefProvenanceHint("profile")).toBe(
"Saved to your profile — follows you on every device.",
);
expect(serverUiPrefProvenanceHint("synced")).toBe(
"Synced across your devices through the gateway.",
);
expect(serverUiPrefProvenanceHint("device-local")).toBe("Stored in this browser only.");
});
it("restores the gateway appearance default while queuing deletion of the profile override", async () => {
const configObject = { ui: { prefs: { theme: "dash" } } };
const client = {
request: vi.fn(async () => ({ status: "ok", entries: { "ui.theme": "knot" } })),
} as unknown as GatewayBrowserClient;
await refreshProfileAppearancePrefs({
client,
profileId: "profile-owner",
configObject,
scope: "ws://profile.test",
onApplied: vi.fn(),
});
const page = new ConfigPage() as unknown as {
context: ApplicationContext;
settings: ReturnType<typeof loadSettings>;
resetSyncedAppearancePref: (key: "theme") => void;
};
page.context = {
gateway: {
connection: { gatewayUrl: "ws://profile.test" },
snapshot: {
selfUser: { id: "profile-owner" },
hello: { auth: { role: "operator", scopes: ["operator.write"] } },
},
},
runtimeConfig: {
state: { connected: true, configSnapshot: { config: configObject } },
canPatch: false,
},
theme: { refresh: vi.fn() },
} as unknown as ApplicationContext;
const beforeReset = loadSettings();
page.settings = beforeReset;
page.resetSyncedAppearancePref("theme");
expect(page.settings.theme).toBe("dash");
expect(changedServerUiPrefs(beforeReset, page.settings)).toEqual({ theme: null });
});
it("uses the committed snapshot for both display and reset while the form draft differs", () => {
const page = new ConfigPage();
const committedConfig = { ui: { prefs: { theme: "claw" } } };
+21 -6
View File
@@ -19,7 +19,7 @@ import {
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { importCustomThemeFromUrl } from "../../app/custom-theme.ts";
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
import {
resetServerUiPref,
resolveServerUiPrefState,
@@ -871,7 +871,10 @@ export class ConfigPage extends OpenClawLightDomElement {
"theme",
this.context.gateway.connection.gatewayUrl,
this.settings,
{ canSync: this.serverUiPrefsCanSync() },
{
canSync: this.serverUiPrefsCanSync("theme"),
profileId: this.context.gateway.snapshot?.selfUser?.id,
},
);
}
@@ -881,7 +884,10 @@ export class ConfigPage extends OpenClawLightDomElement {
"themeMode",
this.context.gateway.connection.gatewayUrl,
this.settings,
{ canSync: this.serverUiPrefsCanSync() },
{
canSync: this.serverUiPrefsCanSync("themeMode"),
profileId: this.context.gateway.snapshot?.selfUser?.id,
},
);
}
@@ -891,7 +897,10 @@ export class ConfigPage extends OpenClawLightDomElement {
"accent",
this.context.gateway.connection.gatewayUrl,
this.settings,
{ canSync: this.serverUiPrefsCanSync() },
{
canSync: this.serverUiPrefsCanSync("accent"),
profileId: this.context.gateway.snapshot?.selfUser?.id,
},
);
}
@@ -915,9 +924,15 @@ export class ConfigPage extends OpenClawLightDomElement {
);
}
private serverUiPrefsCanSync(): boolean | null {
private serverUiPrefsCanSync(key?: "theme" | "themeMode" | "accent"): boolean | null {
const runtimeConfig = this.context.runtimeConfig;
return runtimeConfig.state.connected ? runtimeConfig.canPatch !== false : null;
if (!runtimeConfig.state.connected) {
return null;
}
const gateway = this.context.gateway.snapshot;
return key && gateway?.selfUser
? hasOperatorWriteAccess(gateway.hello?.auth ?? null)
: runtimeConfig.canPatch !== false;
}
private resetLocale() {
@@ -32,6 +32,9 @@ import { renderSettingsSelectRow } from "./settings-select-row.ts";
import type { ConfigProps } from "./view-types.ts";
export function serverUiPrefProvenanceHint(provenance: ServerUiPrefProvenance): string {
if (provenance === "profile") {
return t("configView.profileSyncedHint");
}
if (provenance === "device-local") {
return t("quickSettings.personal.browserOnly");
}