feat(ios): sync wake words via gateway

This commit is contained in:
Peter Steinberger
2025-12-14 05:05:20 +00:00
parent 7b2f712e20
commit 0cef22ef83
6 changed files with 106 additions and 3 deletions

View File

@@ -14,6 +14,7 @@ final class NodeAppModel: ObservableObject {
private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>?
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

View File

@@ -57,6 +57,8 @@ struct SettingsTab: View {
NavigationLink {
VoiceWakeWordsSettingsView()
.environmentObject(self.appModel)
.environmentObject(self.voiceWake)
} label: {
LabeledContent(
"Wake Words",

View File

@@ -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<Void, Never>?
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)
}
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
}