feat(apps): resolve the per-profile accent live on iOS, macOS, and Android (#130598)

* feat(apps): resolve the per-profile accent live on iOS, macOS, and Android

Named follow-up from #130340: native apps now fetch the caller's own
profile accent (users.prefs.get, strict #rrggbb normalization) and prefer
it over the gateway accent, refetching on users.prefs.changed — the
gateway targets that event at the caller's own profile, so clients need no
identity logic. macOS stores it separately from the seam color so
settings-pane config refreshes cannot clobber it, and fetches bypass
ControlChannel.request to avoid degrading the channel on older gateways.
Profile-less and token connections are unchanged. Removes the ios/android
users.prefs.changed allowlist entries now that handlers exist.

* chore(macos): satisfy swiftformat explicit-self on profileAccentHex
This commit is contained in:
Peter Steinberger
2026-08-26 20:01:40 -07:00
committed by GitHub
parent a87fb08e70
commit b581ff0be7
13 changed files with 189 additions and 11 deletions
@@ -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?,
@@ -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
@@ -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))
}
}
@@ -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? {
+27 -1
View File
@@ -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 }
+16 -5
View File
@@ -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)
}
}
@@ -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 {
@@ -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 }
@@ -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.
@@ -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
}
}
@@ -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,
@@ -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 }
@@ -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."
}
}