mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* 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:
co-authored by
Claude Opus 4.6
Cyrus Gray
parent
49cbbbebaf
commit
8e8da17ad9
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"entitlements": "entitlements.sidecar.plist",
|
||||
"infoPlist": "Info.plist",
|
||||
"dmg": {
|
||||
"background": "./images/background-dmg.png"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user