mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-04 22:01:15 +00:00
* feat(macos): add "Trigger Talk Mode" option to Voice Wake settings When enabled, detecting a wake phrase activates Talk Mode (full voice conversation: STT -> LLM -> TTS playback) instead of sending a text message to the chat. Enables hands-free voice assistant UX. Implementation: - Constants.swift: new `openclaw.voiceWakeTriggersTalkMode` defaults key - AppState.swift: new property with UserDefaults persistence + runtime refresh on change so the toggle takes effect immediately - VoiceWakeSettings.swift: "Trigger Talk Mode" toggle under Voice Wake, disabled when Voice Wake is off - VoiceWakeRuntime.swift: `beginCapture` checks `triggersTalkMode` and activates Talk Mode directly, skipping the capture/overlay/forward flow. Pauses the wake listener via `pauseForPushToTalk()` to prevent two audio pipelines competing on the mic. - TalkModeController.swift: resumes voice wake listener when Talk Mode exits by calling `VoiceWakeRuntime.refresh`, mirroring PTT teardown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback - TalkModeController: move VoiceWakeRuntime.refresh() after TalkModeRuntime.setEnabled(false) completes, preventing mic contention race where wake listener restarts before Talk Mode finishes tearing down its audio engine (P1) - VoiceWakeRuntime: remove redundant haltRecognitionPipeline() before pauseForPushToTalk() which already calls it via stop() (P2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resume wake listener based on enabled state, not toggle value Check swabbleEnabled instead of voiceWakeTriggersTalkMode when deciding whether to resume the wake listener after Talk Mode exits. This ensures the paused listener resumes even if the user toggled "Trigger Talk Mode" off during an active session. (P2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: play trigger chime on Talk Mode activation + send chime before TTS - VoiceWakeRuntime: play the configured trigger chime in the triggersTalkMode branch before pausing the wake listener. The early return was skipping the chime that plays in the normal capture flow. - TalkModeRuntime: play the Voice Wake "send" chime before TTS playback starts, giving audible feedback that the AI is about to respond. Talk Mode never used this chime despite it being configurable in settings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move send chime to transcript finalization (on send, not on reply) The send chime now plays when the user's speech is finalized and about to be sent to the AI, not when the TTS response starts. This matches the semantics of "send sound" -- it confirms your input was captured. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
858 lines
30 KiB
Swift
858 lines
30 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Observation
|
|
import ServiceManagement
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class AppState {
|
|
private let isPreview: Bool
|
|
private var isInitializing = true
|
|
private var isApplyingRemoteTokenConfig = false
|
|
private var configWatcher: ConfigFileWatcher?
|
|
private var suppressVoiceWakeGlobalSync = false
|
|
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
|
|
|
private func ifNotPreview(_ action: () -> Void) {
|
|
guard !self.isPreview else { return }
|
|
action()
|
|
}
|
|
|
|
enum ConnectionMode: String {
|
|
case unconfigured
|
|
case local
|
|
case remote
|
|
}
|
|
|
|
enum RemoteTransport: String {
|
|
case ssh
|
|
case direct
|
|
}
|
|
|
|
var isPaused: Bool {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
|
}
|
|
|
|
var launchAtLogin: Bool {
|
|
didSet {
|
|
guard !self.isInitializing else { return }
|
|
self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } }
|
|
}
|
|
}
|
|
|
|
var onboardingSeen: Bool {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) }
|
|
}
|
|
}
|
|
|
|
var debugPaneEnabled: Bool {
|
|
didSet {
|
|
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) }
|
|
CanvasManager.shared.refreshDebugStatus()
|
|
}
|
|
}
|
|
|
|
var swabbleEnabled: Bool {
|
|
didSet {
|
|
self.ifNotPreview {
|
|
UserDefaults.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 {
|
|
UserDefaults.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 { UserDefaults.standard.set(
|
|
self.iconAnimationsEnabled,
|
|
forKey: iconAnimationsEnabledKey) } }
|
|
}
|
|
|
|
var showDockIcon: Bool {
|
|
didSet {
|
|
self.ifNotPreview {
|
|
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
|
|
AppActivationPolicy.apply(showDockIcon: self.showDockIcon)
|
|
}
|
|
}
|
|
}
|
|
|
|
var voiceWakeMicID: String {
|
|
didSet {
|
|
self.ifNotPreview {
|
|
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
|
|
if self.swabbleEnabled {
|
|
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var voiceWakeMicName: String {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
|
|
}
|
|
|
|
var voiceWakeLocaleID: String {
|
|
didSet {
|
|
self.ifNotPreview {
|
|
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
|
|
if self.swabbleEnabled {
|
|
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var voiceWakeAdditionalLocaleIDs: [String] {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
|
self.voiceWakeAdditionalLocaleIDs,
|
|
forKey: voiceWakeAdditionalLocalesKey) } }
|
|
}
|
|
|
|
var voicePushToTalkEnabled: Bool {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
|
self.voicePushToTalkEnabled,
|
|
forKey: voicePushToTalkEnabledKey) } }
|
|
}
|
|
|
|
var voiceWakeTriggersTalkMode: Bool {
|
|
didSet {
|
|
self.ifNotPreview {
|
|
UserDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey)
|
|
if self.swabbleEnabled {
|
|
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var talkEnabled: Bool {
|
|
didSet {
|
|
self.ifNotPreview {
|
|
UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
|
|
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Gateway-provided UI accent color (hex). Optional; clients provide a default.
|
|
var seamColorHex: String?
|
|
|
|
var iconOverride: IconOverrideSelection {
|
|
didSet { self.ifNotPreview { UserDefaults.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 {
|
|
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
|
|
Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
|
|
}
|
|
}
|
|
}
|
|
|
|
var connectionMode: ConnectionMode {
|
|
didSet {
|
|
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
|
self.syncGatewayConfigIfNeeded()
|
|
}
|
|
}
|
|
|
|
var remoteTransport: RemoteTransport {
|
|
didSet { self.syncGatewayConfigIfNeeded() }
|
|
}
|
|
|
|
var canvasEnabled: Bool {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
|
}
|
|
|
|
var execApprovalMode: ExecApprovalQuickMode {
|
|
didSet {
|
|
self.ifNotPreview {
|
|
ExecApprovalsStore.updateDefaults { defaults in
|
|
defaults.security = self.execApprovalMode.security
|
|
defaults.ask = self.execApprovalMode.ask
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
|
var canvasPanelVisible: Bool = false
|
|
|
|
var peekabooBridgeEnabled: Bool {
|
|
didSet {
|
|
self.ifNotPreview {
|
|
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
|
|
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(self.peekabooBridgeEnabled) }
|
|
}
|
|
}
|
|
}
|
|
|
|
var remoteTarget: String {
|
|
didSet {
|
|
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
|
self.syncGatewayConfigIfNeeded()
|
|
}
|
|
}
|
|
|
|
var remoteUrl: String {
|
|
didSet { self.syncGatewayConfigIfNeeded() }
|
|
}
|
|
|
|
var remoteToken: String {
|
|
didSet {
|
|
guard !self.isApplyingRemoteTokenConfig else { return }
|
|
self.remoteTokenDirty = true
|
|
self.remoteTokenUnsupported = false
|
|
self.syncGatewayConfigIfNeeded()
|
|
}
|
|
}
|
|
|
|
private(set) var remoteTokenDirty = false
|
|
private(set) var remoteTokenUnsupported = false
|
|
|
|
var remoteIdentity: String {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
|
}
|
|
|
|
var remoteProjectRoot: String {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
|
|
}
|
|
|
|
var remoteCliPath: String {
|
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } }
|
|
}
|
|
|
|
private var earBoostTask: Task<Void, Never>?
|
|
|
|
init(preview: Bool = false) {
|
|
let isPreview = preview || ProcessInfo.processInfo.isRunningTests
|
|
self.isPreview = isPreview
|
|
if !isPreview {
|
|
migrateLegacyDefaults()
|
|
}
|
|
let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey)
|
|
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
|
self.launchAtLogin = false
|
|
self.onboardingSeen = onboardingSeen
|
|
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: debugPaneEnabledKey)
|
|
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
|
|
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
|
|
self.swabbleTriggerWords = UserDefaults.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 = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
|
|
self.iconAnimationsEnabled = storedIconAnimations
|
|
} else {
|
|
self.iconAnimationsEnabled = true
|
|
UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
|
|
}
|
|
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
|
|
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
|
self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
|
|
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
|
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
|
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
|
self.voicePushToTalkEnabled = UserDefaults.standard
|
|
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
|
self.voiceWakeTriggersTalkMode = UserDefaults.standard
|
|
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
|
|
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
|
|
self.seamColorHex = nil
|
|
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
|
self.heartbeatsEnabled = storedHeartbeats
|
|
} else {
|
|
self.heartbeatsEnabled = true
|
|
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
|
}
|
|
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
|
|
let selection = IconOverrideSelection(rawValue: storedOverride)
|
|
{
|
|
self.iconOverride = selection
|
|
} else {
|
|
self.iconOverride = .system
|
|
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
|
|
}
|
|
|
|
let configRoot = OpenClawConfigFile.loadDict()
|
|
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
|
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
|
|
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
|
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
|
self.remoteTransport = configRemoteTransport
|
|
self.connectionMode = resolvedConnectionMode
|
|
|
|
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
|
if resolvedConnectionMode == .remote,
|
|
configRemoteTransport != .direct,
|
|
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
let host = AppState.remoteHost(from: configRemoteUrl)
|
|
{
|
|
self.remoteTarget = "\(NSUserName())@\(host)"
|
|
} else {
|
|
self.remoteTarget = storedRemoteTarget
|
|
}
|
|
self.remoteUrl = configRemoteUrl ?? ""
|
|
self.remoteToken = configRemoteToken.textFieldValue
|
|
self.remoteTokenDirty = false
|
|
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
|
|
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
|
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
|
let execDefaults = ExecApprovalsStore.resolveDefaults()
|
|
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
|
|
self.peekabooBridgeEnabled = UserDefaults.standard
|
|
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
|
if !self.isPreview {
|
|
Task.detached(priority: .utility) { [weak self] in
|
|
let current = await LaunchAgentManager.status()
|
|
await MainActor.run { [weak self] in self?.launchAtLogin = current }
|
|
}
|
|
}
|
|
|
|
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) }
|
|
}
|
|
|
|
self.isInitializing = false
|
|
if !self.isPreview {
|
|
self.startConfigWatcher()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
deinit {
|
|
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 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
|
|
}
|
|
|
|
private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) {
|
|
let nextToken = tokenValue.textFieldValue
|
|
let unsupported = tokenValue.isUnsupportedNonString
|
|
guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported
|
|
else {
|
|
return
|
|
}
|
|
self.isApplyingRemoteTokenConfig = true
|
|
self.remoteToken = nextToken
|
|
self.isApplyingRemoteTokenConfig = false
|
|
self.remoteTokenDirty = false
|
|
self.remoteTokenUnsupported = unsupported
|
|
}
|
|
|
|
private static func updatedRemoteGatewayConfig(
|
|
current: [String: Any],
|
|
transport: RemoteTransport,
|
|
remoteUrl: String,
|
|
remoteHost: String?,
|
|
remoteTarget: String,
|
|
remoteIdentity: String,
|
|
remoteToken: String,
|
|
remoteTokenDirty: Bool) -> (remote: [String: Any], changed: Bool)
|
|
{
|
|
var remote = current
|
|
var changed = false
|
|
|
|
switch transport {
|
|
case .direct:
|
|
changed = Self.updateGatewayString(
|
|
&remote,
|
|
key: "transport",
|
|
value: RemoteTransport.direct.rawValue) || changed
|
|
|
|
let trimmedUrl = 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:
|
|
changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed
|
|
|
|
if let host = remoteHost {
|
|
let existingUrl = (remote["url"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
|
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
|
let port = parsedExisting?.port ?? 18789
|
|
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
|
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
|
|
}
|
|
|
|
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
|
changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed
|
|
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed
|
|
}
|
|
|
|
if remoteTokenDirty {
|
|
changed = Self.updateGatewayString(&remote, key: "token", value: 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()
|
|
self.applyConfigOverrides(root)
|
|
}
|
|
|
|
private func applyConfigOverrides(_ root: [String: Any]) {
|
|
let gateway = root["gateway"] 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 remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
|
|
|
let desiredMode: ConnectionMode? = switch modeRaw {
|
|
case "local":
|
|
.local
|
|
case "remote":
|
|
.remote
|
|
case "unconfigured":
|
|
.unconfigured
|
|
default:
|
|
nil
|
|
}
|
|
|
|
if let desiredMode {
|
|
if desiredMode != self.connectionMode {
|
|
self.connectionMode = desiredMode
|
|
}
|
|
} else if hasRemoteUrl, self.connectionMode != .remote {
|
|
self.connectionMode = .remote
|
|
}
|
|
|
|
if remoteTransport != self.remoteTransport {
|
|
self.remoteTransport = remoteTransport
|
|
}
|
|
let remoteUrlText = remoteUrl ?? ""
|
|
if remoteUrlText != self.remoteUrl {
|
|
self.remoteUrl = remoteUrlText
|
|
}
|
|
self.applyRemoteTokenState(remoteToken)
|
|
|
|
let targetMode = desiredMode ?? self.connectionMode
|
|
if targetMode == .remote,
|
|
remoteTransport != .direct,
|
|
let host = AppState.remoteHost(from: remoteUrl)
|
|
{
|
|
self.updateRemoteTarget(host: host)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
private static func syncedGatewayRoot(
|
|
currentRoot: [String: Any],
|
|
connectionMode: ConnectionMode,
|
|
remoteTransport: RemoteTransport,
|
|
remoteTarget: String,
|
|
remoteIdentity: String,
|
|
remoteUrl: String,
|
|
remoteToken: String,
|
|
remoteTokenDirty: Bool) -> (root: [String: Any], changed: Bool)
|
|
{
|
|
var root = currentRoot
|
|
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
|
var changed = false
|
|
|
|
let desiredMode: String? = switch connectionMode {
|
|
case .local:
|
|
"local"
|
|
case .remote:
|
|
"remote"
|
|
case .unconfigured:
|
|
nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if connectionMode == .remote {
|
|
let remoteHost = CommandResolver.parseSSHTarget(remoteTarget)?.host
|
|
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
|
|
let updated = Self.updatedRemoteGatewayConfig(
|
|
current: currentRemote,
|
|
transport: remoteTransport,
|
|
remoteUrl: remoteUrl,
|
|
remoteHost: remoteHost,
|
|
remoteTarget: remoteTarget,
|
|
remoteIdentity: remoteIdentity,
|
|
remoteToken: remoteToken,
|
|
remoteTokenDirty: remoteTokenDirty)
|
|
if updated.changed {
|
|
gateway["remote"] = updated.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.isPreview, !self.isInitializing else { return }
|
|
|
|
Task { @MainActor in
|
|
self.syncGatewayConfigNow()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func syncGatewayConfigNow() {
|
|
guard !self.isPreview, !self.isInitializing else { return }
|
|
|
|
// Keep app-only connection settings local to avoid overwriting remote gateway config.
|
|
let synced = Self.syncedGatewayRoot(
|
|
currentRoot: OpenClawConfigFile.loadDict(),
|
|
connectionMode: self.connectionMode,
|
|
remoteTransport: self.remoteTransport,
|
|
remoteTarget: self.remoteTarget,
|
|
remoteIdentity: self.remoteIdentity,
|
|
remoteUrl: self.remoteUrl,
|
|
remoteToken: self.remoteToken,
|
|
remoteTokenDirty: self.remoteTokenDirty)
|
|
guard synced.changed else { return }
|
|
OpenClawConfigFile.saveDict(synced.root)
|
|
}
|
|
|
|
func triggerVoiceEars(ttl: TimeInterval? = 5) {
|
|
self.earBoostTask?.cancel()
|
|
self.earBoostActive = true
|
|
|
|
guard let ttl else { return }
|
|
|
|
self.earBoostTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000))
|
|
await MainActor.run { [weak self] in self?.earBoostActive = false }
|
|
}
|
|
}
|
|
|
|
func stopVoiceEars() {
|
|
self.earBoostTask?.cancel()
|
|
self.earBoostTask = nil
|
|
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
|
|
}
|
|
|
|
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(self.swabbleTriggerWords)
|
|
self.voiceWakeGlobalSyncTask?.cancel()
|
|
self.voiceWakeGlobalSyncTask = Task { [sanitized] in
|
|
try? await Task.sleep(nanoseconds: 650_000_000)
|
|
await GatewayConnection.shared.voiceWakeSetTriggers(sanitized)
|
|
}
|
|
}
|
|
|
|
func setWorking(_ working: Bool) {
|
|
self.isWorking = working
|
|
}
|
|
|
|
// MARK: - Chime persistence
|
|
|
|
private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime {
|
|
guard let data = UserDefaults.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 }
|
|
UserDefaults.standard.set(data, forKey: key)
|
|
}
|
|
}
|
|
|
|
extension AppState {
|
|
static var preview: AppState {
|
|
let state = AppState(preview: true)
|
|
state.isPaused = false
|
|
state.launchAtLogin = true
|
|
state.onboardingSeen = true
|
|
state.debugPaneEnabled = true
|
|
state.swabbleEnabled = true
|
|
state.swabbleTriggerWords = ["Claude", "Computer", "Jarvis"]
|
|
state.voiceWakeTriggerChime = .system(name: "Glass")
|
|
state.voiceWakeSendChime = .system(name: "Ping")
|
|
state.iconAnimationsEnabled = true
|
|
state.showDockIcon = true
|
|
state.voiceWakeMicID = "BuiltInMic"
|
|
state.voiceWakeMicName = "Built-in Microphone"
|
|
state.voiceWakeLocaleID = Locale.current.identifier
|
|
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
|
state.voicePushToTalkEnabled = false
|
|
state.talkEnabled = false
|
|
state.iconOverride = .system
|
|
state.heartbeatsEnabled = true
|
|
state.connectionMode = .local
|
|
state.remoteTransport = .ssh
|
|
state.canvasEnabled = true
|
|
state.remoteTarget = "user@example.com"
|
|
state.remoteUrl = "wss://gateway.example.ts.net"
|
|
state.remoteToken = "example-token"
|
|
state.remoteIdentity = "~/.ssh/id_ed25519"
|
|
state.remoteProjectRoot = "~/Projects/openclaw"
|
|
state.remoteCliPath = ""
|
|
return state
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
@MainActor
|
|
extension AppState {
|
|
static func _testUpdatedRemoteGatewayConfig(
|
|
current: [String: Any],
|
|
transport: RemoteTransport,
|
|
remoteUrl: String,
|
|
remoteHost: String?,
|
|
remoteTarget: String,
|
|
remoteIdentity: String,
|
|
remoteToken: String,
|
|
remoteTokenDirty: Bool) -> [String: Any]
|
|
{
|
|
self.updatedRemoteGatewayConfig(
|
|
current: current,
|
|
transport: transport,
|
|
remoteUrl: remoteUrl,
|
|
remoteHost: remoteHost,
|
|
remoteTarget: remoteTarget,
|
|
remoteIdentity: remoteIdentity,
|
|
remoteToken: remoteToken,
|
|
remoteTokenDirty: remoteTokenDirty).remote
|
|
}
|
|
|
|
static func _testSyncedGatewayRoot(
|
|
currentRoot: [String: Any],
|
|
connectionMode: ConnectionMode,
|
|
remoteTransport: RemoteTransport,
|
|
remoteTarget: String,
|
|
remoteIdentity: String,
|
|
remoteUrl: String,
|
|
remoteToken: String,
|
|
remoteTokenDirty: Bool) -> [String: Any]
|
|
{
|
|
self.syncedGatewayRoot(
|
|
currentRoot: currentRoot,
|
|
connectionMode: connectionMode,
|
|
remoteTransport: remoteTransport,
|
|
remoteTarget: remoteTarget,
|
|
remoteIdentity: remoteIdentity,
|
|
remoteUrl: remoteUrl,
|
|
remoteToken: remoteToken,
|
|
remoteTokenDirty: remoteTokenDirty).root
|
|
}
|
|
}
|
|
#endif
|
|
|
|
@MainActor
|
|
enum AppStateStore {
|
|
static let shared = AppState()
|
|
static var isPausedFlag: Bool {
|
|
UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
|
}
|
|
|
|
static func updateLaunchAtLogin(enabled: Bool) {
|
|
Task.detached(priority: .utility) {
|
|
await LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath)
|
|
}
|
|
}
|
|
|
|
static var canvasEnabled: Bool {
|
|
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
enum AppActivationPolicy {
|
|
static func apply(showDockIcon: Bool) {
|
|
_ = showDockIcon
|
|
DockIconManager.shared.updateDockVisibility()
|
|
}
|
|
}
|