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.

View File

@@ -5,9 +5,10 @@ import Network
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.debugID }
var id: String { self.stableID }
var name: String
var endpoint: NWEndpoint
var stableID: String
var debugID: String
}
@@ -54,7 +55,8 @@ final class BridgeDiscoveryModel: ObservableObject {
return DiscoveredBridge(
name: decodedName,
endpoint: result.endpoint,
debugID: Self.prettyEndpointDebugID(result.endpoint))
stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
default:
return nil
}
@@ -73,8 +75,4 @@ final class BridgeDiscoveryModel: ObservableObject {
self.bridges = []
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>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Clawdis Node</string>
<string>ClawdisKit</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -25,11 +25,11 @@
<string>_clawdis-bridge._tcp</string>
</array>
<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>
<string>Clawdis Node needs microphone access for voice wake.</string>
<string>ClawdisKit needs microphone access for voice wake.</string>
<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>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

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

View File

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

View File

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

View File

@@ -43,14 +43,14 @@ targets:
info:
path: Sources/Info.plist
properties:
CFBundleDisplayName: Clawdis Node
CFBundleDisplayName: ClawdisKit
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
UIBackgroundModes:
- 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:
- _clawdis-bridge._tcp
NSMicrophoneUsageDescription: Clawdis Node needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: Clawdis Node uses on-device speech recognition for voice wake.
NSMicrophoneUsageDescription: ClawdisKit needs microphone access 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:
- `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)
Gateway stores the authoritative state under `~/.clawdis/`:
- `~/.clawdis/nodes/paired.json`