mirror of https://github.com/openclaw/openclaw.git
fix(macos): guard voice audio paths with no input device (#25817)
Co-authored-by: Stefan Förster <103369858+sfo2001@users.noreply.github.com>
This commit is contained in:
parent
e11e510f5b
commit
236b22b6a2
|
|
@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
### Fixes
|
||||
|
||||
- macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001.
|
||||
- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.
|
||||
- Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3.
|
||||
- Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,15 @@ final class AudioInputDeviceObserver {
|
|||
return output
|
||||
}
|
||||
|
||||
/// Returns true when the system default input device exists and is alive with input channels.
|
||||
/// Use this preflight before accessing `AVAudioEngine.inputNode` to avoid SIGABRT on Macs
|
||||
/// without a built-in microphone (Mac mini, Mac Pro, Mac Studio) or when an external mic
|
||||
/// is disconnected.
|
||||
static func hasUsableDefaultInputDevice() -> Bool {
|
||||
guard let uid = self.defaultInputDeviceUID() else { return false }
|
||||
return self.aliveInputDeviceUIDs().contains(uid)
|
||||
}
|
||||
|
||||
static func defaultInputDeviceSummary() -> String {
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ actor MicLevelMonitor {
|
|||
if self.running { return }
|
||||
self.logger.info(
|
||||
"mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))")
|
||||
guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else {
|
||||
self.engine = nil
|
||||
throw NSError(
|
||||
domain: "MicLevelMonitor",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"])
|
||||
}
|
||||
let engine = AVAudioEngine()
|
||||
self.engine = engine
|
||||
let input = engine.inputNode
|
||||
|
|
|
|||
|
|
@ -185,6 +185,12 @@ actor TalkModeRuntime {
|
|||
}
|
||||
guard let audioEngine = self.audioEngine else { return }
|
||||
|
||||
guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else {
|
||||
self.audioEngine = nil
|
||||
self.logger.error("talk mode: no usable audio input device")
|
||||
return
|
||||
}
|
||||
|
||||
let input = audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
input.removeTap(onBus: 0)
|
||||
|
|
|
|||
|
|
@ -244,6 +244,14 @@ actor VoicePushToTalk {
|
|||
}
|
||||
guard let audioEngine = self.audioEngine else { return }
|
||||
|
||||
guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else {
|
||||
self.audioEngine = nil
|
||||
throw NSError(
|
||||
domain: "VoicePushToTalk",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"])
|
||||
}
|
||||
|
||||
let input = audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
if self.tapInstalled {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,14 @@ actor VoiceWakeRuntime {
|
|||
}
|
||||
guard let audioEngine = self.audioEngine else { return }
|
||||
|
||||
guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else {
|
||||
self.audioEngine = nil
|
||||
throw NSError(
|
||||
domain: "VoiceWakeRuntime",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"])
|
||||
}
|
||||
|
||||
let input = audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
guard format.channelCount > 0, format.sampleRate > 0 else {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,14 @@ final class VoiceWakeTester {
|
|||
self.logInputSelection(preferredMicID: micID)
|
||||
self.configureSession(preferredMicID: micID)
|
||||
|
||||
guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else {
|
||||
self.audioEngine = nil
|
||||
throw NSError(
|
||||
domain: "VoiceWakeTester",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"])
|
||||
}
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
self.audioEngine = engine
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct AudioInputDeviceObserverTests {
|
||||
@Test func hasUsableDefaultInputDeviceReturnsBool() {
|
||||
// Smoke test: verifies the composition logic runs without crashing.
|
||||
// Actual result depends on whether the host has an audio input device.
|
||||
let result = AudioInputDeviceObserver.hasUsableDefaultInputDevice()
|
||||
_ = result // suppress unused-variable warning; the assertion is "no crash"
|
||||
}
|
||||
|
||||
@Test func hasUsableDefaultInputDeviceConsistentWithComponents() {
|
||||
// When no default UID exists, the method must return false.
|
||||
// When a default UID exists, the result must match alive-set membership.
|
||||
let uid = AudioInputDeviceObserver.defaultInputDeviceUID()
|
||||
let alive = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
||||
let expected = uid.map { alive.contains($0) } ?? false
|
||||
#expect(AudioInputDeviceObserver.hasUsableDefaultInputDevice() == expected)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue