fix(ios): stabilize voice wake + bridge UI
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# ClawdisNode (iOS)
|
||||
# ClawdisKit (iOS)
|
||||
|
||||
Internal-only SwiftUI app scaffold.
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/ios/Sources/Bridge/BridgeEndpointID.swift
Normal file
19
apps/ios/Sources/Bridge/BridgeEndpointID.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user