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