fix(voice): cross-platform microphone permission handling (#489) (#491)

* fix(voice): add cross-platform microphone permission handling (#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 (#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>
This commit is contained in:
oxoxDev
2026-04-10 20:17:05 +05:30
committed by GitHub
co-authored by Claude Opus 4.6 Cyrus Gray
parent 49cbbbebaf
commit 8e8da17ad9
9 changed files with 194 additions and 17 deletions
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>OpenHuman uses the microphone for voice dictation — press the hotkey to record speech and transcribe it to text.</string>
</dict>
</plist>
+1
View File
@@ -8,6 +8,7 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<!-- Required for the core sidecar to make outbound HTTPS calls (registry fetch,
skill manifest + JS bundle downloads) under the macOS Hardened Runtime.
Without this, reqwest connections are silently blocked by the OS in signed
+1
View File
@@ -69,6 +69,7 @@
"macOS": {
"minimumSystemVersion": "10.15",
"entitlements": "entitlements.sidecar.plist",
"infoPlist": "Info.plist",
"dmg": {
"background": "./images/background-dmg.png"
}
+4 -1
View File
@@ -36,7 +36,10 @@ pub use permissions::{
detect_screen_recording_permission, open_macos_privacy_pane, request_accessibility_access,
request_screen_recording_access,
};
pub use permissions::{detect_permissions, permission_to_str};
pub use permissions::{
detect_microphone_permission, detect_permissions, microphone_denied_message, permission_to_str,
request_microphone_access,
};
pub use terminal::{
extract_terminal_input_context, is_terminal_app, is_text_role, looks_like_terminal_buffer,
};
+122
View File
@@ -60,6 +60,7 @@ pub fn permission_to_str(permission: PermissionKind) -> &'static str {
PermissionKind::ScreenRecording => "screen_recording",
PermissionKind::Accessibility => "accessibility",
PermissionKind::InputMonitoring => "input_monitoring",
PermissionKind::Microphone => "microphone",
}
}
@@ -129,12 +130,132 @@ pub fn detect_input_monitoring_permission() -> PermissionState {
}
}
// ---------------------------------------------------------------------------
// Microphone permission — cross-platform
// ---------------------------------------------------------------------------
/// Detect whether the app has microphone permission.
///
/// Uses CPAL device probing as a cross-platform permission proxy:
/// - If `default_input_device()` returns a device, access is available.
/// - If it returns `None`, either permission is denied or no mic is connected.
///
/// On **macOS** under hardened runtime, CPAL will fail to enumerate input
/// devices when the `com.apple.security.device.audio-input` entitlement is
/// missing or microphone permission is denied in System Settings.
///
/// On **Windows**, `None` may indicate a privacy toggle denial or no hardware.
///
/// **Linux** standard desktops don't enforce per-app permissions; Flatpak/Snap
/// sandboxes are detected separately.
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub fn detect_microphone_permission() -> PermissionState {
use cpal::traits::HostTrait;
let host = cpal::default_host();
match host.default_input_device() {
Some(device) => {
let name =
cpal::traits::DeviceTrait::name(&device).unwrap_or_else(|_| "<unknown>".into());
log::debug!("[permissions] microphone access available — device: {name}");
PermissionState::Granted
}
None => {
log::debug!(
"[permissions] no default input device — possible permission denial or no mic connected"
);
PermissionState::Unknown
}
}
}
#[cfg(target_os = "linux")]
pub fn detect_microphone_permission() -> PermissionState {
// Standard Linux desktops (PulseAudio/PipeWire) don't enforce app-level mic permissions.
// Detect Flatpak sandbox — if sandboxed, probe CPAL as a permission proxy.
if std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/run/flatpak").exists() {
use cpal::traits::HostTrait;
let host = cpal::default_host();
match host.default_input_device() {
Some(_) => PermissionState::Granted,
None => {
log::debug!(
"[permissions] Linux (Flatpak): no default input device — possible sandbox restriction"
);
PermissionState::Denied
}
}
} else {
PermissionState::Granted
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
pub fn detect_microphone_permission() -> PermissionState {
PermissionState::Unsupported
}
/// Request microphone access from the operating system.
///
/// - **macOS**: Triggers the system permission prompt if status is `NotDetermined`.
/// Note: `AVCaptureDevice.requestAccess(for:)` is async in ObjC but we call the
/// synchronous authorization check — the system prompt is triggered by the check itself
/// when entitlements + usage description are present. Alternatively, opening the
/// Privacy pane guides the user.
/// - **Windows**: Opens the Privacy > Microphone settings page.
/// - **Linux**: No-op for standard installs; guidance for Flatpak in error messages.
#[cfg(target_os = "macos")]
pub fn request_microphone_access() {
log::debug!("[permissions] requesting macOS microphone access via Privacy pane");
open_macos_privacy_pane("Privacy_Microphone");
}
#[cfg(target_os = "windows")]
pub fn request_microphone_access() {
log::debug!("[permissions] opening Windows Privacy > Microphone settings");
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "ms-settings:privacy-microphone"])
.status();
}
#[cfg(target_os = "linux")]
pub fn request_microphone_access() {
log::debug!("[permissions] Linux: no programmatic mic permission request available");
// No-op — standard Linux desktops don't have an app-level permission gate.
// For Flatpak, the XDG Portal API (ashpd crate) could be used in the future.
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
pub fn request_microphone_access() {
// Unsupported platform — no-op.
}
/// Returns a platform-specific user-facing message when microphone permission is denied.
pub fn microphone_denied_message() -> String {
#[cfg(target_os = "macos")]
{
"Microphone permission denied. Grant access in System Settings > Privacy & Security > Microphone, then restart the app.".to_string()
}
#[cfg(target_os = "windows")]
{
"Microphone access unavailable. Check Settings > Privacy & Security > Microphone and ensure the app is allowed. If no microphone is connected, plug one in.".to_string()
}
#[cfg(target_os = "linux")]
{
"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()
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
"Microphone access is not supported on this platform.".to_string()
}
}
#[cfg(target_os = "macos")]
pub fn detect_permissions() -> PermissionStatus {
PermissionStatus {
screen_recording: detect_screen_recording_permission(),
accessibility: detect_accessibility_permission(),
input_monitoring: detect_input_monitoring_permission(),
microphone: detect_microphone_permission(),
}
}
@@ -144,5 +265,6 @@ pub fn detect_permissions() -> PermissionStatus {
screen_recording: PermissionState::Unsupported,
accessibility: PermissionState::Unsupported,
input_monitoring: PermissionState::Unsupported,
microphone: detect_microphone_permission(),
}
}
+2
View File
@@ -65,6 +65,7 @@ pub struct PermissionStatus {
pub screen_recording: PermissionState,
pub accessibility: PermissionState,
pub input_monitoring: PermissionState,
pub microphone: PermissionState,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -73,6 +74,7 @@ pub enum PermissionKind {
ScreenRecording,
Accessibility,
InputMonitoring,
Microphone,
}
#[cfg(test)]
+23 -16
View File
@@ -17,6 +17,7 @@ use super::types::{
AccessibilityStatus, AppContextInfo, CaptureFrame, CaptureImageRefResult, CaptureNowResult,
CaptureTestResult, CoreProcessStatus, SessionStatus, StartSessionParams,
};
use crate::openhuman::accessibility::request_microphone_access;
use crate::openhuman::accessibility::{
capture_screen_image_ref_for_context, detect_permissions, foreground_context,
permission_to_str, AppContext, PermissionKind, PermissionState, PermissionStatus,
@@ -202,6 +203,7 @@ impl AccessibilityEngine {
screen_recording: PermissionState::Unsupported,
accessibility: PermissionState::Unsupported,
input_monitoring: PermissionState::Unsupported,
microphone: PermissionState::Unsupported,
});
}
@@ -220,27 +222,32 @@ impl AccessibilityEngine {
&self,
permission: PermissionKind,
) -> Result<PermissionStatus, String> {
if !cfg!(target_os = "macos") {
// Microphone permission is cross-platform; other permissions are macOS-only.
if matches!(permission, PermissionKind::Microphone) {
request_microphone_access();
} else if !cfg!(target_os = "macos") {
return Ok(PermissionStatus {
screen_recording: PermissionState::Unsupported,
accessibility: PermissionState::Unsupported,
input_monitoring: PermissionState::Unsupported,
microphone: PermissionState::Unsupported,
});
}
#[cfg(target_os = "macos")]
{
match permission {
PermissionKind::ScreenRecording => {
request_screen_recording_access();
open_macos_privacy_pane("Privacy_ScreenCapture");
}
PermissionKind::Accessibility => {
request_accessibility_access();
open_macos_privacy_pane("Privacy_Accessibility");
}
PermissionKind::InputMonitoring => {
open_macos_privacy_pane("Privacy_ListenEvent");
} else {
#[cfg(target_os = "macos")]
{
match permission {
PermissionKind::ScreenRecording => {
request_screen_recording_access();
open_macos_privacy_pane("Privacy_ScreenCapture");
}
PermissionKind::Accessibility => {
request_accessibility_access();
open_macos_privacy_pane("Privacy_Accessibility");
}
PermissionKind::InputMonitoring => {
open_macos_privacy_pane("Privacy_ListenEvent");
}
PermissionKind::Microphone => unreachable!(),
}
}
}
@@ -51,6 +51,7 @@ impl EngineState {
screen_recording: PermissionState::Unknown,
accessibility: PermissionState::Unknown,
input_monitoring: PermissionState::Unknown,
microphone: PermissionState::Unknown,
},
features: AccessibilityFeatures {
screen_monitoring: true,
+32
View File
@@ -188,6 +188,38 @@ fn record_on_thread(
stop_flag: Arc<AtomicBool>,
setup_tx: std::sync::mpsc::SyncSender<Result<(), String>>,
) -> Result<RecordingResult, String> {
// --- Cross-platform microphone permission pre-check ---
use crate::openhuman::accessibility::{
detect_microphone_permission, microphone_denied_message, request_microphone_access,
PermissionState,
};
let mic_perm = detect_microphone_permission();
debug!("{LOG_PREFIX} microphone permission state: {mic_perm:?}");
match mic_perm {
PermissionState::Unknown => {
info!("{LOG_PREFIX} microphone permission not yet determined — requesting access");
request_microphone_access();
// Re-check after request (macOS may have shown a prompt).
let updated = detect_microphone_permission();
debug!("{LOG_PREFIX} microphone permission after request: {updated:?}");
if matches!(updated, PermissionState::Denied | PermissionState::Unknown) {
let msg = microphone_denied_message();
error!("{LOG_PREFIX} {msg}");
let _ = setup_tx.send(Err(msg.clone()));
return Err(msg);
}
}
PermissionState::Denied => {
let msg = microphone_denied_message();
error!("{LOG_PREFIX} {msg}");
let _ = setup_tx.send(Err(msg.clone()));
return Err(msg);
}
_ => {} // Granted or Unsupported — proceed normally.
}
let host = cpal::default_host();
let device = host
.default_input_device()