mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
33b10fe4aa
* fix(macos): honor user accent precedence in config snapshot and live-update chat window The Control UI user accent (ui.prefs.accent) landed in #128432/#128577 with precedence user accent -> operator ui.seamColor -> theme default, and the gateway's talk.config payload already applies it. The macOS app had two gaps: - ChannelsStore.applyUIConfig read raw ui.seamColor from the config.get snapshot and clobbered the user accent set from talk.config depending on arrival order. It now resolves ui.prefs.accent ?? ui.seamColor via a testable helper mirroring the gateway precedence. - The native chat window read AppStateStore.seamColorHex once at window construction, so accent changes never live-updated. MacChatSurface now reads the @Observable store in body, deleting the one-shot userAccent plumbing. Docs: configuration-reference.md documents the precedence for native-app chrome. Regression test fails pre-fix (snapshot returned the operator seam color instead of the user accent). * fix(macos): refresh config from gateway config.changed events Addresses the review finding that no macOS consumer turned the gateway's hash-only config.changed broadcast into refreshed shared state, so a Control UI accent change never reached an open native chat window while the app ran. ChannelsStore now subscribes to gateway pushes and re-fetches config.get on config.changed, reconnect snapshots, and sequence gaps. The refresh applies non-force so an in-progress local settings draft wins (the gateway rejects stale-hash writes anyway). The in-flight reload queue gains a closed pending level (none/refresh/force) so a refresh arriving during a load is coalesced instead of dropped, and a requeued refresh cannot clobber a dirty draft the way the old boolean force-pending did.
222 lines
7.6 KiB
Swift
222 lines
7.6 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
|
|
func whatsappLoginWaitRequestTimeoutMs(
|
|
startedAt: Date,
|
|
timeoutMs: Int,
|
|
didRunFinalWait: inout Bool,
|
|
now: Date = Date()) -> Int?
|
|
{
|
|
let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000)
|
|
let remainingMs = max(timeoutMs - elapsedMs, 0)
|
|
if remainingMs > 0 {
|
|
return remainingMs
|
|
}
|
|
if didRunFinalWait {
|
|
return nil
|
|
}
|
|
didRunFinalWait = true
|
|
return 1
|
|
}
|
|
|
|
extension ChannelsStore {
|
|
func start() {
|
|
guard !self.isPreview else { return }
|
|
self.startCount += 1
|
|
guard self.startCount == 1 else { return }
|
|
guard self.pollTask == nil else { return }
|
|
GatewayPushSubscription.restartTask(task: &self.gatewayPushTask) { [weak self] push in
|
|
self?.handleGatewayPush(push)
|
|
}
|
|
self.pollTask = Task.detached { [weak self] in
|
|
guard let self else { return }
|
|
await self.refresh(probe: false)
|
|
async let schemaLoad: Void = self.loadConfigSchema()
|
|
async let configLoad: Void = self.loadConfig(force: false)
|
|
_ = await (schemaLoad, configLoad)
|
|
while await SimpleTaskSupport.waitForNextOperation(interval: self.interval) {
|
|
await self.refresh(probe: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
guard !self.isPreview else { return }
|
|
guard self.startCount > 0 else { return }
|
|
self.startCount -= 1
|
|
guard self.startCount == 0 else { return }
|
|
self.pollTask?.cancel()
|
|
self.pollTask = nil
|
|
self.gatewayPushTask?.cancel()
|
|
self.gatewayPushTask = nil
|
|
}
|
|
|
|
static func gatewayPushRequestsConfigRefresh(_ push: GatewayPush) -> Bool {
|
|
switch push {
|
|
case let .event(event):
|
|
event.event == "config.changed"
|
|
case .snapshot, .seqGap:
|
|
true
|
|
}
|
|
}
|
|
|
|
private func handleGatewayPush(_ push: GatewayPush) {
|
|
guard Self.gatewayPushRequestsConfigRefresh(push) else { return }
|
|
// Change events contain only a hash; refetch without overwriting a dirty local draft.
|
|
Task { await self.loadConfig(force: false, refresh: true) }
|
|
}
|
|
|
|
func refresh(probe: Bool) async {
|
|
guard !self.isRefreshing else { return }
|
|
self.isRefreshing = true
|
|
defer { self.isRefreshing = false }
|
|
|
|
do {
|
|
let statusTimeoutMs = probe ? 8000 : 2500
|
|
let params: [String: AnyCodable] = [
|
|
"probe": AnyCodable(probe),
|
|
"timeoutMs": AnyCodable(statusTimeoutMs),
|
|
]
|
|
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
|
|
method: .channelsStatus,
|
|
params: params,
|
|
timeoutMs: probe ? 12000 : 5000)
|
|
self.snapshot = snap
|
|
self.lastSuccess = Date()
|
|
self.lastError = nil
|
|
} catch {
|
|
self.lastError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async {
|
|
guard !self.whatsappBusy else { return }
|
|
self.whatsappBusy = true
|
|
defer { self.whatsappBusy = false }
|
|
var shouldAutoWait = false
|
|
do {
|
|
let params: [String: AnyCodable] = [
|
|
"force": AnyCodable(force),
|
|
"timeoutMs": AnyCodable(30000),
|
|
]
|
|
let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded(
|
|
method: .webLoginStart,
|
|
params: params,
|
|
timeoutMs: 35000)
|
|
self.whatsappLoginMessage = result.message
|
|
self.whatsappLoginQrDataUrl = result.qrDataUrl
|
|
self.whatsappLoginConnected = result.connected
|
|
shouldAutoWait = autoWait && result.qrDataUrl != nil
|
|
} catch {
|
|
self.whatsappLoginMessage = error.localizedDescription
|
|
self.whatsappLoginQrDataUrl = nil
|
|
self.whatsappLoginConnected = nil
|
|
}
|
|
await self.refresh(probe: true)
|
|
if shouldAutoWait {
|
|
Task { await self.waitWhatsAppLogin() }
|
|
}
|
|
}
|
|
|
|
func waitWhatsAppLogin(timeoutMs: Int = 120_000) async {
|
|
guard !self.whatsappBusy else { return }
|
|
self.whatsappBusy = true
|
|
defer { self.whatsappBusy = false }
|
|
let startedAt = Date()
|
|
var didRunFinalWait = false
|
|
do {
|
|
while let remainingMs = whatsappLoginWaitRequestTimeoutMs(
|
|
startedAt: startedAt,
|
|
timeoutMs: timeoutMs,
|
|
didRunFinalWait: &didRunFinalWait)
|
|
{
|
|
var params: [String: AnyCodable] = [
|
|
"timeoutMs": AnyCodable(remainingMs),
|
|
]
|
|
if let currentQrDataUrl = self.whatsappLoginQrDataUrl {
|
|
params["currentQrDataUrl"] = AnyCodable(currentQrDataUrl)
|
|
}
|
|
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
|
|
method: .webLoginWait,
|
|
params: params,
|
|
timeoutMs: Double(remainingMs) + 5000)
|
|
self.applyWhatsAppLoginWaitResult(result)
|
|
if result.connected || result.qrDataUrl == nil || didRunFinalWait {
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
self.whatsappLoginMessage = error.localizedDescription
|
|
}
|
|
await self.refresh(probe: true)
|
|
}
|
|
|
|
func logoutWhatsApp() async {
|
|
guard !self.whatsappBusy else { return }
|
|
self.whatsappBusy = true
|
|
defer { self.whatsappBusy = false }
|
|
do {
|
|
let params: [String: AnyCodable] = [
|
|
"channel": AnyCodable("whatsapp"),
|
|
]
|
|
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
|
method: .channelsLogout,
|
|
params: params,
|
|
timeoutMs: 15000)
|
|
self.whatsappLoginMessage = result.cleared
|
|
? "Logged out and cleared credentials."
|
|
: "No WhatsApp session found."
|
|
self.whatsappLoginQrDataUrl = nil
|
|
} catch {
|
|
self.whatsappLoginMessage = error.localizedDescription
|
|
}
|
|
await self.refresh(probe: true)
|
|
}
|
|
|
|
func logoutTelegram() async {
|
|
guard !self.telegramBusy else { return }
|
|
self.telegramBusy = true
|
|
defer { self.telegramBusy = false }
|
|
do {
|
|
let params: [String: AnyCodable] = [
|
|
"channel": AnyCodable("telegram"),
|
|
]
|
|
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
|
method: .channelsLogout,
|
|
params: params,
|
|
timeoutMs: 15000)
|
|
if result.envToken == true {
|
|
self.configStatus = "Telegram token still set via env; config cleared."
|
|
} else {
|
|
self.configStatus = result.cleared
|
|
? "Telegram token cleared."
|
|
: "No Telegram token configured."
|
|
}
|
|
await self.loadConfig()
|
|
} catch {
|
|
self.configStatus = error.localizedDescription
|
|
}
|
|
await self.refresh(probe: true)
|
|
}
|
|
}
|
|
|
|
private struct WhatsAppLoginStartResult: Codable {
|
|
let qrDataUrl: String?
|
|
let message: String
|
|
let connected: Bool?
|
|
}
|
|
|
|
struct WhatsAppLoginWaitResult: Codable {
|
|
let connected: Bool
|
|
let message: String
|
|
let qrDataUrl: String?
|
|
}
|
|
|
|
private struct ChannelLogoutResult: Codable {
|
|
let channel: String?
|
|
let accountId: String?
|
|
let cleared: Bool
|
|
let envToken: Bool?
|
|
}
|