diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index df89166..b30ed3e 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,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}')" + 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 9b1ac56..93ae426 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -118,3 +118,14 @@ 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" + 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/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..f5b3852 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}") + })?; + + 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/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_desktop_macos_microphone_plist.py b/tests/test_desktop_macos_microphone_plist.py index ae77a2f..d9be13a 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,30 @@ 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 + + +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 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"}