Skip to content

Commit 8e8da17

Browse files
oxoxDevclaudegraycyrus
authored
fix(voice): cross-platform microphone permission handling (tinyhumansai#489) (tinyhumansai#491)
* fix(voice): add cross-platform microphone permission handling (tinyhumansai#489) Voice dictation in release DMG silently fails because the macOS hardened runtime enforces entitlements and the sidecar plist lacked the audio-input entitlement. This adds the entitlement, NSMicrophoneUsageDescription for the system permission prompt, and cross-platform microphone permission detection (CPAL device probe) with clear error messages on macOS, Windows, and Linux. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(permissions): use plist file for infoPlist and fix cross-platform warnings (tinyhumansai#489) - infoPlist expects a file path, not inline JSON — create Info.plist with NSMicrophoneUsageDescription and reference it as a string - Move Microphone permission request out of macOS-only cfg block since request_microphone_access() is cross-platform (fixes unused import warning on Linux CI) - Treat persistent Unknown mic permission as Denied per CodeRabbit review Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Cyrus Gray <144336577+graycyrus@users.noreply.github.com>
1 parent 49cbbbe commit 8e8da17

9 files changed

Lines changed: 194 additions & 17 deletions

File tree

app/src-tauri/Info.plist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>NSMicrophoneUsageDescription</key>
6+
<string>OpenHuman uses the microphone for voice dictation — press the hotkey to record speech and transcribe it to text.</string>
7+
</dict>
8+
</plist>

app/src-tauri/entitlements.sidecar.plist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<true/>
99
<key>com.apple.security.cs.disable-library-validation</key>
1010
<true/>
11+
<key>com.apple.security.device.audio-input</key>
1112
<!-- Required for the core sidecar to make outbound HTTPS calls (registry fetch,
1213
skill manifest + JS bundle downloads) under the macOS Hardened Runtime.
1314
Without this, reqwest connections are silently blocked by the OS in signed

app/src-tauri/tauri.conf.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"macOS": {
7070
"minimumSystemVersion": "10.15",
7171
"entitlements": "entitlements.sidecar.plist",
72+
"infoPlist": "Info.plist",
7273
"dmg": {
7374
"background": "./images/background-dmg.png"
7475
}

src/openhuman/accessibility/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ pub use permissions::{
3636
detect_screen_recording_permission, open_macos_privacy_pane, request_accessibility_access,
3737
request_screen_recording_access,
3838
};
39-
pub use permissions::{detect_permissions, permission_to_str};
39+
pub use permissions::{
40+
detect_microphone_permission, detect_permissions, microphone_denied_message, permission_to_str,
41+
request_microphone_access,
42+
};
4043
pub use terminal::{
4144
extract_terminal_input_context, is_terminal_app, is_text_role, looks_like_terminal_buffer,
4245
};

src/openhuman/accessibility/permissions.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ pub fn permission_to_str(permission: PermissionKind) -> &'static str {
6060
PermissionKind::ScreenRecording => "screen_recording",
6161
PermissionKind::Accessibility => "accessibility",
6262
PermissionKind::InputMonitoring => "input_monitoring",
63+
PermissionKind::Microphone => "microphone",
6364
}
6465
}
6566

@@ -129,12 +130,132 @@ pub fn detect_input_monitoring_permission() -> PermissionState {
129130
}
130131
}
131132

133+
// ---------------------------------------------------------------------------
134+
// Microphone permission — cross-platform
135+
// ---------------------------------------------------------------------------
136+
137+
/// Detect whether the app has microphone permission.
138+
///
139+
/// Uses CPAL device probing as a cross-platform permission proxy:
140+
/// - If `default_input_device()` returns a device, access is available.
141+
/// - If it returns `None`, either permission is denied or no mic is connected.
142+
///
143+
/// On **macOS** under hardened runtime, CPAL will fail to enumerate input
144+
/// devices when the `com.apple.security.device.audio-input` entitlement is
145+
/// missing or microphone permission is denied in System Settings.
146+
///
147+
/// On **Windows**, `None` may indicate a privacy toggle denial or no hardware.
148+
///
149+
/// **Linux** standard desktops don't enforce per-app permissions; Flatpak/Snap
150+
/// sandboxes are detected separately.
151+
#[cfg(any(target_os = "macos", target_os = "windows"))]
152+
pub fn detect_microphone_permission() -> PermissionState {
153+
use cpal::traits::HostTrait;
154+
let host = cpal::default_host();
155+
match host.default_input_device() {
156+
Some(device) => {
157+
let name =
158+
cpal::traits::DeviceTrait::name(&device).unwrap_or_else(|_| "<unknown>".into());
159+
log::debug!("[permissions] microphone access available — device: {name}");
160+
PermissionState::Granted
161+
}
162+
None => {
163+
log::debug!(
164+
"[permissions] no default input device — possible permission denial or no mic connected"
165+
);
166+
PermissionState::Unknown
167+
}
168+
}
169+
}
170+
171+
#[cfg(target_os = "linux")]
172+
pub fn detect_microphone_permission() -> PermissionState {
173+
// Standard Linux desktops (PulseAudio/PipeWire) don't enforce app-level mic permissions.
174+
// Detect Flatpak sandbox — if sandboxed, probe CPAL as a permission proxy.
175+
if std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/run/flatpak").exists() {
176+
use cpal::traits::HostTrait;
177+
let host = cpal::default_host();
178+
match host.default_input_device() {
179+
Some(_) => PermissionState::Granted,
180+
None => {
181+
log::debug!(
182+
"[permissions] Linux (Flatpak): no default input device — possible sandbox restriction"
183+
);
184+
PermissionState::Denied
185+
}
186+
}
187+
} else {
188+
PermissionState::Granted
189+
}
190+
}
191+
192+
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
193+
pub fn detect_microphone_permission() -> PermissionState {
194+
PermissionState::Unsupported
195+
}
196+
197+
/// Request microphone access from the operating system.
198+
///
199+
/// - **macOS**: Triggers the system permission prompt if status is `NotDetermined`.
200+
/// Note: `AVCaptureDevice.requestAccess(for:)` is async in ObjC but we call the
201+
/// synchronous authorization check — the system prompt is triggered by the check itself
202+
/// when entitlements + usage description are present. Alternatively, opening the
203+
/// Privacy pane guides the user.
204+
/// - **Windows**: Opens the Privacy > Microphone settings page.
205+
/// - **Linux**: No-op for standard installs; guidance for Flatpak in error messages.
206+
#[cfg(target_os = "macos")]
207+
pub fn request_microphone_access() {
208+
log::debug!("[permissions] requesting macOS microphone access via Privacy pane");
209+
open_macos_privacy_pane("Privacy_Microphone");
210+
}
211+
212+
#[cfg(target_os = "windows")]
213+
pub fn request_microphone_access() {
214+
log::debug!("[permissions] opening Windows Privacy > Microphone settings");
215+
let _ = std::process::Command::new("cmd")
216+
.args(["/C", "start", "ms-settings:privacy-microphone"])
217+
.status();
218+
}
219+
220+
#[cfg(target_os = "linux")]
221+
pub fn request_microphone_access() {
222+
log::debug!("[permissions] Linux: no programmatic mic permission request available");
223+
// No-op — standard Linux desktops don't have an app-level permission gate.
224+
// For Flatpak, the XDG Portal API (ashpd crate) could be used in the future.
225+
}
226+
227+
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
228+
pub fn request_microphone_access() {
229+
// Unsupported platform — no-op.
230+
}
231+
232+
/// Returns a platform-specific user-facing message when microphone permission is denied.
233+
pub fn microphone_denied_message() -> String {
234+
#[cfg(target_os = "macos")]
235+
{
236+
"Microphone permission denied. Grant access in System Settings > Privacy & Security > Microphone, then restart the app.".to_string()
237+
}
238+
#[cfg(target_os = "windows")]
239+
{
240+
"Microphone access unavailable. Check Settings > Privacy & Security > Microphone and ensure the app is allowed. If no microphone is connected, plug one in.".to_string()
241+
}
242+
#[cfg(target_os = "linux")]
243+
{
244+
"No microphone device available. Check your audio settings and ensure a microphone is connected. If running in a Flatpak sandbox, grant microphone access via Flatseal or system settings.".to_string()
245+
}
246+
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
247+
{
248+
"Microphone access is not supported on this platform.".to_string()
249+
}
250+
}
251+
132252
#[cfg(target_os = "macos")]
133253
pub fn detect_permissions() -> PermissionStatus {
134254
PermissionStatus {
135255
screen_recording: detect_screen_recording_permission(),
136256
accessibility: detect_accessibility_permission(),
137257
input_monitoring: detect_input_monitoring_permission(),
258+
microphone: detect_microphone_permission(),
138259
}
139260
}
140261

@@ -144,5 +265,6 @@ pub fn detect_permissions() -> PermissionStatus {
144265
screen_recording: PermissionState::Unsupported,
145266
accessibility: PermissionState::Unsupported,
146267
input_monitoring: PermissionState::Unsupported,
268+
microphone: detect_microphone_permission(),
147269
}
148270
}

src/openhuman/accessibility/types.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ pub struct PermissionStatus {
6565
pub screen_recording: PermissionState,
6666
pub accessibility: PermissionState,
6767
pub input_monitoring: PermissionState,
68+
pub microphone: PermissionState,
6869
}
6970

7071
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -73,6 +74,7 @@ pub enum PermissionKind {
7374
ScreenRecording,
7475
Accessibility,
7576
InputMonitoring,
77+
Microphone,
7678
}
7779

7880
#[cfg(test)]

src/openhuman/screen_intelligence/engine.rs

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use super::types::{
1717
AccessibilityStatus, AppContextInfo, CaptureFrame, CaptureImageRefResult, CaptureNowResult,
1818
CaptureTestResult, CoreProcessStatus, SessionStatus, StartSessionParams,
1919
};
20+
use crate::openhuman::accessibility::request_microphone_access;
2021
use crate::openhuman::accessibility::{
2122
capture_screen_image_ref_for_context, detect_permissions, foreground_context,
2223
permission_to_str, AppContext, PermissionKind, PermissionState, PermissionStatus,
@@ -202,6 +203,7 @@ impl AccessibilityEngine {
202203
screen_recording: PermissionState::Unsupported,
203204
accessibility: PermissionState::Unsupported,
204205
input_monitoring: PermissionState::Unsupported,
206+
microphone: PermissionState::Unsupported,
205207
});
206208
}
207209

@@ -220,27 +222,32 @@ impl AccessibilityEngine {
220222
&self,
221223
permission: PermissionKind,
222224
) -> Result<PermissionStatus, String> {
223-
if !cfg!(target_os = "macos") {
225+
// Microphone permission is cross-platform; other permissions are macOS-only.
226+
if matches!(permission, PermissionKind::Microphone) {
227+
request_microphone_access();
228+
} else if !cfg!(target_os = "macos") {
224229
return Ok(PermissionStatus {
225230
screen_recording: PermissionState::Unsupported,
226231
accessibility: PermissionState::Unsupported,
227232
input_monitoring: PermissionState::Unsupported,
233+
microphone: PermissionState::Unsupported,
228234
});
229-
}
230-
231-
#[cfg(target_os = "macos")]
232-
{
233-
match permission {
234-
PermissionKind::ScreenRecording => {
235-
request_screen_recording_access();
236-
open_macos_privacy_pane("Privacy_ScreenCapture");
237-
}
238-
PermissionKind::Accessibility => {
239-
request_accessibility_access();
240-
open_macos_privacy_pane("Privacy_Accessibility");
241-
}
242-
PermissionKind::InputMonitoring => {
243-
open_macos_privacy_pane("Privacy_ListenEvent");
235+
} else {
236+
#[cfg(target_os = "macos")]
237+
{
238+
match permission {
239+
PermissionKind::ScreenRecording => {
240+
request_screen_recording_access();
241+
open_macos_privacy_pane("Privacy_ScreenCapture");
242+
}
243+
PermissionKind::Accessibility => {
244+
request_accessibility_access();
245+
open_macos_privacy_pane("Privacy_Accessibility");
246+
}
247+
PermissionKind::InputMonitoring => {
248+
open_macos_privacy_pane("Privacy_ListenEvent");
249+
}
250+
PermissionKind::Microphone => unreachable!(),
244251
}
245252
}
246253
}

src/openhuman/screen_intelligence/state.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ impl EngineState {
5151
screen_recording: PermissionState::Unknown,
5252
accessibility: PermissionState::Unknown,
5353
input_monitoring: PermissionState::Unknown,
54+
microphone: PermissionState::Unknown,
5455
},
5556
features: AccessibilityFeatures {
5657
screen_monitoring: true,

src/openhuman/voice/audio_capture.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,38 @@ fn record_on_thread(
188188
stop_flag: Arc<AtomicBool>,
189189
setup_tx: std::sync::mpsc::SyncSender<Result<(), String>>,
190190
) -> Result<RecordingResult, String> {
191+
// --- Cross-platform microphone permission pre-check ---
192+
use crate::openhuman::accessibility::{
193+
detect_microphone_permission, microphone_denied_message, request_microphone_access,
194+
PermissionState,
195+
};
196+
197+
let mic_perm = detect_microphone_permission();
198+
debug!("{LOG_PREFIX} microphone permission state: {mic_perm:?}");
199+
200+
match mic_perm {
201+
PermissionState::Unknown => {
202+
info!("{LOG_PREFIX} microphone permission not yet determined — requesting access");
203+
request_microphone_access();
204+
// Re-check after request (macOS may have shown a prompt).
205+
let updated = detect_microphone_permission();
206+
debug!("{LOG_PREFIX} microphone permission after request: {updated:?}");
207+
if matches!(updated, PermissionState::Denied | PermissionState::Unknown) {
208+
let msg = microphone_denied_message();
209+
error!("{LOG_PREFIX} {msg}");
210+
let _ = setup_tx.send(Err(msg.clone()));
211+
return Err(msg);
212+
}
213+
}
214+
PermissionState::Denied => {
215+
let msg = microphone_denied_message();
216+
error!("{LOG_PREFIX} {msg}");
217+
let _ = setup_tx.send(Err(msg.clone()));
218+
return Err(msg);
219+
}
220+
_ => {} // Granted or Unsupported — proceed normally.
221+
}
222+
191223
let host = cpal::default_host();
192224
let device = host
193225
.default_input_device()

0 commit comments

Comments
 (0)