diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 6bc7e46fd..8af819881 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -14,6 +14,7 @@ final class NodeAppModel: ObservableObject { private let bridge = BridgeSession() private var bridgeTask: Task? + private var voiceWakeSyncTask: Task? let voiceWake = VoiceWakeManager() var bridgeSession: BridgeSession { self.bridge } @@ -57,6 +58,8 @@ final class NodeAppModel: ObservableObject { self.bridgeServerName = nil self.bridgeRemoteAddress = nil self.connectedBridgeID = BridgeEndpointID.stableID(endpoint) + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = nil self.bridgeTask = Task { var attempt = 0 @@ -86,6 +89,7 @@ final class NodeAppModel: ObservableObject { self.bridgeRemoteAddress = addr } } + await self.startVoiceWakeSync() }, onInvoke: { [weak self] req in guard let self else { @@ -126,6 +130,8 @@ final class NodeAppModel: ObservableObject { func disconnectBridge() { self.bridgeTask?.cancel() self.bridgeTask = nil + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = nil Task { await self.bridge.disconnect() } self.bridgeStatusText = "Disconnected" self.bridgeServerName = nil @@ -133,6 +139,52 @@ final class NodeAppModel: ObservableObject { self.connectedBridgeID = nil } + func setGlobalWakeWords(_ words: [String]) async { + let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words) + + struct Payload: Codable { + var triggers: [String] + } + let payload = Payload(triggers: sanitized) + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { return } + + do { + _ = try await self.bridge.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12) + } catch { + // Best-effort only. + } + } + + private func startVoiceWakeSync() async { + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = Task { [weak self] in + guard let self else { return } + + await self.refreshWakeWordsFromGateway() + + let stream = await self.bridge.subscribeServerEvents(bufferingNewest: 200) + for await evt in stream { + if Task.isCancelled { return } + guard evt.event == "voicewake.changed" else { continue } + guard let payloadJSON = evt.payloadJSON else { continue } + guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payloadJSON) else { continue } + VoiceWakePreferences.saveTriggerWords(triggers) + } + } + } + + private func refreshWakeWordsFromGateway() async { + do { + let data = try await self.bridge.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } + VoiceWakePreferences.saveTriggerWords(triggers) + } catch { + // Best-effort only. + } + } + func sendVoiceTranscript(text: String, sessionKey: String?) async throws { struct Payload: Codable { var text: String diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index ec4c01491..a8fb4cf72 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -57,6 +57,8 @@ struct SettingsTab: View { NavigationLink { VoiceWakeWordsSettingsView() + .environmentObject(self.appModel) + .environmentObject(self.voiceWake) } label: { LabeledContent( "Wake Words", diff --git a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift index 1f915199f..60b86c631 100644 --- a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift +++ b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift @@ -1,7 +1,9 @@ import SwiftUI struct VoiceWakeWordsSettingsView: View { - @State private var triggerWords: [String] = [] + @EnvironmentObject private var appModel: NodeAppModel + @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() + @State private var syncTask: Task? var body: some View { Form { @@ -34,13 +36,21 @@ struct VoiceWakeWordsSettingsView: View { } .navigationTitle("Wake Words") .toolbar { EditButton() } - .task { + .onAppear { if self.triggerWords.isEmpty { - self.triggerWords = VoiceWakePreferences.loadTriggerWords() + self.triggerWords = VoiceWakePreferences.defaultTriggerWords } } .onChange(of: self.triggerWords) { _, newValue in + // Keep local voice wake responsive even if bridge isn't connected yet. VoiceWakePreferences.saveTriggerWords(newValue) + + let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) + self.syncTask?.cancel() + self.syncTask = Task { [snapshot, weak appModel = self.appModel] in + try? await Task.sleep(nanoseconds: 650_000_000) + await appModel?.setGlobalWakeWords(snapshot) + } } } diff --git a/apps/ios/Sources/Voice/VoiceWakePreferences.swift b/apps/ios/Sources/Voice/VoiceWakePreferences.swift index 3ee9284f9..96f46518e 100644 --- a/apps/ios/Sources/Voice/VoiceWakePreferences.swift +++ b/apps/ios/Sources/Voice/VoiceWakePreferences.swift @@ -7,6 +7,17 @@ enum VoiceWakePreferences { // Keep defaults aligned with the mac app. static let defaultTriggerWords: [String] = ["clawd", "claude"] + static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { + guard let data = payloadJSON.data(using: .utf8) else { return nil } + return self.decodeGatewayTriggers(from: data) + } + + static func decodeGatewayTriggers(from data: Data) -> [String]? { + struct Payload: Decodable { var triggers: [String] } + guard let decoded = try? JSONDecoder().decode(Payload.self, from: data) else { return nil } + return self.sanitizeTriggerWords(decoded.triggers) + } + static func loadTriggerWords(defaults: UserDefaults = .standard) -> [String] { defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords } diff --git a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift index 5277db549..3cd19aff7 100644 --- a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift +++ b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift @@ -48,13 +48,18 @@ import UIKit } @Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() { + let appModel = NodeAppModel() let root = NavigationStack { VoiceWakeWordsSettingsView() } + .environmentObject(appModel) _ = Self.host(root) } @Test @MainActor func chatSheetBuildsAViewHierarchy() { + let appModel = NodeAppModel() let bridge = BridgeSession() let root = ChatSheet(bridge: bridge, sessionKey: "test") + .environmentObject(appModel) + .environmentObject(appModel.voiceWake) _ = Self.host(root) } diff --git a/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift b/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift new file mode 100644 index 000000000..7fcc0c6d2 --- /dev/null +++ b/apps/ios/Tests/VoiceWakeGatewaySyncTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite struct VoiceWakeGatewaySyncTests { + @Test func decodeGatewayTriggersFromJSONSanitizes() { + let payload = #"{"triggers":[" clawd ","", "computer"]}"# + let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload) + #expect(triggers == ["clawd", "computer"]) + } + + @Test func decodeGatewayTriggersFromJSONFallsBackWhenEmpty() { + let payload = #"{"triggers":[" ",""]}"# + let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payload) + #expect(triggers == VoiceWakePreferences.defaultTriggerWords) + } + + @Test func decodeGatewayTriggersFromInvalidJSONReturnsNil() { + let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: "not json") + #expect(triggers == nil) + } +} +