mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
a87fb08e70
commit
b581ff0be7
@@ -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? {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user