diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index a8d60f5637f4..c2fdc91d6591 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -92,6 +92,7 @@ import ai.openclaw.app.node.asStringOrNull import ai.openclaw.app.node.invokeErrorFromThrowable import ai.openclaw.app.node.readAndroidPermissionSnapshot import ai.openclaw.app.node.resolveGatewayAccentArgb +import ai.openclaw.app.node.resolveProfileAccentArgb import ai.openclaw.app.systemagent.SystemAgentChatController import ai.openclaw.app.systemagent.SystemAgentChatState import ai.openclaw.app.systemagent.SystemAgentGatewayAccess @@ -5174,6 +5175,11 @@ class NodeRuntime private constructor( if (event == GatewayEvent.VoicewakeChanged.rawValue) { applyVoiceWakeWords(payloadJson) } + if (event == GatewayEvent.UsersPrefsChanged.rawValue) { + // The gateway targets this event at connections bound to the caller's own + // profile; receipt means our profile appearance changed on another device. + scope.launch { refreshBrandingFromGateway() } + } handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson) micCapture.handleGatewayEvent(event, payloadJson) talkMode.handleGatewayEvent(event, payloadJson) @@ -5548,7 +5554,7 @@ class NodeRuntime private constructor( val res = requestGatewayData(gatewayScope, "config.get", "{}") val root = json.parseToJsonElement(res).asObjectOrNull() val config = root?.get("config").asObjectOrNull() - val parsed = resolveGatewayAccentArgb(config) + val parsed = fetchProfileAccentArgb(gatewayScope) ?: resolveGatewayAccentArgb(config) publishGatewayData(gatewayScope) { _gatewayAccentArgb.value = parsed } @@ -5557,6 +5563,28 @@ class NodeRuntime private constructor( } } + /** + * Caller's per-profile accent (users.prefs.get). Null covers profile-less + * connections (no_durable_identity), older gateways without the method, and + * malformed stored values, so the gateway accent stays the fallback. Inner + * try: a failed profile fetch must not discard the config accent. + */ + private suspend fun fetchProfileAccentArgb(gatewayScope: GatewayDataScope): Long? = + try { + val res = + requestGatewayData(gatewayScope, GatewayMethod.UsersPrefsGet.rawValue, """{"keys":["ui.accent"]}""") + val root = json.parseToJsonElement(res).asObjectOrNull() + if ((root?.get("status") as? JsonPrimitive)?.contentOrNull == "ok") { + resolveProfileAccentArgb(root.get("entries").asObjectOrNull()) + } else { + null + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + null + } + /** Lists one directory of the active agent's workspace (read-only RPC). */ suspend fun listWorkspaceFiles( path: String?, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt index 0a40f0d92fe7..963ba1a2b2ef 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/NodeUtils.kt @@ -75,6 +75,15 @@ fun parseHexColorArgb(raw: String?): Long? { return 0xFF000000L or rgb } +/** + * Per-profile accent from a users.prefs.get entries payload. Null for missing or + * malformed values so callers fall back to the gateway accent. + */ +fun resolveProfileAccentArgb(entries: JsonObject?): Long? { + val value = entries?.get("ui.accent")?.takeIf { it !is JsonNull } + return parseHexColorArgb((value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull) +} + fun resolveGatewayAccentArgb(config: JsonObject?): Long? { val ui = config?.get("ui").asObjectOrNull() // Control UI precedence (gateway talk.config): a present user accent wins over the diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/NodeUtilsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/NodeUtilsTest.kt index 9fe23e918a61..d38939e104ee 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/NodeUtilsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/NodeUtilsTest.kt @@ -82,4 +82,23 @@ class NodeUtilsTest { } assertNull(resolveGatewayAccentArgb(null)) } + + @Test + fun resolveProfileAccentArgb_readsUiAccentEntryStrictly() { + val cases = + linkedMapOf( + """{"ui.accent":"#123456"}""" to 0xFF123456L, + """{"ui.accent":"ABCDEF"}""" to 0xFFABCDEFL, + """{"ui.accent":"invalid"}""" to null, + """{"ui.accent":123}""" to null, + """{"ui.accent":null}""" to null, + """{}""" to null, + ) + + for ((source, expected) in cases) { + val entries = json.parseToJsonElement(source) as JsonObject + assertEquals(source, expected, resolveProfileAccentArgb(entries)) + } + assertNull(resolveProfileAccentArgb(null)) + } } diff --git a/apps/ios/Sources/Design/ColorHexSupport.swift b/apps/ios/Sources/Design/ColorHexSupport.swift index 7ca4e44cfb06..a0dbd5a47e43 100644 --- a/apps/ios/Sources/Design/ColorHexSupport.swift +++ b/apps/ios/Sources/Design/ColorHexSupport.swift @@ -18,6 +18,12 @@ enum ColorHexSupport { return "#\(hex)" } + /// Per-profile accent from a users.prefs.get entries payload. nil for + /// missing or malformed values so callers fall back to the gateway accent. + static func profileAccentHex(entries: [String: Any]?) -> String? { + self.normalizedHex(entries?["ui.accent"] as? String) + } + /// Gateway user-accent contract shared with the Control UI and talk config: /// ui.prefs.accent wins over ui.seamColor; invalid values fall through. static func gatewayUserAccentHex(configUI ui: [String: Any]?) -> String? { diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index b3c93c50bcdf..e8054e14783b 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1582,13 +1582,14 @@ final class NodeAppModel { let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) let scope = (session?["scope"] as? String) ?? "per-sender" let accentHex = ColorHexSupport.gatewayUserAccentHex(configUI: config["ui"] as? [String: Any]) + let profileAccentHex = await self.fetchProfileAccentHex(ifCurrentRoute: sourceRoute) guard shouldApply(), GatewayStableIdentifier.matches(self.chatTranscriptCacheGatewayID, sourceGatewayID) else { return } await MainActor.run { self.mainSessionBaseKey = mainKey self.gatewaySessionScope = scope - self.gatewayAccentColorHex = accentHex + self.gatewayAccentColorHex = profileAccentHex ?? accentHex self.synchronizeTalkSessionKey() } } catch { @@ -1602,6 +1603,26 @@ final class NodeAppModel { } } + /// Caller's per-profile accent (users.prefs.get). nil covers every + /// non-authoritative outcome — profile-less connections + /// (no_durable_identity), older gateways without the method, and malformed + /// stored values — so the gateway accent stays the fallback. + private func fetchProfileAccentHex(ifCurrentRoute sourceRoute: GatewayNodeSessionRoute) async -> String? { + do { + let res = try await operatorGateway.request( + method: "users.prefs.get", + paramsJSON: #"{"keys":["ui.accent"]}"#, + timeoutSeconds: 8, + ifCurrentRoute: sourceRoute) + guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any], + json["status"] as? String == "ok" + else { return nil } + return ColorHexSupport.profileAccentHex(entries: json["entries"] as? [String: Any]) + } catch { + return nil + } + } + private func refreshAgentsFromGateway(shouldApply: () -> Bool = { true }) async { do { guard let sourceGatewayID = self.chatTranscriptCacheGatewayID, @@ -1739,6 +1760,11 @@ final class NodeAppModel { // already-connected app; the protocol directs clients to re-read via // config.get, which the guarded branding refresh already does. await self.refreshBrandingFromGateway(shouldApply: shouldContinue) + case "users.prefs.changed": + // The gateway targets this event at connections bound to the + // caller's own profile, so any receipt means our profile appearance + // changed on another device — re-run the guarded branding refresh. + await self.refreshBrandingFromGateway(shouldApply: shouldContinue) case "voicewake.changed": struct Payload: Decodable { var triggers: [String] } guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return } diff --git a/apps/ios/Tests/GatewayAccentColorTests.swift b/apps/ios/Tests/GatewayAccentColorTests.swift index c2597e32134c..b28aa5ac890e 100644 --- a/apps/ios/Tests/GatewayAccentColorTests.swift +++ b/apps/ios/Tests/GatewayAccentColorTests.swift @@ -3,13 +3,13 @@ import Testing @testable import OpenClaw struct GatewayAccentColorTests { - @Test func normalizesBareAndPrefixedHex() { + @Test func `normalizes bare and prefixed hex`() { #expect(ColorHexSupport.normalizedHex("#A1B2C3") == "#a1b2c3") #expect(ColorHexSupport.normalizedHex("a1b2c3") == "#a1b2c3") #expect(ColorHexSupport.normalizedHex(" #ff0000 ") == "#ff0000") } - @Test func rejectsInvalidHex() { + @Test func `rejects invalid hex`() { #expect(ColorHexSupport.normalizedHex(nil) == nil) #expect(ColorHexSupport.normalizedHex("") == nil) #expect(ColorHexSupport.normalizedHex("#fff") == nil) @@ -19,7 +19,7 @@ struct GatewayAccentColorTests { #expect(ColorHexSupport.normalizedHex("+abcde1") == nil) } - @Test func userAccentWinsOverSeamColor() { + @Test func `user accent wins over seam color`() { let ui: [String: Any] = [ "prefs": ["accent": "#123456"], "seamColor": "#654321", @@ -27,7 +27,7 @@ struct GatewayAccentColorTests { #expect(ColorHexSupport.gatewayUserAccentHex(configUI: ui) == "#123456") } - @Test func invalidAccentFallsBackToSeamColor() { + @Test func `invalid accent falls back to seam color`() { let ui: [String: Any] = [ "prefs": ["accent": "not-a-color"], "seamColor": "#654321", @@ -35,8 +35,19 @@ struct GatewayAccentColorTests { #expect(ColorHexSupport.gatewayUserAccentHex(configUI: ui) == "#654321") } - @Test func missingUIReturnsNil() { + @Test func `missing UI returns nil`() { #expect(ColorHexSupport.gatewayUserAccentHex(configUI: nil) == nil) #expect(ColorHexSupport.gatewayUserAccentHex(configUI: [:]) == nil) } + + @Test func `profile accent reads ui accent entry`() { + #expect(ColorHexSupport.profileAccentHex(entries: ["ui.accent": "#A1B2C3"]) == "#a1b2c3") + } + + @Test func `profile accent rejects missing or malformed entries`() { + #expect(ColorHexSupport.profileAccentHex(entries: nil) == nil) + #expect(ColorHexSupport.profileAccentHex(entries: [:]) == nil) + #expect(ColorHexSupport.profileAccentHex(entries: ["ui.accent": "not-a-color"]) == nil) + #expect(ColorHexSupport.profileAccentHex(entries: ["ui.accent": 42]) == nil) + } } diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index f147745534fe..3b9c525678f0 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -278,6 +278,15 @@ final class AppState { /// Gateway-provided UI accent color (hex). Optional; clients provide a default. var seamColorHex: String? + /// Caller's per-profile accent (users.prefs.get). Kept separate from + /// seamColorHex so settings-pane config refreshes cannot clobber it. + var profileAccentHex: String? + + /// Accent the UI renders: the profile accent wins over the gateway seam color. + var effectiveAccentHex: String? { + self.profileAccentHex ?? self.seamColorHex + } + var iconOverride: IconOverrideSelection { didSet { self.ifNotPreview { AppDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } } @@ -528,6 +537,7 @@ final class AppState { AppDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey) } self.seamColorHex = nil + self.profileAccentHex = nil if let storedHeartbeats = AppDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { self.heartbeatsEnabled = storedHeartbeats } else { diff --git a/apps/macos/Sources/OpenClaw/ColorHexSupport.swift b/apps/macos/Sources/OpenClaw/ColorHexSupport.swift index 506f2f1fb4ad..571b8a5664ee 100644 --- a/apps/macos/Sources/OpenClaw/ColorHexSupport.swift +++ b/apps/macos/Sources/OpenClaw/ColorHexSupport.swift @@ -1,6 +1,21 @@ import SwiftUI enum ColorHexSupport { + /// Strict #rrggbb validation (leading "#" optional); canonical lowercase "#rrggbb". + static func normalizedHex(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = (trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed).lowercased() + guard hex.count == 6, hex.allSatisfy({ $0.isASCII && $0.isHexDigit }) else { return nil } + return "#\(hex)" + } + + /// Per-profile accent from a users.prefs.get entries payload. nil for + /// missing or malformed values so callers fall back to the gateway accent. + static func profileAccentHex(entries: [String: Any]?) -> String? { + self.normalizedHex(entries?["ui.accent"] as? String) + } + static func color(fromHex raw: String?) -> Color? { let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index 9fd3fdc827f6..b07cf6e29f80 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -504,13 +504,47 @@ final class ControlChannel { } case let .event(evt) where evt.event == "shutdown": self.setStateThrottled(.degraded("gateway shutdown")) + case let .event(evt) where evt.event == "users.prefs.changed": + // The gateway targets this event at connections bound to the + // caller's own profile; receipt means our profile appearance + // changed on another device. + self.refreshProfileAccent() case .snapshot: self.setStateThrottled(.connected) + self.refreshProfileAccent() default: break } } + private func refreshProfileAccent() { + Task { + let accent = await Self.fetchProfileAccentHex() + AppStateStore.shared.profileAccentHex = accent + } + } + + /// Caller's per-profile accent (users.prefs.get). nil covers profile-less + /// connections (no_durable_identity), older gateways without the method, + /// and malformed stored values, so the gateway seam color stays the + /// fallback. Goes straight through GatewayConnection: routing this through + /// ControlChannel.request would mark the channel degraded on the expected + /// older-gateway failure. + private static func fetchProfileAccentHex() async -> String? { + do { + let data = try await GatewayConnection.shared.requestRaw( + method: "users.prefs.get", + params: ["keys": OpenClawKit.AnyCodable(["ui.accent"])], + timeoutMs: 8000) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + json["status"] as? String == "ok" + else { return nil } + return ColorHexSupport.profileAccentHex(entries: json["entries"] as? [String: Any]) + } catch { + return nil + } + } + private func routeWorkActivity(from event: ControlAgentEvent) { // We currently treat VoiceWake as the "main" session for UI purposes. // In the future, the gateway can include a sessionKey to distinguish runs. diff --git a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift index 66a5d622d2df..fe5c1cc0a552 100644 --- a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift +++ b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift @@ -54,7 +54,7 @@ struct TalkOverlayView: View { private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) private var seamColor: Color { - ColorHexSupport.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor + ColorHexSupport.color(fromHex: self.appState.effectiveAccentHex) ?? Self.defaultSeamColor } } diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 8a0f998fe3ad..40e9bfdfb1f5 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -889,7 +889,7 @@ private struct MacChatSurface: View { var body: some View { OpenClawChatWindowShell( viewModel: self.viewModel, - userAccent: ColorHexSupport.color(fromHex: self.appState.seamColorHex), + userAccent: ColorHexSupport.color(fromHex: self.appState.effectiveAccentHex), displayOptions: self.displayOptions, emptyAssistantIntro: Self.emptyAssistantIntro, emptyAssistantPrompts: Self.emptyAssistantPrompts, diff --git a/apps/macos/Tests/OpenClawIPCTests/ChannelsStoreUIConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/ChannelsStoreUIConfigTests.swift index 76620ed8b7b6..0174f02e09f1 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ChannelsStoreUIConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ChannelsStoreUIConfigTests.swift @@ -19,6 +19,28 @@ struct ChannelsStoreUIConfigTests { #expect(ChannelsStore.uiAccent(userAccent: " ", seamColor: " \n ") == nil) } + @Test func `profile accent wins over the gateway seam color`() { + let store = AppStateStore.shared + let savedSeam = store.seamColorHex + let savedProfile = store.profileAccentHex + defer { + store.seamColorHex = savedSeam + store.profileAccentHex = savedProfile + } + store.seamColorHex = "#445566" + store.profileAccentHex = nil + #expect(store.effectiveAccentHex == "#445566") + store.profileAccentHex = "#112233" + #expect(store.effectiveAccentHex == "#112233") + } + + @Test func `profile accent entries validate strictly`() { + #expect(ColorHexSupport.profileAccentHex(entries: ["ui.accent": "#A1B2C3"]) == "#a1b2c3") + #expect(ColorHexSupport.profileAccentHex(entries: ["ui.accent": "not-a-color"]) == nil) + #expect(ColorHexSupport.profileAccentHex(entries: [:]) == nil) + #expect(ColorHexSupport.profileAccentHex(entries: nil) == nil) + } + @Test func `config snapshots preserve the Control UI user accent`() { let previousAccent = AppStateStore.shared.seamColorHex defer { AppStateStore.shared.seamColorHex = previousAccent } diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 34f386ca0aa0..575fddb20fb0 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -33,7 +33,6 @@ "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": { @@ -71,7 +70,6 @@ "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." } }