feat(ios): sync wake words via gateway
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -57,6 +57,8 @@ struct SettingsTab: View {
|
||||
|
||||
NavigationLink {
|
||||
VoiceWakeWordsSettingsView()
|
||||
.environmentObject(self.appModel)
|
||||
.environmentObject(self.voiceWake)
|
||||
} label: {
|
||||
LabeledContent(
|
||||
"Wake Words",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
23
apps/ios/Tests/VoiceWakeGatewaySyncTests.swift
Normal file
23
apps/ios/Tests/VoiceWakeGatewaySyncTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user