Files
openclaw/apps/macos/Sources/OpenClaw/AppState.swift
Peter Steinberger 2b0da0e193 refactor(macos): single-owner hybrid status menu with live session cards and exec approvals (#130041)
* test(macos): add OPENCLAW_DEBUG_OPEN_MENU screenshot hook

* feat(macos): add live execution approval queue

* refactor(macos): replace menu injectors with owned status menu

* fix(macos): keep unconfigured menu header calm

* fix(macos): route status-item right-clicks through a local event monitor

NSControl's send-action mask ignores right mouse buttons, so the previous
sendAction(on: [.rightMouseUp]) wiring never fired and the menu was
unreachable by pointer. A local monitor now owns pointer routing (left =
dashboard, right = menu) — the same mechanism the shipped
StatusItemMouseRouter used — and menuWillOpen gained the re-entrancy
guard the old injector carried, since reconciling tracked rows can
re-enter the callback without a close.

* chore(i18n): refresh native inventory for status menu strings

* chore(macos): remove menu-refactor dead code

Periphery flagged the orphans the status-menu refactor left behind: the
ExecApprovalQuickMode enum and AppState's entire quick-mode read/retry
surface (its only consumer was the deleted menu picker; the Settings
pane owns exec-approval policy UI), SessionMenuLabelView,
TrackingAreaSupport, NodeMenuMultilineView, UpdateStatus.disabled, and
two fixture-only initializers. StatusMenuController.stop() is now wired
into applicationWillTerminate. The menu-highlight environment key moved
from the deleted view file into MenuItemHighlightColors.

* chore(macos): fix status-menu lint style and refresh i18n inventory

* fix(macos): converge approval cards after losing a resolution race

The status-menu queue and the modal prompter intentionally share the
gateway approval event stream: the gateway resolves each approval
exactly once, the resolved broadcast removes the card, and the modal
stays the active presentation owner while the menu is the passive,
ambient one. What was missing: when the menu's resolve loses the race
(modal or another client answered first), the gateway rejection left a
zombie card if the resolved event was dropped. Resolve failures now
re-list from the authoritative queue. Regression test simulates the
race at the socket boundary and fails pre-fix.
2026-08-26 06:16:16 -07:00

1568 lines
61 KiB
Swift

import AppKit
import Foundation
import Observation
import OpenClawKit
import ServiceManagement
import SwiftUI
private enum RemoteTLSFingerprintUpdate {
case preserve
case replace(String?)
}
@MainActor
final class VoiceWakeGlobalSyncScheduler {
private let delay: @Sendable () async throws -> Void
private let deliver: @Sendable ([String]) async -> Void
private var debounceTask: Task<Void, Never>?
private var deliveryTail: Task<Void, Never>?
private var generation: UInt64 = 0
init(
delay: @escaping @Sendable () async throws -> Void = {
try await Task.sleep(for: .milliseconds(650))
},
deliver: @escaping @Sendable ([String]) async -> Void = { triggers in
await GatewayConnection.shared.voiceWakeSetTriggers(triggers)
})
{
self.delay = delay
self.deliver = deliver
}
@discardableResult
func schedule(_ triggers: [String]) -> Task<Void, Never> {
self.generation &+= 1
let generation = self.generation
self.debounceTask?.cancel()
let delay = self.delay
let task = Task { [weak self] in
do {
try await delay()
} catch {
return
}
guard !Task.isCancelled, let self, self.generation == generation else { return }
self.enqueueDelivery(triggers, generation: generation)
}
self.debounceTask = task
return task
}
private func enqueueDelivery(_ triggers: [String], generation: UInt64) {
let previousDelivery = self.deliveryTail
let deliver = self.deliver
self.deliveryTail = Task { [weak self, previousDelivery] in
await previousDelivery?.value
guard let self, self.generation == generation else { return }
await deliver(triggers)
}
}
}
enum ExecApprovalsPolicyLoadState: Equatable {
case loading
case available
case unavailable(String)
var isAvailable: Bool {
self == .available
}
var errorMessage: String? {
guard case let .unavailable(message) = self else { return nil }
return message
}
}
@MainActor
@Observable
final class AppState {
private static let logger = Logger(subsystem: "ai.openclaw", category: "app-state")
let isPreview: Bool
@ObservationIgnored private let gatewayConfigSaver: ([String: Any]) -> Bool
@ObservationIgnored let bundleLocationAllowsPersistentIntegration: Bool
@ObservationIgnored private var isHydratingLaunchAtLogin = false
private var isInitializing = true
private var isApplyingGatewayConfig = false
private enum GatewayConfigSyncState: Equatable {
case current
case pending
case failed
}
@ObservationIgnored private var gatewayConfigSyncState = GatewayConfigSyncState.current
@ObservationIgnored private var gatewayConfigSyncTask: Task<Void, Never>?
@ObservationIgnored private(set) var gatewayRoutingGeneration: UInt64 = 0
#if DEBUG
@ObservationIgnored private var gatewayConfigSyncEnabledForTesting = false
#endif
private var configWatcher: ConfigFileWatcher?
private var lastConfigFingerprint: Data?
private var lastObservedGatewayConfig: GatewayConfigSnapshot = .empty
private var dirtyGatewayConfigFields: Set<GatewayConfigField> = []
private var conflictedGatewayConfigFields: Set<GatewayConfigField> = []
private var suppressVoiceWakeGlobalSync = false
@ObservationIgnored private let voiceWakeGlobalSyncScheduler = VoiceWakeGlobalSyncScheduler()
@ObservationIgnored private var activeComputerPresenceTask: Task<Void, Never>?
@ObservationIgnored private var activeComputerPresenceUpdateGeneration: UInt64 = 0
@ObservationIgnored private var computerControlHostReconciliationTask: Task<Void, Never>?
@ObservationIgnored private var computerControlHostGeneration: UInt64 = 0
var isPaused: Bool {
didSet { self.ifNotPreview { AppDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
var launchAtLogin: Bool {
didSet {
guard Self.shouldPersistLaunchAtLoginChange(
isInitializing: self.isInitializing,
isHydrating: self.isHydratingLaunchAtLogin,
isEnabling: self.launchAtLogin,
bundleLocationAllowsPersistentIntegration: self.bundleLocationAllowsPersistentIntegration)
else { return }
self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } }
}
}
var onboardingSeen: Bool {
didSet { self.ifNotPreview { AppDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) }
}
}
var debugPaneEnabled: Bool {
didSet {
self.ifNotPreview { AppDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) }
CanvasManager.shared.refreshDebugStatus()
}
}
var nativeSettingsPanesEnabled: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.nativeSettingsPanesEnabled, forKey: nativeSettingsPanesEnabledKey)
}
}
}
var swabbleEnabled: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
var swabbleTriggerWords: [String] {
didSet {
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
self.ifNotPreview {
AppDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
self.scheduleVoiceWakeGlobalSyncIfNeeded()
}
}
}
var voiceWakeTriggerChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
}
var voiceWakeSendChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
}
var iconAnimationsEnabled: Bool {
didSet { self.ifNotPreview { AppDefaults.standard.set(
self.iconAnimationsEnabled,
forKey: iconAnimationsEnabledKey) } }
}
var showDockIcon: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
AppActivationPolicy.apply(showDockIcon: self.showDockIcon)
}
}
}
var voiceWakeMicID: String {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
if self.swabbleEnabled, !self.talkEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
if self.talkEnabled {
Task { await TalkModeRuntime.shared.inputDeviceSelectionDidChange() }
}
}
}
}
var voiceWakeMicName: String {
didSet { self.ifNotPreview { AppDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
}
var voiceWakeLocaleID: String {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
var voiceWakeAdditionalLocaleIDs: [String] {
didSet { self.ifNotPreview { AppDefaults.standard.set(
self.voiceWakeAdditionalLocaleIDs,
forKey: voiceWakeAdditionalLocalesKey) } }
}
var voicePushToTalkEnabled: Bool {
didSet { self.ifNotPreview { AppDefaults.standard.set(
self.voicePushToTalkEnabled,
forKey: voicePushToTalkEnabledKey) } }
}
var voiceWakeTriggersTalkMode: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
var voiceWakeMeterActive = false
var talkEnabled: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
}
}
}
var talkRealtimeRelayEnabled = isTalkRealtimeRelayEnabled() {
didSet { self.persistTalkRealtimeRelayPreference(previousValue: oldValue) }
}
var talkPhaseSoundsEnabled: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey)
}
}
}
var talkShiftToStopEnabled: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey)
Task { TalkSpeechInterruptMonitor.shared.setEnabled(self.talkShiftToStopEnabled && self.talkEnabled) }
}
}
}
/// Gateway-provided UI accent color (hex). Optional; clients provide a default.
var seamColorHex: String?
var iconOverride: IconOverrideSelection {
didSet { self.ifNotPreview { AppDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
}
var isWorking: Bool = false
var earBoostActive: Bool = false
var blinkTick: Int = 0
var sendCelebrationTick: Int = 0
var heartbeatsEnabled: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
}
}
}
var connectionMode: ConnectionMode {
didSet {
self.ifNotPreview { AppDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
if oldValue != self.connectionMode {
self.markGatewayConfigDirty([.mode])
}
syncGatewayConfigIfNeeded()
}
}
var remoteTransport: RemoteTransport {
didSet {
if oldValue != self.remoteTransport {
let fields: Set<GatewayConfigField> = switch self.remoteTransport {
case .direct:
[.remoteTransport, .remoteUrl]
case .ssh:
[.remoteTransport, .remoteUrl, .remoteTarget, .remoteIdentity, .remoteHostKeyPolicy]
}
self.markGatewayConfigDirty(fields)
}
syncGatewayConfigIfNeeded()
}
}
var canvasEnabled: Bool {
didSet { self.ifNotPreview { AppDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
var quickChatEnabled: Bool {
didSet { self.ifNotPreview { AppDefaults.standard.set(self.quickChatEnabled, forKey: quickChatEnabledKey) } }
}
var cookieSyncEnabled: Bool {
didSet {
self.ifNotPreview { AppDefaults.standard.set(self.cookieSyncEnabled, forKey: cookieSyncEnabledKey) }
}
}
var cookieSyncIntoProfile: String {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.cookieSyncIntoProfile, forKey: cookieSyncIntoProfileKey)
}
}
}
var cookieSyncDomains: [String] {
didSet {
self.ifNotPreview { AppDefaults.standard.set(self.cookieSyncDomains, forKey: cookieSyncDomainsKey) }
}
}
var activeComputerPresenceEnabled: Bool {
didSet { self.scheduleActiveComputerPresenceUpdate() }
}
/// Tracks whether the Canvas panel is currently visible (not persisted).
var canvasPanelVisible: Bool = false
var peekabooBridgeEnabled: Bool {
didSet {
self.ifNotPreview {
AppDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
}
self.applyComputerControlHostState()
}
}
/// The selected provider owns the complete Computer Control execution surface.
/// Keep the unselected host stopped so one node execution never mixes backends.
func applyComputerControlHostState() {
self.ifNotPreview {
let computerControlEnabled = isComputerControlEnabled()
let provider = ComputerControlProvider.current()
let launchPlan = AppLaunchRuntimePlan.current
let peekabooBridgeEnabled = self.peekabooBridgeEnabled
self.computerControlHostGeneration &+= 1
let generation = self.computerControlHostGeneration
let predecessor = self.computerControlHostReconciliationTask
let task = Task { @MainActor [weak self] in
await predecessor?.value
guard let self, generation == self.computerControlHostGeneration else { return }
switch provider {
case .cua where computerControlEnabled:
await PeekabooBridgeHostCoordinator.shared.setEnabled(false)
guard generation == self.computerControlHostGeneration else { return }
await CuaDriverHostCoordinator.shared.setEnabled(true)
case .peekaboo:
if launchPlan.allowsCuaComputerControl {
await CuaDriverHostCoordinator.shared.setEnabled(false)
}
guard generation == self.computerControlHostGeneration else { return }
await PeekabooBridgeHostCoordinator.shared.setEnabled(
peekabooBridgeEnabled && computerControlEnabled)
case .cua:
await CuaDriverHostCoordinator.shared.setEnabled(false)
guard generation == self.computerControlHostGeneration else { return }
await PeekabooBridgeHostCoordinator.shared.setEnabled(false)
}
}
self.computerControlHostReconciliationTask = task
}
}
var remoteTarget: String {
didSet {
self.ifNotPreview { AppDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
if oldValue != self.remoteTarget {
self.markGatewayConfigDirty([.remoteTarget, .remoteUrl, .remoteHostKeyPolicy])
}
syncGatewayConfigIfNeeded()
}
}
var remoteUrl: String {
didSet {
if oldValue != self.remoteUrl {
self.markGatewayConfigDirty([.remoteUrl])
}
syncGatewayConfigIfNeeded()
}
}
var remoteToken: String {
didSet {
guard oldValue != self.remoteToken else { return }
self.markGatewayConfigDirty([.remoteToken])
self.remoteTokenUnsupported = false
syncGatewayConfigIfNeeded()
}
}
var remoteTokenDirty: Bool {
self.dirtyGatewayConfigFields.contains(.remoteToken)
}
var gatewayConfigConflict: GatewayConfigConflict? {
let fields = GatewayConfigField.allCases.filter(self.conflictedGatewayConfigFields.contains)
guard !fields.isEmpty else { return nil }
let fieldNames = fields.map(\.displayName)
let fieldList = ListFormatter.localizedString(byJoining: fieldNames)
let message = String(localized: """
These settings changed outside the app while you were editing: \(fieldList). \
Choose which version to keep.
""")
return GatewayConfigConflict(
fields: fields,
fieldNames: fieldNames,
message: message)
}
private(set) var remoteTokenUnsupported = false
var remoteIdentity: String {
didSet {
self.ifNotPreview { AppDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) }
if oldValue != self.remoteIdentity {
self.markGatewayConfigDirty([.remoteIdentity])
}
syncGatewayConfigIfNeeded()
}
}
var remoteProjectRoot: String {
didSet { self.ifNotPreview { AppDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
}
var remoteCliPath: String {
didSet { self.ifNotPreview { AppDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } }
}
init(
preview: Bool = false,
gatewayConfigSaver: @escaping ([String: Any]) -> Bool = { OpenClawConfigFile.saveDict($0) })
{
let isPreview = preview || ProcessInfo.processInfo.isRunningTests
self.isPreview = isPreview
self.bundleLocationAllowsPersistentIntegration =
!AppProfile.current.isActive &&
(isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration())
self.gatewayConfigSaver = gatewayConfigSaver
let onboardingSeen = AppDefaults.standard.bool(forKey: onboardingSeenKey)
self.isPaused = AppLaunchRuntimePlan.current.resolvePaused(AppDefaults.standard.bool(forKey: pauseDefaultsKey))
self.launchAtLogin = false
self.onboardingSeen = onboardingSeen
self.debugPaneEnabled = AppDefaults.standard.bool(forKey: debugPaneEnabledKey)
self.nativeSettingsPanesEnabled = AppDefaults.standard.bool(forKey: nativeSettingsPanesEnabledKey)
let savedVoiceWake = AppDefaults.standard.bool(forKey: swabbleEnabledKey)
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
self.swabbleTriggerWords = AppDefaults.standard
.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
self.voiceWakeTriggerChime = Self.loadChime(
key: voiceWakeTriggerChimeKey,
fallback: .system(name: "Glass"))
self.voiceWakeSendChime = Self.loadChime(
key: voiceWakeSendChimeKey,
fallback: .system(name: "Glass"))
if let storedIconAnimations = AppDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
self.iconAnimationsEnabled = storedIconAnimations
} else {
self.iconAnimationsEnabled = true
AppDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
}
if let storedShowDockIcon = AppDefaults.standard.object(forKey: showDockIconKey) as? Bool {
self.showDockIcon = storedShowDockIcon
} else {
self.showDockIcon = true
AppDefaults.standard.set(true, forKey: showDockIconKey)
}
self.voiceWakeMicID = AppDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
self.voiceWakeMicName = AppDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
self.voiceWakeLocaleID = AppDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = AppDefaults.standard
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
self.voicePushToTalkEnabled = AppDefaults.standard
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
self.voiceWakeTriggersTalkMode = AppDefaults.standard
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
self.talkEnabled = AppDefaults.standard.bool(forKey: talkEnabledKey)
if let storedPhaseSounds = AppDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
self.talkPhaseSoundsEnabled = storedPhaseSounds
} else {
self.talkPhaseSoundsEnabled = true
AppDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey)
}
if let storedShiftToStop = AppDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool {
self.talkShiftToStopEnabled = storedShiftToStop
} else {
self.talkShiftToStopEnabled = true
AppDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey)
}
self.seamColorHex = nil
if let storedHeartbeats = AppDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
self.heartbeatsEnabled = storedHeartbeats
} else {
self.heartbeatsEnabled = true
AppDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
}
if let storedOverride = AppDefaults.standard.string(forKey: iconOverrideKey),
let selection = IconOverrideSelection(rawValue: storedOverride)
{
self.iconOverride = selection
} else {
self.iconOverride = .system
AppDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
}
let configRoot = OpenClawConfigFile.loadDict()
self.lastConfigFingerprint = Self.configFingerprint(configRoot)
self.lastObservedGatewayConfig = Self.gatewayConfigSnapshot(configRoot)
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
let configRemoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
let configRemoteTransport = configRemoteResolution.transport
let configRemoteUrl = configRemoteResolution.directURL?.absoluteString
?? GatewayRemoteConfig.resolveUrlString(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
self.connectionMode = resolvedConnectionMode
let configRemote = (configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
let hasConfigRemoteTarget = configRemote?.keys.contains("sshTarget") == true
let configRemoteTarget = (configRemote?["sshTarget"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let storedRemoteTarget = AppDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if resolvedConnectionMode == .remote,
hasConfigRemoteTarget
{
self.remoteTarget = configRemoteTarget
} else if resolvedConnectionMode == .remote,
configRemoteTransport != .direct,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl),
!LoopbackHost.isLoopbackHost(host)
{
self.remoteTarget = "\(NSUserName())@\(host)"
} else {
self.remoteTarget = storedRemoteTarget
}
self.remoteUrl = configRemoteUrl ?? ""
self.remoteToken = configRemoteToken.textFieldValue
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
let hasConfigRemoteIdentity = configRemote?.keys.contains("sshIdentity") == true
let configRemoteIdentity = (configRemote?["sshIdentity"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.remoteIdentity = hasConfigRemoteIdentity
? configRemoteIdentity
: AppDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty ?? ""
self.remoteProjectRoot = AppDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
self.remoteCliPath = AppDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
self.canvasEnabled = AppDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.quickChatEnabled = AppDefaults.standard.object(forKey: quickChatEnabledKey) as? Bool ?? true
(self.cookieSyncEnabled, self.cookieSyncIntoProfile, self.cookieSyncDomains) = Self.loadCookieSyncDefaults()
self.activeComputerPresenceEnabled = Self.resolveActiveComputerPresenceEnabled()
self.peekabooBridgeEnabled = AppLaunchRuntimePlan.current.resolvePeekabooBridgeEnabled(
AppDefaults.standard.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true)
if !self.isPreview, !AppProfile.current.isActive {
Task.detached(priority: .utility) { [weak self] in
let current = await LaunchAgentManager.status()
await MainActor.run { [weak self] in self?.hydrateLaunchAtLogin(current) }
}
} else if !self.isPreview, AppProfile.current.isActive {
Self.logger.info("login-agent status skipped (unavailable under app profile)")
}
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = false
}
if self.talkEnabled, !PermissionManager.voiceWakePermissionsGranted() {
self.talkEnabled = false
}
if !self.isPreview {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
}
if !self.isPreview {
self.reconcilePreferredGatewayRouteBinding()
}
self.isInitializing = false
if !self.isPreview {
self.startConfigWatcher()
}
}
private func hydrateLaunchAtLogin(_ enabled: Bool) {
// Reading launchd state must not rewrite a valid plist with this process's transient bundle path.
self.isHydratingLaunchAtLogin = true
self.launchAtLogin = enabled
self.isHydratingLaunchAtLogin = false
}
@MainActor
deinit {
self.gatewayConfigSyncTask?.cancel()
self.configWatcher?.stop()
}
private static func remoteHost(from urlString: String?) -> String? {
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty,
let url = URL(string: raw),
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return nil
}
return host
}
private static func sanitizeSSHTarget(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
return trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private static func sshTunnelGatewayUrl(existingUrl: String?, expectedRemoteHost: String?) -> String {
let fallback = "ws://127.0.0.1:18789"
let trimmed = existingUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty,
let url = URL(string: trimmed),
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return fallback
}
let preservePort: Bool = if LoopbackHost.isLoopbackHost(host) {
true
} else if let expectedRemoteHost {
OpenClawConfigFile.canonicalHostForComparison(host) ==
OpenClawConfigFile.canonicalHostForComparison(expectedRemoteHost)
} else {
false
}
guard preservePort else { return fallback }
return "ws://127.0.0.1:\(url.port ?? 18789)"
}
private static func updateGatewayString(
_ dictionary: inout [String: Any],
key: String,
value: String?) -> Bool
{
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
guard dictionary[key] != nil else { return false }
dictionary.removeValue(forKey: key)
return true
}
if (dictionary[key] as? String) != trimmed {
dictionary[key] = trimmed
return true
}
return false
}
}
extension AppState {
private func markGatewayConfigDirty(_ fields: Set<GatewayConfigField>) {
guard !self.isInitializing, !self.isApplyingGatewayConfig else { return }
self.dirtyGatewayConfigFields.formUnion(fields)
}
private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) {
let nextToken = tokenValue.textFieldValue
let unsupported = tokenValue.isUnsupportedNonString
if self.remoteToken != nextToken {
self.remoteToken = nextToken
}
self.remoteTokenUnsupported = unsupported
}
private static func updatedRemoteGatewayConfig(
current: [String: Any],
draft: RemoteGatewayConfigDraft) -> (remote: [String: Any], changed: Bool)
{
var remote = current
var changed = false
switch draft.transport {
case .direct:
if draft.dirtyFields.contains(.remoteTransport) {
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.direct.rawValue) || changed
}
if draft.dirtyFields.contains(.remoteUrl) {
let trimmedUrl = draft.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed
} else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) {
changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed
}
}
case .ssh:
if draft.dirtyFields.contains(.remoteTransport) {
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.ssh.rawValue) || changed
}
let existingTarget = Self.sanitizeSSHTarget(remote["sshTarget"] as? String ?? "")
let sanitizedTarget = Self.sanitizeSSHTarget(draft.remoteTarget)
let expectedRemoteHost = CommandResolver.parseSSHTarget(sanitizedTarget)?.host ?? draft.remoteHost
if draft.dirtyFields.contains(.remoteUrl) {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let desiredUrl = Self.sshTunnelGatewayUrl(
existingUrl: existingUrl,
expectedRemoteHost: expectedRemoteHost)
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
}
if draft.dirtyFields.contains(.remoteTarget) {
changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed
}
if draft.dirtyFields.contains(.remoteIdentity) {
changed = Self.updateGatewayString(
&remote,
key: "sshIdentity",
value: draft.remoteIdentity) || changed
}
if draft.dirtyFields.contains(.remoteHostKeyPolicy), existingTarget != sanitizedTarget {
changed = Self.updateGatewayString(
&remote,
key: "sshHostKeyPolicy",
value: "strict") || changed
}
}
if draft.dirtyFields.contains(.remoteToken) {
changed = Self.updateGatewayString(&remote, key: "token", value: draft.remoteToken) || changed
}
return (remote, changed)
}
private func startConfigWatcher() {
let configUrl = OpenClawConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
Task { @MainActor in
self?.applyConfigFromDisk()
}
}
self.configWatcher?.start()
}
private func applyConfigFromDisk() {
let root = OpenClawConfigFile.loadDict()
let fingerprint = Self.configFingerprint(root)
guard fingerprint != self.lastConfigFingerprint else { return }
self.lastConfigFingerprint = fingerprint
self.applyConfigOverrides(root)
MacNodeModeCoordinator.shared.refresh()
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
}
private static func configFingerprint(_ root: [String: Any]) -> Data? {
var comparableRoot = root
if var meta = comparableRoot["meta"] as? [String: Any] {
// Writers refresh these bookkeeping fields without changing runtime configuration.
// Ignoring them prevents metadata churn from restarting gateway and node routing.
meta.removeValue(forKey: "lastTouchedAt")
meta.removeValue(forKey: "lastTouchedVersion")
if meta.isEmpty {
comparableRoot.removeValue(forKey: "meta")
} else {
comparableRoot["meta"] = meta
}
}
return try? JSONSerialization.data(withJSONObject: comparableRoot, options: [.sortedKeys])
}
private static func gatewayConfigSnapshot(_ root: [String: Any]) -> GatewayConfigSnapshot {
let gateway = root["gateway"] as? [String: Any]
let remote = gateway?["remote"] as? [String: Any]
var values: [GatewayConfigField: GatewayConfigValue] = [:]
for field in GatewayConfigField.allCases {
let value = if let remoteKey = field.remoteKey {
remote?[remoteKey]
} else {
gateway?["mode"]
}
guard let value else {
values[field] = .missing
continue
}
let data = try? JSONSerialization.data(
withJSONObject: ["value": value],
options: [.sortedKeys])
values[field] = data.map(GatewayConfigValue.json) ?? .missing
}
return GatewayConfigSnapshot(values: values)
}
private func gatewayConfigDraft() -> GatewayConfigSyncDraft {
GatewayConfigSyncDraft(
connectionMode: self.connectionMode,
remoteTransport: self.remoteTransport,
remoteTarget: self.remoteTarget,
remoteIdentity: self.remoteIdentity,
remoteUrl: self.remoteUrl,
remoteToken: self.remoteToken,
dirtyFields: self.dirtyGatewayConfigFields)
}
private static func gatewayConfigFieldsPersisted(by draft: GatewayConfigSyncDraft) -> Set<GatewayConfigField> {
var fields = draft.dirtyFields.intersection([.mode, .remoteTransport, .remoteUrl, .remoteToken])
if draft.remoteTransport == .ssh {
fields.formUnion(draft.dirtyFields.intersection([
.remoteTarget,
.remoteIdentity,
.remoteHostKeyPolicy,
]))
}
return fields
}
private func reconcileGatewayConfigOwnership(_ root: [String: Any]) -> Set<GatewayConfigField> {
let diskSnapshot = Self.gatewayConfigSnapshot(root)
let desiredRoot = Self.syncedGatewayRoot(
currentRoot: root,
draft: self.gatewayConfigDraft()).root
let desiredSnapshot = Self.gatewayConfigSnapshot(desiredRoot)
let priorConflicts = self.conflictedGatewayConfigFields
// Dirty fields retain the user's pending UI value. A disk value that moved
// from the last observed baseline is a conflict, never write authorization.
let persistedFields = Self.gatewayConfigFieldsPersisted(by: self.gatewayConfigDraft())
var externallyPersistedFields: Set<GatewayConfigField> = []
for field in persistedFields {
if diskSnapshot[field] == desiredSnapshot[field] {
externallyPersistedFields.insert(field)
} else if diskSnapshot[field] != self.lastObservedGatewayConfig[field] {
self.conflictedGatewayConfigFields.insert(field)
}
}
self.dirtyGatewayConfigFields.subtract(externallyPersistedFields)
self.conflictedGatewayConfigFields.subtract(externallyPersistedFields)
self.conflictedGatewayConfigFields.formIntersection(self.dirtyGatewayConfigFields)
self.lastObservedGatewayConfig = diskSnapshot
return priorConflicts
}
private func applyGatewayConfigView(
_ root: [String: Any],
forcing forcedFields: Set<GatewayConfigField> = [])
{
let gateway = root["gateway"] as? [String: Any]
let remote = gateway?["remote"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let remoteToken = GatewayRemoteConfig.resolveTokenValue(root: root)
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let remoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
let remoteTransport = remoteResolution.transport
let desiredMode: ConnectionMode? = switch modeRaw {
case "local":
.local
case "remote":
.remote
case "unconfigured":
.unconfigured
default:
nil
}
self.isApplyingGatewayConfig = true
self.applyGatewayConfigMode(
desiredMode,
hasRemoteUrl: hasRemoteUrl,
forcing: forcedFields.contains(.mode))
let shouldApplyTransport = forcedFields.contains(.remoteTransport) ||
!self.dirtyGatewayConfigFields.contains(.remoteTransport)
if shouldApplyTransport, remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
if forcedFields.contains(.remoteUrl) || !self.dirtyGatewayConfigFields.contains(.remoteUrl) {
let remoteUrlText = remoteResolution.directURL?.absoluteString ?? remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
}
if forcedFields.contains(.remoteToken) || !self.dirtyGatewayConfigFields.contains(.remoteToken) {
self.applyRemoteTokenState(remoteToken)
}
let targetMode = desiredMode ?? self.connectionMode
if forcedFields.contains(.remoteTarget) {
let configuredTarget = Self.sanitizeSSHTarget(remote?["sshTarget"] as? String ?? "")
if configuredTarget != Self.sanitizeSSHTarget(self.remoteTarget) {
self.remoteTarget = configuredTarget
}
} else if !self.dirtyGatewayConfigFields.contains(.remoteTarget),
targetMode == .remote,
remoteTransport != .direct
{
let hasConfiguredTarget = remote?.keys.contains("sshTarget") == true
let configuredTarget = Self.sanitizeSSHTarget(remote?["sshTarget"] as? String ?? "")
if hasConfiguredTarget, configuredTarget != Self.sanitizeSSHTarget(self.remoteTarget) {
self.remoteTarget = configuredTarget
} else if !hasConfiguredTarget,
let host = AppState.remoteHost(from: remoteUrl),
!LoopbackHost.isLoopbackHost(host)
{
self.updateRemoteTarget(host: host)
}
}
if forcedFields.contains(.remoteIdentity) {
let configuredIdentity = (remote?["sshIdentity"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if configuredIdentity != self.remoteIdentity {
self.remoteIdentity = configuredIdentity
}
} else if !self.dirtyGatewayConfigFields.contains(.remoteIdentity),
remote?.keys.contains("sshIdentity") == true
{
let configuredIdentity = (remote?["sshIdentity"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if configuredIdentity != self.remoteIdentity {
self.remoteIdentity = configuredIdentity
}
}
self.isApplyingGatewayConfig = false
}
private func applyGatewayConfigMode(
_ desiredMode: ConnectionMode?,
hasRemoteUrl: Bool,
forcing: Bool)
{
guard forcing || !self.dirtyGatewayConfigFields.contains(.mode) else { return }
let nextMode = desiredMode ?? (hasRemoteUrl ? .remote : forcing ? .unconfigured : nil)
if let nextMode, nextMode != self.connectionMode {
self.connectionMode = nextMode
}
}
private func applyConfigOverrides(_ root: [String: Any]) {
advanceGatewayRoutingGeneration()
let previousSelection = self.gatewaySelectionSnapshot()
let priorConflicts = self.reconcileGatewayConfigOwnership(root)
self.applyGatewayConfigView(root)
if self.gatewaySelectionSnapshot() != previousSelection {
// Discovery ids describe one concrete endpoint. An external config
// edit has no discovery selection event to update that ownership,
// so retaining the old id would apply its activation lease to the
// replacement Gateway.
GatewayDiscoveryPreferences.setPreferredStableID(nil)
}
let newConflicts = self.conflictedGatewayConfigFields.subtracting(priorConflicts)
if !newConflicts.isEmpty {
let names = self.conflictedGatewayConfigFields.map(\.rawValue).sorted().joined(separator: ", ")
Self.logger.warning("gateway config sync conflict fields=\(names)")
}
if !self.conflictedGatewayConfigFields.isEmpty {
self.setGatewayConfigSyncState(.failed)
} else if !priorConflicts.isEmpty, self.dirtyGatewayConfigFields.isEmpty {
self.setGatewayConfigSyncState(.current)
}
}
private func gatewaySelectionSnapshot() -> GatewaySelectionSnapshot {
let remoteUrl = GatewayRemoteConfig.normalizeGatewayUrlString(self.remoteUrl) ??
self.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
return GatewaySelectionSnapshot(
connectionMode: self.connectionMode,
remoteTransport: self.remoteTransport,
remoteUrl: remoteUrl,
remoteTarget: Self.sanitizeSSHTarget(self.remoteTarget))
}
@discardableResult
private func reconcilePreferredGatewayRouteBinding() -> Bool {
let binding = GatewayDiscoveryPreferences.routeBinding(
connectionMode: self.connectionMode,
remoteTransport: self.remoteTransport,
remoteURL: self.remoteUrl,
remoteTarget: self.remoteTarget)
return GatewayDiscoveryPreferences.clearPreferredStableIDIfRouteBindingMismatch(binding)
}
private func updateRemoteTarget(host: String) {
let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return }
let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
let port = parsed.port
let assembled: String = if let user {
port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
} else {
port == 22 ? host : "\(host):\(port)"
}
if assembled != self.remoteTarget {
self.remoteTarget = assembled
}
}
func startVoiceEars() {
self.earBoostActive = true
}
func stopVoiceEars() {
self.earBoostActive = false
}
func blinkOnce() {
self.blinkTick &+= 1
}
func celebrateSend() {
self.sendCelebrationTick &+= 1
}
func setVoiceWakeEnabled(_ enabled: Bool) async {
guard voiceWakeSupported else {
self.swabbleEnabled = false
return
}
guard !enabled || SpeechRecognitionRequestPolicy.supportsPassiveVoiceWake(
localeID: self.voiceWakeLocaleID)
else {
self.swabbleEnabled = false
return
}
self.swabbleEnabled = enabled
guard !self.isPreview else { return }
if !enabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
return
}
if PermissionManager.voiceWakePermissionsGranted() {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
return
}
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
self.swabbleEnabled = granted
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
func setTalkEnabled(_ enabled: Bool) async {
guard voiceWakeSupported else {
self.talkEnabled = false
await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled")
return
}
self.talkEnabled = enabled
guard !self.isPreview else { return }
if !enabled {
await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled")
return
}
if PermissionManager.voiceWakePermissionsGranted() {
await GatewayConnection.shared.talkMode(enabled: true, phase: "enabled")
return
}
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
self.talkEnabled = granted
await GatewayConnection.shared.talkMode(enabled: granted, phase: granted ? "enabled" : "denied")
}
// MARK: - Global wake words sync (Gateway-owned)
func applyGlobalVoiceWakeTriggers(_ triggers: [String]) {
self.suppressVoiceWakeGlobalSync = true
self.swabbleTriggerWords = triggers
self.suppressVoiceWakeGlobalSync = false
}
private func scheduleVoiceWakeGlobalSyncIfNeeded() {
guard !self.suppressVoiceWakeGlobalSync else { return }
let sanitized = sanitizeVoiceWakeTriggers(swabbleTriggerWords)
self.voiceWakeGlobalSyncScheduler.schedule(sanitized)
}
// MARK: - Chime persistence
private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime {
guard let data = AppDefaults.standard.data(forKey: key) else { return fallback }
if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) {
return decoded
}
return fallback
}
private func storeChime(_ chime: VoiceWakeChime, key: String) {
guard let data = try? JSONEncoder().encode(chime) else { return }
AppDefaults.standard.set(data, forKey: key)
}
}
// MARK: - App state helpers
extension AppState {
private func ifNotPreview(_ action: () -> Void) {
guard !self.isPreview else { return }
action()
}
static func shouldPersistLaunchAtLoginChange(
isInitializing: Bool,
isHydrating: Bool,
isEnabling: Bool,
bundleLocationAllowsPersistentIntegration: Bool) -> Bool
{
!isInitializing && !isHydrating && (!isEnabling || bundleLocationAllowsPersistentIntegration)
}
}
extension AppState {
private static func syncedGatewayRoot(
currentRoot: [String: Any],
draft: GatewayConfigSyncDraft,
remoteTLSFingerprintUpdate: RemoteTLSFingerprintUpdate = .preserve)
-> (root: [String: Any], changed: Bool)
{
var root = currentRoot
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let desiredMode: String? = switch draft.connectionMode {
case .local:
"local"
case .remote:
"remote"
case .unconfigured:
nil
}
if draft.dirtyFields.contains(.mode) {
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
}
var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false
if draft.dirtyFields.contains(where: { $0.remoteKey != nil }) {
let remoteHost = CommandResolver.parseSSHTarget(draft.remoteTarget)?.host
let updated = Self.updatedRemoteGatewayConfig(
current: remote,
draft: .init(
transport: draft.remoteTransport,
remoteUrl: draft.remoteUrl,
remoteHost: remoteHost,
remoteTarget: draft.remoteTarget,
remoteIdentity: draft.remoteIdentity,
remoteToken: draft.remoteToken,
dirtyFields: draft.dirtyFields))
remote = updated.remote
remoteChanged = updated.changed
}
if case let .replace(fingerprint) = remoteTLSFingerprintUpdate {
remoteChanged = Self.updateGatewayString(
&remote,
key: "tlsFingerprint",
value: fingerprint) || remoteChanged
}
if remoteChanged {
if remote.isEmpty {
gateway.removeValue(forKey: "remote")
} else {
gateway["remote"] = remote
}
changed = true
}
guard changed else { return (currentRoot, false) }
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
return (root, true)
}
private func syncGatewayConfigIfNeeded() {
guard !self.isApplyingGatewayConfig else { return }
self.advanceGatewayRoutingGeneration()
guard self.gatewayConfigSyncIsEnabled, !self.isInitializing else { return }
self.setGatewayConfigSyncState(.pending)
self.gatewayConfigSyncTask?.cancel()
self.gatewayConfigSyncTask = Task { @MainActor in
guard !Task.isCancelled else { return }
self.syncGatewayConfigNow()
}
}
private var gatewayConfigSyncIsEnabled: Bool {
#if DEBUG
if self.gatewayConfigSyncEnabledForTesting {
return true
}
#endif
return !self.isPreview
}
var gatewayConfigIsCurrentForRouting: Bool {
self.gatewayConfigSyncState == .current
}
private func setGatewayConfigSyncState(_ state: GatewayConfigSyncState) {
guard self.gatewayConfigSyncState != state else { return }
self.gatewayConfigSyncState = state
self.advanceGatewayRoutingGeneration()
guard !self.isPreview, state != .pending else { return }
// Failed persistence must retire the old endpoint; recovery must publish
// the newly canonical route. Requests also re-check this state directly.
Task { await GatewayEndpointStore.shared.refresh() }
}
private func advanceGatewayRoutingGeneration() {
self.gatewayRoutingGeneration &+= 1
}
private static func gatewayDraftCanPersist(_ draft: GatewayConfigSyncDraft) -> Bool {
let ownsRemoteRoute = draft.dirtyFields.contains(.remoteTransport) ||
draft.dirtyFields.contains(.remoteUrl) ||
draft.dirtyFields.contains(.remoteTarget)
guard draft.connectionMode == .remote || ownsRemoteRoute else { return true }
switch draft.remoteTransport {
case .direct:
return GatewayRemoteConfig.normalizeGatewayUrl(draft.remoteUrl) != nil
case .ssh:
let target = Self.sanitizeSSHTarget(draft.remoteTarget)
return !target.isEmpty &&
CommandResolver.sshTargetValidationMessage(target) == nil &&
CommandResolver.parseSSHTarget(target) != nil
}
}
@discardableResult
func syncGatewayConfigNow() -> Bool {
self.syncGatewayConfigNow(remoteTLSFingerprintUpdate: .preserve)
}
@discardableResult
func syncGatewayConfigNow(remoteTLSFingerprint: String?) -> Bool {
self.syncGatewayConfigNow(remoteTLSFingerprintUpdate: .replace(remoteTLSFingerprint))
}
private func syncGatewayConfigNow(remoteTLSFingerprintUpdate: RemoteTLSFingerprintUpdate) -> Bool {
guard self.gatewayConfigSyncIsEnabled, !self.isInitializing else { return true }
self.setGatewayConfigSyncState(.pending)
let currentRoot = OpenClawConfigFile.loadDict()
self.applyConfigOverrides(currentRoot)
guard self.conflictedGatewayConfigFields.isEmpty else { return false }
let draft = self.gatewayConfigDraft()
guard Self.gatewayDraftCanPersist(draft) else {
self.setGatewayConfigSyncState(.failed)
return false
}
// Keep app-only connection settings local to avoid overwriting remote gateway config.
let synced = Self.syncedGatewayRoot(
currentRoot: currentRoot,
draft: draft,
remoteTLSFingerprintUpdate: remoteTLSFingerprintUpdate)
guard synced.changed else {
self.acknowledgeGatewayConfigPersistence(draft.dirtyFields, root: currentRoot)
self.setGatewayConfigSyncState(.current)
return true
}
guard self.gatewayConfigSaver(synced.root) else {
self.setGatewayConfigSyncState(.failed)
Self.logger.warning("gateway config sync rejected to protect persisted gateway auth/mode")
return false
}
self.acknowledgeGatewayConfigPersistence(draft.dirtyFields, root: synced.root)
self.lastConfigFingerprint = Self.configFingerprint(synced.root)
self.setGatewayConfigSyncState(.current)
NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil)
return true
}
private func acknowledgeGatewayConfigPersistence(
_ fields: Set<GatewayConfigField>,
root: [String: Any])
{
let persistedFields = Self.gatewayConfigFieldsPersisted(by: self.gatewayConfigDraft())
.intersection(fields)
self.dirtyGatewayConfigFields.subtract(persistedFields)
self.conflictedGatewayConfigFields.subtract(persistedFields)
self.lastObservedGatewayConfig = Self.gatewayConfigSnapshot(root)
}
@discardableResult
func useFileGatewayConfigConflict() -> Bool {
let fields = self.conflictedGatewayConfigFields
guard !fields.isEmpty else { return true }
let priorDraft = self.gatewayConfigDraft()
let priorRemoteTokenUnsupported = self.remoteTokenUnsupported
let root = OpenClawConfigFile.loadDict()
self.dirtyGatewayConfigFields.subtract(fields)
self.conflictedGatewayConfigFields.subtract(fields)
self.applyGatewayConfigView(root, forcing: fields)
guard self.syncGatewayConfigNow() else {
self.restoreGatewayConfigDraft(priorDraft, fields: fields)
self.remoteTokenUnsupported = priorRemoteTokenUnsupported
self.dirtyGatewayConfigFields.formUnion(fields)
self.conflictedGatewayConfigFields.formUnion(fields)
self.setGatewayConfigSyncState(.failed)
return false
}
return true
}
@discardableResult
func keepGatewayConfigEdits() -> Bool {
let fields = self.conflictedGatewayConfigFields
guard !fields.isEmpty else { return true }
self.conflictedGatewayConfigFields.subtract(fields)
self.dirtyGatewayConfigFields.formUnion(fields)
guard self.syncGatewayConfigNow(),
self.dirtyGatewayConfigFields.isDisjoint(with: fields)
else {
self.conflictedGatewayConfigFields.formUnion(fields)
self.setGatewayConfigSyncState(.failed)
return false
}
return true
}
private func restoreGatewayConfigDraft(
_ draft: GatewayConfigSyncDraft,
fields: Set<GatewayConfigField>)
{
self.isApplyingGatewayConfig = true
if fields.contains(.mode) {
self.connectionMode = draft.connectionMode
}
if fields.contains(.remoteTransport) {
self.remoteTransport = draft.remoteTransport
}
if fields.contains(.remoteUrl) {
self.remoteUrl = draft.remoteUrl
}
if fields.contains(.remoteTarget) {
self.remoteTarget = draft.remoteTarget
}
if fields.contains(.remoteIdentity) {
self.remoteIdentity = draft.remoteIdentity
}
if fields.contains(.remoteToken) {
self.remoteToken = draft.remoteToken
}
self.isApplyingGatewayConfig = false
}
}
extension AppState {
private static func loadCookieSyncDefaults() -> (Bool, String, [String]) {
let defaults = AppDefaults.standard
return (
defaults.object(forKey: cookieSyncEnabledKey) as? Bool ?? false,
defaults.string(forKey: cookieSyncIntoProfileKey) ?? "imported",
defaults.stringArray(forKey: cookieSyncDomainsKey) ?? [])
}
static func resolveActiveComputerPresenceEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool {
defaults.bool(forKey: activeComputerPresenceEnabledKey)
}
static func activeComputerPresenceUpdateIsCurrent(
capturedGeneration: UInt64,
currentGeneration: UInt64,
capturedEnabled: Bool,
currentEnabled: Bool,
isCancelled: Bool) -> Bool
{
!isCancelled &&
capturedGeneration == currentGeneration &&
capturedEnabled == currentEnabled
}
private func scheduleActiveComputerPresenceUpdate() {
self.ifNotPreview {
let enabled = self.activeComputerPresenceEnabled
AppDefaults.standard.set(enabled, forKey: activeComputerPresenceEnabledKey)
PresenceReporter.shared.sendImmediate(reason: "activity-sharing-changed")
self.activeComputerPresenceUpdateGeneration &+= 1
let generation = self.activeComputerPresenceUpdateGeneration
self.activeComputerPresenceTask?.cancel()
// Apply the newest privacy choice immediately. An older network-bound
// enable may resume later, but the reporter's generation guard contains it.
self.activeComputerPresenceTask = Task { @MainActor [weak self] in
guard let self,
Self.activeComputerPresenceUpdateIsCurrent(
capturedGeneration: generation,
currentGeneration: self.activeComputerPresenceUpdateGeneration,
capturedEnabled: enabled,
currentEnabled: self.activeComputerPresenceEnabled,
isCancelled: Task.isCancelled)
else { return }
await MacNodeModeCoordinator.shared.setPresenceActivityReportingEnabled(enabled)
}
}
}
enum ConnectionMode: String {
case unconfigured
case local
case remote
}
enum RemoteTransport: String {
case ssh
case direct
}
}
#if DEBUG
@MainActor
extension AppState {
static func _testConfigFingerprint(_ root: [String: Any]) -> Data? {
self.configFingerprint(root)
}
static func _testUpdatedRemoteGatewayConfig(
current: [String: Any],
draft: RemoteGatewayConfigDraft) -> [String: Any]
{
self.updatedRemoteGatewayConfig(
current: current,
draft: draft).remote
}
static func _testSyncedGatewayRoot(
currentRoot: [String: Any],
draft: GatewayConfigSyncDraft) -> [String: Any]
{
self.syncedGatewayRoot(
currentRoot: currentRoot,
draft: draft).root
}
static func _testGatewayDraftCanPersist(_ draft: GatewayConfigSyncDraft) -> Bool {
self.gatewayDraftCanPersist(draft)
}
func _testApplyConfigOverrides(_ root: [String: Any]) {
self.applyConfigOverrides(root)
}
func _testApplyConfigFromDisk() {
self.applyConfigFromDisk()
}
func _testEnableGatewayConfigSync() {
self.gatewayConfigSyncEnabledForTesting = true
}
func _testAwaitGatewayConfigSync() async {
await self.gatewayConfigSyncTask?.value
}
var _testGatewayConfigIsCurrentForRouting: Bool {
self.gatewayConfigIsCurrentForRouting
}
var _testDirtyGatewayConfigFields: [String] {
self.dirtyGatewayConfigFields.map(\.rawValue).sorted()
}
var _testConflictedGatewayConfigFields: [String] {
self.conflictedGatewayConfigFields.map(\.rawValue).sorted()
}
@discardableResult
func _testReconcilePreferredGatewayRouteBinding() -> Bool {
self.reconcilePreferredGatewayRouteBinding()
}
}
#endif
@MainActor
enum AppStateStore {
static let shared = AppState()
static func updateLaunchAtLogin(enabled: Bool) {
Task.detached(priority: .utility) {
await LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath)
}
}
}
@MainActor
enum AppActivationPolicy {
static func apply(showDockIcon: Bool) {
_ = showDockIcon
DockIconManager.shared.updateDockVisibility()
}
}