fix(ios): stabilize voice wake + bridge UI

This commit is contained in:
Peter Steinberger
2025-12-13 12:28:34 +00:00
parent 2b71ea21ad
commit 3863fe6412
9 changed files with 60 additions and 34 deletions

View File

@@ -1,4 +1,4 @@
# ClawdisNode (iOS) # ClawdisKit (iOS)
Internal-only SwiftUI app scaffold. Internal-only SwiftUI app scaffold.

View File

@@ -5,9 +5,10 @@ import Network
@MainActor @MainActor
final class BridgeDiscoveryModel: ObservableObject { final class BridgeDiscoveryModel: ObservableObject {
struct DiscoveredBridge: Identifiable, Equatable { struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.debugID } var id: String { self.stableID }
var name: String var name: String
var endpoint: NWEndpoint var endpoint: NWEndpoint
var stableID: String
var debugID: String var debugID: String
} }
@@ -54,7 +55,8 @@ final class BridgeDiscoveryModel: ObservableObject {
return DiscoveredBridge( return DiscoveredBridge(
name: decodedName, name: decodedName,
endpoint: result.endpoint, endpoint: result.endpoint,
debugID: Self.prettyEndpointDebugID(result.endpoint)) stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
default: default:
return nil return nil
} }
@@ -73,8 +75,4 @@ final class BridgeDiscoveryModel: ObservableObject {
self.bridges = [] self.bridges = []
self.statusText = "Stopped" self.statusText = "Stopped"
} }
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
} }

View File

@@ -0,0 +1,19 @@
import ClawdisKit
import Foundation
import Network
enum BridgeEndpointID {
static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):
// Keep this stable across encode/decode differences; use raw service tuple.
"\(type)|\(domain)|\(name)"
default:
String(describing: endpoint)
}
}
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
BonjourEscapes.decode(String(describing: endpoint))
}
}

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Clawdis Node</string> <string>ClawdisKit</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -25,11 +25,11 @@
<string>_clawdis-bridge._tcp</string> <string>_clawdis-bridge._tcp</string>
</array> </array>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Clawdis Node discovers and connects to your Clawdis bridge on the local network.</string> <string>ClawdisKit discovers and connects to your Clawdis bridge on the local network.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Clawdis Node needs microphone access for voice wake.</string> <string>ClawdisKit needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key> <key>NSSpeechRecognitionUsageDescription</key>
<string>Clawdis Node uses on-device speech recognition for voice wake.</string> <string>ClawdisKit uses on-device speech recognition for voice wake.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -9,7 +9,7 @@ final class NodeAppModel: ObservableObject {
@Published var bridgeStatusText: String = "Not connected" @Published var bridgeStatusText: String = "Not connected"
@Published var bridgeServerName: String? @Published var bridgeServerName: String?
@Published var bridgeRemoteAddress: String? @Published var bridgeRemoteAddress: String?
@Published var connectedBridgeDebugID: String? @Published var connectedBridgeID: String?
private let bridge = BridgeSession() private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>? private var bridgeTask: Task<Void, Never>?
@@ -58,7 +58,7 @@ final class NodeAppModel: ObservableObject {
self.bridgeStatusText = "Connecting…" self.bridgeStatusText = "Connecting…"
self.bridgeServerName = nil self.bridgeServerName = nil
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeDebugID = BonjourEscapes.decode(String(describing: endpoint)) self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
self.bridgeTask = Task { self.bridgeTask = Task {
do { do {
@@ -96,14 +96,14 @@ final class NodeAppModel: ObservableObject {
self.bridgeStatusText = "Disconnected" self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil self.bridgeServerName = nil
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeDebugID = nil self.connectedBridgeID = nil
} }
} catch { } catch {
await MainActor.run { await MainActor.run {
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)" self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
self.bridgeServerName = nil self.bridgeServerName = nil
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeDebugID = nil self.connectedBridgeID = nil
} }
} }
} }
@@ -116,7 +116,7 @@ final class NodeAppModel: ObservableObject {
self.bridgeStatusText = "Disconnected" self.bridgeStatusText = "Disconnected"
self.bridgeServerName = nil self.bridgeServerName = nil
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeDebugID = nil self.connectedBridgeID = nil
} }
func sendVoiceTranscript(text: String, sessionKey: String?) async throws { func sendVoiceTranscript(text: String, sessionKey: String?) async throws {

View File

@@ -98,9 +98,9 @@ struct SettingsTab: View {
Text("No bridges found yet.") Text("No bridges found yet.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
let connectedID = self.appModel.connectedBridgeDebugID let connectedID = self.appModel.connectedBridgeID
let rows = self.discovery.bridges.filter { bridge in let rows = self.discovery.bridges.filter { bridge in
let isConnected = bridge.debugID == connectedID let isConnected = bridge.stableID == connectedID
switch showing { switch showing {
case .all: case .all:
return true return true
@@ -117,10 +117,6 @@ struct SettingsTab: View {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(bridge.name) Text(bridge.name)
Text(bridge.debugID)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
} }
Spacer() Spacer()

View File

@@ -2,6 +2,13 @@ import AVFAudio
import Foundation import Foundation
import Speech import Speech
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> AVAudioNodeTapBlock {
{ buffer, _ in
// This callback is invoked on a realtime audio thread/queue. Keep it tiny and nonisolated.
queue.enqueueCopy(of: buffer)
}
}
private final class AudioBufferQueue: @unchecked Sendable { private final class AudioBufferQueue: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var buffers: [AVAudioPCMBuffer] = [] private var buffers: [AVAudioPCMBuffer] = []
@@ -41,7 +48,7 @@ extension AVAudioPCMBuffer {
let channels = Int(format.channelCount) let channels = Int(format.channelCount)
let frames = Int(frameLength) let frames = Int(frameLength)
for ch in 0..<channels { for ch in 0..<channels {
dst[ch].assign(from: src[ch], count: frames) dst[ch].update(from: src[ch], count: frames)
} }
return copy return copy
} }
@@ -50,7 +57,7 @@ extension AVAudioPCMBuffer {
let channels = Int(format.channelCount) let channels = Int(format.channelCount)
let frames = Int(frameLength) let frames = Int(frameLength)
for ch in 0..<channels { for ch in 0..<channels {
dst[ch].assign(from: src[ch], count: frames) dst[ch].update(from: src[ch], count: frames)
} }
return copy return copy
} }
@@ -59,7 +66,7 @@ extension AVAudioPCMBuffer {
let channels = Int(format.channelCount) let channels = Int(format.channelCount)
let frames = Int(frameLength) let frames = Int(frameLength)
for ch in 0..<channels { for ch in 0..<channels {
dst[ch].assign(from: src[ch], count: frames) dst[ch].update(from: src[ch], count: frames)
} }
return copy return copy
} }
@@ -176,11 +183,11 @@ final class VoiceWakeManager: NSObject, ObservableObject {
let queue = AudioBufferQueue() let queue = AudioBufferQueue()
self.tapQueue = queue self.tapQueue = queue
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak queue] buffer, _ in inputNode.installTap(
// `SFSpeechAudioBufferRecognitionRequest.append` is MainActor-isolated on iOS 26. onBus: 0,
// Copy + enqueue in the realtime callback, drain + append from the MainActor. bufferSize: 1024,
queue?.enqueueCopy(of: buffer) format: recordingFormat,
} block: makeAudioTapEnqueueCallback(queue: queue))
self.audioEngine.prepare() self.audioEngine.prepare()
try self.audioEngine.start() try self.audioEngine.start()

View File

@@ -43,14 +43,14 @@ targets:
info: info:
path: Sources/Info.plist path: Sources/Info.plist
properties: properties:
CFBundleDisplayName: Clawdis Node CFBundleDisplayName: ClawdisKit
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
UIBackgroundModes: UIBackgroundModes:
- audio - audio
NSLocalNetworkUsageDescription: Clawdis Node discovers and connects to your Clawdis bridge on the local network. NSLocalNetworkUsageDescription: ClawdisKit discovers and connects to your Clawdis bridge on the local network.
NSBonjourServices: NSBonjourServices:
- _clawdis-bridge._tcp - _clawdis-bridge._tcp
NSMicrophoneUsageDescription: Clawdis Node needs microphone access for voice wake. NSMicrophoneUsageDescription: ClawdisKit needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdis Node uses on-device speech recognition for voice wake. NSSpeechRecognitionUsageDescription: ClawdisKit uses on-device speech recognition for voice wake.

View File

@@ -74,6 +74,12 @@ CLI must be able to fully operate without any GUI:
Optional interactive helper: Optional interactive helper:
- `clawdis nodes watch` (subscribe to `node.pair.requested` and prompt in-place) - `clawdis nodes watch` (subscribe to `node.pair.requested` and prompt in-place)
Implementation pointers:
- CLI commands: `src/cli/nodes-cli.ts`
- Gateway handlers + events: `src/gateway/server.ts`
- Pairing store: `src/infra/node-pairing.ts` (under `~/.clawdis/nodes/`)
- Optional macOS UI prompt (frontend only): `apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift`
## Storage (private, local) ## Storage (private, local)
Gateway stores the authoritative state under `~/.clawdis/`: Gateway stores the authoritative state under `~/.clawdis/`:
- `~/.clawdis/nodes/paired.json` - `~/.clawdis/nodes/paired.json`