Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/desktop-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions desktop/src-tauri/SoCa.entitlements
Original file line number Diff line number Diff line change
@@ -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>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
78 changes: 54 additions & 24 deletions desktop/src-tauri/src/microphone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MicrophonePermission, String> {
use std::sync::mpsc;
use std::time::Duration;

fn begin_microphone_access(
sender: std::sync::mpsc::SyncSender<Result<MicrophonePermission, String>>,
) {
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<MicrophonePermission, String>>,
) -> Result<MicrophonePermission, String> {
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(),
Expand All @@ -95,15 +108,32 @@ pub fn request_microphone_access() -> Result<MicrophonePermission, String> {
/// 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<MicrophonePermission, String> {
fn request_microphone_access() -> Result<MicrophonePermission, String> {
Ok(MicrophonePermission::Authorized)
}

#[tauri::command]
pub async fn microphone_request_access() -> Result<MicrophonePermission, String> {
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<MicrophonePermission, String> {
#[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)]
Expand Down
3 changes: 2 additions & 1 deletion desktop/src-tauri/tauri.package.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"bundle": {
"resources": ["resources/soca-engine/**/*"],
"macOS": {
"signingIdentity": "-"
"signingIdentity": "-",
"entitlements": "SoCa.entitlements"
}
}
}
6 changes: 5 additions & 1 deletion scripts/write_tauri_release_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions tests/test_desktop_macos_microphone_plist.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
import plistlib
from pathlib import Path

Expand All @@ -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
1 change: 1 addition & 0 deletions tests/test_tauri_release_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
Loading