From 43983063dcf8fa793f6baa599d826a6286e7bac5 Mon Sep 17 00:00:00 2001 From: Minh Thinh <22280087@student.hcmus.edu.vn> Date: Wed, 26 Aug 2026 19:03:28 +0700 Subject: [PATCH 1/3] fix: request macOS microphone consent from the app bundle --- .github/workflows/desktop-package.yml | 4 + desktop/src-tauri/SoCa.entitlements | 8 ++ desktop/src-tauri/src/microphone.rs | 78 ++++++++++++++------ desktop/src-tauri/tauri.package.conf.json | 3 +- tests/test_desktop_macos_microphone_plist.py | 15 ++++ 5 files changed, 83 insertions(+), 25 deletions(-) create mode 100644 desktop/src-tauri/SoCa.entitlements diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index df89166..e077ebb 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -74,6 +74,7 @@ jobs: tests/test_desktop_sidecar_build.py tests/test_engine_packaged_storage_flow.py tests/test_tauri_release_config.py + tests/test_desktop_macos_microphone_plist.py - name: Run desktop unit suite run: npm test --prefix desktop - name: Install linuxdeploy with host-driver exclusion support @@ -121,6 +122,9 @@ jobs: bundled_sidecar=desktop/src-tauri/target/release/bundle/macos/SoCa.app/Contents/Resources/resources/soca-engine/soca-engine test -f "$bundled_sidecar" test "$(shasum -a 256 "$sidecar_path" | awk '{print $1}')" = "$(shasum -a 256 "$bundled_sidecar" | awk '{print $1}')" + codesign -d --entitlements :- desktop/src-tauri/target/release/bundle/macos/SoCa.app/Contents/MacOS/soca-desktop 2>/dev/null \ + | plutil -extract com.apple.security.device.audio-input raw - \ + | grep -qx true sidecar_path="$bundled_sidecar" fi uv run python scripts/verify_desktop_sidecar.py --sidecar "$sidecar_path" diff --git a/desktop/src-tauri/SoCa.entitlements b/desktop/src-tauri/SoCa.entitlements new file mode 100644 index 0000000..f35f2d4 --- /dev/null +++ b/desktop/src-tauri/SoCa.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.audio-input + + + diff --git a/desktop/src-tauri/src/microphone.rs b/desktop/src-tauri/src/microphone.rs index 635a706..c54d5e8 100644 --- a/desktop/src-tauri/src/microphone.rs +++ b/desktop/src-tauri/src/microphone.rs @@ -42,44 +42,57 @@ fn from_native_status( } } -/// Request microphone permission from the actual macOS application bundle. +/// Begin microphone authorization on the native application main thread. /// -/// Tauri runs this command away from the WebView event loop, so waiting for the -/// system sheet does not freeze the UI. The wait is bounded and no sidecar is -/// launched unless macOS returns an explicit authorization result. +/// TCC attributes the request to the GUI bundle which calls AVFoundation. The +/// completion arrives asynchronously, so this function never blocks the main +/// thread while macOS displays its consent sheet. #[cfg(target_os = "macos")] -pub fn request_microphone_access() -> Result { - use std::sync::mpsc; - use std::time::Duration; - +fn begin_microphone_access( + sender: std::sync::mpsc::SyncSender>, +) { use block2::RcBlock; use objc2_av_foundation::{AVAuthorizationStatus, AVCaptureDevice, AVMediaTypeAudio}; let audio_media_type = unsafe { - AVMediaTypeAudio - .ok_or_else(|| "macOS did not provide the audio media type.".to_string())? + AVMediaTypeAudio.ok_or_else(|| "macOS did not provide the audio media type.".to_string()) + }; + let Ok(audio_media_type) = audio_media_type else { + let _ = sender.send(Err( + "macOS did not provide the audio media type.".to_string() + )); + return; }; let current = unsafe { AVCaptureDevice::authorizationStatusForMediaType(audio_media_type) }; if current != AVAuthorizationStatus::NotDetermined { - return from_native_status(current); + let _ = sender.send(from_native_status(current)); + return; } - let (sender, receiver) = mpsc::sync_channel(1); let completion = RcBlock::new(move |granted| { - // The receiver may already have timed out; the sender is intentionally + // The receiver may already have timed out; delivery is deliberately // best-effort because the system owns the completion timing. - let _ = sender.send(bool::from(granted)); + let permission = if bool::from(granted) { + MicrophonePermission::Authorized + } else { + MicrophonePermission::Denied + }; + let _ = sender.send(Ok(permission)); }); unsafe { AVCaptureDevice::requestAccessForMediaType_completionHandler(audio_media_type, &completion); } +} + +#[cfg(target_os = "macos")] +fn wait_for_microphone_access( + receiver: std::sync::mpsc::Receiver>, +) -> Result { + use std::sync::mpsc; + use std::time::Duration; match receiver.recv_timeout(Duration::from_secs(60)) { - Ok(true) => Ok(MicrophonePermission::Authorized), - Ok(false) => { - let final_status = unsafe { AVCaptureDevice::authorizationStatusForMediaType(audio_media_type) }; - from_native_status(final_status) - } + Ok(result) => result, Err(mpsc::RecvTimeoutError::Timeout) => Err( "Microphone permission is still waiting for a response. Finish the macOS prompt, then try again." .to_string(), @@ -95,15 +108,32 @@ pub fn request_microphone_access() -> Result { /// audio stack. This preserves a single frontend contract without claiming a /// macOS-specific permission result elsewhere. #[cfg(not(target_os = "macos"))] -pub fn request_microphone_access() -> Result { +fn request_microphone_access() -> Result { Ok(MicrophonePermission::Authorized) } #[tauri::command] -pub async fn microphone_request_access() -> Result { - tauri::async_runtime::spawn_blocking(request_microphone_access) - .await - .map_err(|error| format!("Microphone permission task failed: {error}"))? +pub async fn microphone_request_access( + app: tauri::AppHandle, +) -> Result { + #[cfg(target_os = "macos")] + { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + app.run_on_main_thread(move || begin_microphone_access(sender)) + .map_err(|error| { + format!("Could not start the microphone permission request: {error}") + })?; + + return tauri::async_runtime::spawn_blocking(move || wait_for_microphone_access(receiver)) + .await + .map_err(|error| format!("Microphone permission task failed: {error}"))?; + } + + #[cfg(not(target_os = "macos"))] + { + let _ = app; + request_microphone_access() + } } #[cfg(test)] diff --git a/desktop/src-tauri/tauri.package.conf.json b/desktop/src-tauri/tauri.package.conf.json index 2a5b5a1..f94b0fd 100644 --- a/desktop/src-tauri/tauri.package.conf.json +++ b/desktop/src-tauri/tauri.package.conf.json @@ -3,7 +3,8 @@ "bundle": { "resources": ["resources/soca-engine/**/*"], "macOS": { - "signingIdentity": "-" + "signingIdentity": "-", + "entitlements": "SoCa.entitlements" } } } diff --git a/tests/test_desktop_macos_microphone_plist.py b/tests/test_desktop_macos_microphone_plist.py index ae77a2f..d4ca8f3 100644 --- a/tests/test_desktop_macos_microphone_plist.py +++ b/tests/test_desktop_macos_microphone_plist.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import plistlib from pathlib import Path @@ -14,3 +15,17 @@ def test_macos_bundle_declares_its_microphone_purpose() -> None: purpose = payload.get("NSMicrophoneUsageDescription") assert isinstance(purpose, str) assert purpose.strip() + + +def test_macos_bundle_signs_the_audio_input_entitlement() -> None: + """The usage description alone cannot obtain microphone access on macOS.""" + + root = Path(__file__).resolve().parents[1] + config_path = root / "desktop" / "src-tauri" / "tauri.package.conf.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + entitlements_path = root / "desktop" / "src-tauri" / config["bundle"]["macOS"]["entitlements"] + + with entitlements_path.open("rb") as source: + entitlements = plistlib.load(source) + + assert entitlements["com.apple.security.device.audio-input"] is True From b349a790020a8f7375fd837b8054f489d12c2191 Mon Sep 17 00:00:00 2001 From: Minh Thinh <22280087@student.hcmus.edu.vn> Date: Wed, 26 Aug 2026 19:07:42 +0700 Subject: [PATCH 2/3] fix: preserve microphone entitlement in signed releases --- .github/workflows/desktop-release.yml | 10 ++++++++++ scripts/write_tauri_release_config.py | 6 +++++- tests/test_tauri_release_config.py | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 9b1ac56..bdd730b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -118,3 +118,13 @@ jobs: releaseBody: See RELEASE_NOTES.md in the source release for privacy, retention, install, and rollback information. releaseDraft: true prerelease: false + - name: Verify signed macOS microphone entitlement + if: matrix.platform == 'darwin' + shell: bash + run: | + app=desktop/src-tauri/target/release/bundle/macos/SoCa.app + test -f "$app/Contents/MacOS/soca-desktop" + codesign --verify --deep --strict "$app" + codesign -d --entitlements :- "$app/Contents/MacOS/soca-desktop" 2>/dev/null \ + | plutil -extract com.apple.security.device.audio-input raw - \ + | grep -qx true diff --git a/scripts/write_tauri_release_config.py b/scripts/write_tauri_release_config.py index def0d5a..22f7160 100644 --- a/scripts/write_tauri_release_config.py +++ b/scripts/write_tauri_release_config.py @@ -9,6 +9,7 @@ from pathlib import Path UPDATE_ENDPOINT = "https://github.com/finalflash159/soca/releases/latest/download/latest.json" +MACOS_ENTITLEMENTS = "src-tauri/SoCa.entitlements" def release_config(environment: dict[str, str], *, platform: str) -> dict[str, object]: @@ -24,7 +25,10 @@ def release_config(environment: dict[str, str], *, platform: str) -> dict[str, o identity = environment.get("APPLE_SIGNING_IDENTITY", "").strip() if not identity: raise ValueError("APPLE_SIGNING_IDENTITY is required for a macOS release") - bundle["macOS"] = {"signingIdentity": identity} + bundle["macOS"] = { + "signingIdentity": identity, + "entitlements": MACOS_ENTITLEMENTS, + } elif platform == "win32": thumbprint = environment.get("WINDOWS_CERTIFICATE_THUMBPRINT", "").strip() timestamp = environment.get("WINDOWS_TIMESTAMP_URL", "").strip() diff --git a/tests/test_tauri_release_config.py b/tests/test_tauri_release_config.py index ebaf962..a88e6df 100644 --- a/tests/test_tauri_release_config.py +++ b/tests/test_tauri_release_config.py @@ -41,6 +41,7 @@ def test_release_config_uses_signed_github_updates_and_platform_signing() -> Non ) assert macos["bundle"]["createUpdaterArtifacts"] is True + assert macos["bundle"]["macOS"]["entitlements"] == module.MACOS_ENTITLEMENTS assert macos["plugins"]["updater"]["endpoints"] == [module.UPDATE_ENDPOINT] assert windows["bundle"]["windows"]["certificateThumbprint"] == "ABCD" assert windows["plugins"]["updater"]["windows"] == {"installMode": "passive"} From 21078140667c7013950a8c0b600d377fd578ba46 Mon Sep 17 00:00:00 2001 From: Minh Thinh <22280087@student.hcmus.edu.vn> Date: Wed, 16 Sep 2026 23:36:24 +0700 Subject: [PATCH 3/3] fix: verify macOS microphone entitlement --- .github/workflows/desktop-package.yml | 7 ++++--- .github/workflows/desktop-release.yml | 7 ++++--- desktop/src-tauri/src/microphone.rs | 4 ++-- tests/test_desktop_macos_microphone_plist.py | 13 +++++++++++++ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index e077ebb..b30ed3e 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -122,9 +122,10 @@ jobs: bundled_sidecar=desktop/src-tauri/target/release/bundle/macos/SoCa.app/Contents/Resources/resources/soca-engine/soca-engine test -f "$bundled_sidecar" test "$(shasum -a 256 "$sidecar_path" | awk '{print $1}')" = "$(shasum -a 256 "$bundled_sidecar" | awk '{print $1}')" - codesign -d --entitlements :- desktop/src-tauri/target/release/bundle/macos/SoCa.app/Contents/MacOS/soca-desktop 2>/dev/null \ - | plutil -extract com.apple.security.device.audio-input raw - \ - | grep -qx true + entitlements_file="$RUNNER_TEMP/soca-package-entitlements.plist" + codesign -d --entitlements :- desktop/src-tauri/target/release/bundle/macos/SoCa.app/Contents/MacOS/soca-desktop \ + > "$entitlements_file" 2>/dev/null + test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements_file")" = true sidecar_path="$bundled_sidecar" fi uv run python scripts/verify_desktop_sidecar.py --sidecar "$sidecar_path" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index bdd730b..93ae426 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -125,6 +125,7 @@ jobs: app=desktop/src-tauri/target/release/bundle/macos/SoCa.app test -f "$app/Contents/MacOS/soca-desktop" codesign --verify --deep --strict "$app" - codesign -d --entitlements :- "$app/Contents/MacOS/soca-desktop" 2>/dev/null \ - | plutil -extract com.apple.security.device.audio-input raw - \ - | grep -qx true + entitlements_file="$RUNNER_TEMP/soca-release-entitlements.plist" + codesign -d --entitlements :- "$app/Contents/MacOS/soca-desktop" \ + > "$entitlements_file" 2>/dev/null + test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements_file")" = true diff --git a/desktop/src-tauri/src/microphone.rs b/desktop/src-tauri/src/microphone.rs index c54d5e8..f5b3852 100644 --- a/desktop/src-tauri/src/microphone.rs +++ b/desktop/src-tauri/src/microphone.rs @@ -124,9 +124,9 @@ pub async fn microphone_request_access( format!("Could not start the microphone permission request: {error}") })?; - return tauri::async_runtime::spawn_blocking(move || wait_for_microphone_access(receiver)) + tauri::async_runtime::spawn_blocking(move || wait_for_microphone_access(receiver)) .await - .map_err(|error| format!("Microphone permission task failed: {error}"))?; + .map_err(|error| format!("Microphone permission task failed: {error}"))? } #[cfg(not(target_os = "macos"))] diff --git a/tests/test_desktop_macos_microphone_plist.py b/tests/test_desktop_macos_microphone_plist.py index d4ca8f3..d9be13a 100644 --- a/tests/test_desktop_macos_microphone_plist.py +++ b/tests/test_desktop_macos_microphone_plist.py @@ -29,3 +29,16 @@ def test_macos_bundle_signs_the_audio_input_entitlement() -> None: entitlements = plistlib.load(source) assert entitlements["com.apple.security.device.audio-input"] is True + + +def test_macos_workflows_verify_the_literal_entitlement_key() -> None: + """Dotted entitlement names must not be treated as nested plist key paths.""" + + root = Path(__file__).resolve().parents[1] + for workflow_name in ("desktop-package.yml", "desktop-release.yml"): + workflow = (root / ".github" / "workflows" / workflow_name).read_text( + encoding="utf-8" + ) + assert "PlistBuddy" in workflow + assert "Print :com.apple.security.device.audio-input" in workflow + assert "plutil -extract com.apple.security.device.audio-input" not in workflow