From 7b2f712e203df8c85e294932608632e8693dcd81 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 05:05:13 +0000 Subject: [PATCH] feat(macos): sync wake words via gateway --- apps/macos/Sources/Clawdis/AppState.swift | 28 ++++++++ apps/macos/Sources/Clawdis/MenuBar.swift | 2 + .../Clawdis/VoiceWakeGlobalSettingsSync.swift | 66 +++++++++++++++++++ .../VoiceWakeGlobalSettingsSyncTests.swift | 56 ++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 apps/macos/Sources/Clawdis/VoiceWakeGlobalSettingsSync.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/VoiceWakeGlobalSettingsSyncTests.swift diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index d3d47c988..f567e30df 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -6,6 +6,8 @@ import SwiftUI @MainActor final class AppState: ObservableObject { private let isPreview: Bool + private var suppressVoiceWakeGlobalSync = false + private var voiceWakeGlobalSyncTask: Task? private func ifNotPreview(_ action: () -> Void) { guard !self.isPreview else { return } @@ -53,6 +55,7 @@ final class AppState: ObservableObject { if self.swabbleEnabled { Task { await VoiceWakeRuntime.shared.refresh(state: self) } } + self.scheduleVoiceWakeGlobalSyncIfNeeded() } } } @@ -310,6 +313,31 @@ final class AppState: ObservableObject { Task { await VoiceWakeRuntime.shared.refresh(state: self) } } + // 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) + do { + _ = try await GatewayConnection.shared.request( + method: "voicewake.set", + params: ["triggers": AnyCodable(sanitized)], + timeoutMs: 10_000) + } catch { + // Best-effort only. + } + } + } + func setWorking(_ working: Bool) { self.isWorking = working } diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index c21df7b9e..fc79535dc 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -179,6 +179,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) } } NodePairingApprovalPrompter.shared.start() + VoiceWakeGlobalSettingsSync.shared.start() Task { PresenceReporter.shared.start() } Task { await HealthStore.shared.refresh(onDemand: true) } Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } @@ -197,6 +198,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { GatewayProcessManager.shared.stop() PresenceReporter.shared.stop() NodePairingApprovalPrompter.shared.stop() + VoiceWakeGlobalSettingsSync.shared.stop() WebChatManager.shared.close() WebChatManager.shared.resetTunnels() Task { await RemoteTunnelManager.shared.stopAll() } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/Clawdis/VoiceWakeGlobalSettingsSync.swift new file mode 100644 index 000000000..3eb6fbb29 --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeGlobalSettingsSync.swift @@ -0,0 +1,66 @@ +import Foundation +import OSLog + +@MainActor +final class VoiceWakeGlobalSettingsSync { + static let shared = VoiceWakeGlobalSettingsSync() + + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.sync") + private var task: Task? + + private struct VoiceWakePayload: Codable, Equatable { + let triggers: [String] + } + + func start() { + guard self.task == nil else { return } + self.task = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + do { + try await GatewayConnection.shared.refresh() + } catch { + // Not configured / not reachable yet. + } + + await self.refreshFromGateway() + + let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200) + for await push in stream { + if Task.isCancelled { return } + await self.handle(push: push) + } + + // If the stream finishes (gateway shutdown / reconnect), loop and resubscribe. + try? await Task.sleep(nanoseconds: 600_000_000) + } + } + } + + func stop() { + self.task?.cancel() + self.task = nil + } + + private func refreshFromGateway() async { + do { + let data = try await GatewayConnection.shared.request(method: "voicewake.get", params: nil, timeoutMs: 8000) + let payload = try JSONDecoder().decode(VoiceWakePayload.self, from: data) + AppStateStore.shared.applyGlobalVoiceWakeTriggers(payload.triggers) + } catch { + // Best-effort only. + } + } + + func handle(push: GatewayPush) async { + guard case let .event(evt) = push else { return } + guard evt.event == "voicewake.changed" else { return } + guard let payload = evt.payload else { return } + do { + let decoded = try GatewayPayloadDecoding.decode(payload, as: VoiceWakePayload.self) + AppStateStore.shared.applyGlobalVoiceWakeTriggers(decoded.triggers) + } catch { + self.logger.error("failed to decode voicewake.changed: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/VoiceWakeGlobalSettingsSyncTests.swift b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeGlobalSettingsSyncTests.swift new file mode 100644 index 000000000..522aa6e93 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/VoiceWakeGlobalSettingsSyncTests.swift @@ -0,0 +1,56 @@ +import ClawdisProtocol +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) struct VoiceWakeGlobalSettingsSyncTests { + @Test func appliesVoiceWakeChangedEventToAppState() async { + let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } + + await MainActor.run { + AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"]) + } + + let payload = ClawdisProtocol.AnyCodable(["triggers": ["clawd", "computer"]]) + let evt = EventFrame( + type: "event", + event: "voicewake.changed", + payload: payload, + seq: nil, + stateversion: nil) + + await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt)) + + let updated = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } + #expect(updated == ["clawd", "computer"]) + + await MainActor.run { + AppStateStore.shared.applyGlobalVoiceWakeTriggers(previous) + } + } + + @Test func ignoresVoiceWakeChangedEventWithInvalidPayload() async { + let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } + + await MainActor.run { + AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"]) + } + + let payload = ClawdisProtocol.AnyCodable(["unexpected": 123]) + let evt = EventFrame( + type: "event", + event: "voicewake.changed", + payload: payload, + seq: nil, + stateversion: nil) + + await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt)) + + let updated = await MainActor.run { AppStateStore.shared.swabbleTriggerWords } + #expect(updated == ["before"]) + + await MainActor.run { + AppStateStore.shared.applyGlobalVoiceWakeTriggers(previous) + } + } +}