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 let bridge = BridgeSession()
|
||||||
private var bridgeTask: Task<Void, Never>?
|
private var bridgeTask: Task<Void, Never>?
|
||||||
|
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||||
let voiceWake = VoiceWakeManager()
|
let voiceWake = VoiceWakeManager()
|
||||||
|
|
||||||
var bridgeSession: BridgeSession { self.bridge }
|
var bridgeSession: BridgeSession { self.bridge }
|
||||||
@@ -57,6 +58,8 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||||
|
self.voiceWakeSyncTask?.cancel()
|
||||||
|
self.voiceWakeSyncTask = nil
|
||||||
|
|
||||||
self.bridgeTask = Task {
|
self.bridgeTask = Task {
|
||||||
var attempt = 0
|
var attempt = 0
|
||||||
@@ -86,6 +89,7 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self.bridgeRemoteAddress = addr
|
self.bridgeRemoteAddress = addr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await self.startVoiceWakeSync()
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@@ -126,6 +130,8 @@ final class NodeAppModel: ObservableObject {
|
|||||||
func disconnectBridge() {
|
func disconnectBridge() {
|
||||||
self.bridgeTask?.cancel()
|
self.bridgeTask?.cancel()
|
||||||
self.bridgeTask = nil
|
self.bridgeTask = nil
|
||||||
|
self.voiceWakeSyncTask?.cancel()
|
||||||
|
self.voiceWakeSyncTask = nil
|
||||||
Task { await self.bridge.disconnect() }
|
Task { await self.bridge.disconnect() }
|
||||||
self.bridgeStatusText = "Disconnected"
|
self.bridgeStatusText = "Disconnected"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
@@ -133,6 +139,52 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self.connectedBridgeID = nil
|
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 {
|
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||||
struct Payload: Codable {
|
struct Payload: Codable {
|
||||||
var text: String
|
var text: String
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
VoiceWakeWordsSettingsView()
|
VoiceWakeWordsSettingsView()
|
||||||
|
.environmentObject(self.appModel)
|
||||||
|
.environmentObject(self.voiceWake)
|
||||||
} label: {
|
} label: {
|
||||||
LabeledContent(
|
LabeledContent(
|
||||||
"Wake Words",
|
"Wake Words",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VoiceWakeWordsSettingsView: View {
|
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 {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -34,13 +36,21 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Wake Words")
|
.navigationTitle("Wake Words")
|
||||||
.toolbar { EditButton() }
|
.toolbar { EditButton() }
|
||||||
.task {
|
.onAppear {
|
||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.loadTriggerWords()
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.triggerWords) { _, newValue in
|
.onChange(of: self.triggerWords) { _, newValue in
|
||||||
|
// Keep local voice wake responsive even if bridge isn't connected yet.
|
||||||
VoiceWakePreferences.saveTriggerWords(newValue)
|
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.
|
// Keep defaults aligned with the mac app.
|
||||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
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] {
|
static func loadTriggerWords(defaults: UserDefaults = .standard) -> [String] {
|
||||||
defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords
|
defaults.stringArray(forKey: self.triggerWordsKey) ?? self.defaultTriggerWords
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,13 +48,18 @@ import UIKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
||||||
|
.environmentObject(appModel)
|
||||||
_ = Self.host(root)
|
_ = Self.host(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
|
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
let bridge = BridgeSession()
|
let bridge = BridgeSession()
|
||||||
let root = ChatSheet(bridge: bridge, sessionKey: "test")
|
let root = ChatSheet(bridge: bridge, sessionKey: "test")
|
||||||
|
.environmentObject(appModel)
|
||||||
|
.environmentObject(appModel.voiceWake)
|
||||||
_ = Self.host(root)
|
_ = 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