diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fa9c9e6..d14e5b4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -63,9 +63,17 @@ jobs: # No Apple Developer ID yet; ad-hoc (`--sign -`) is enough to make # Gatekeeper let users open the app via right-click → Open. Once a # cert is available, swap in the real identity and add notarization. + # + # The entitlements file is required even for ad-hoc signing because + # we enable hardened runtime (--options runtime). Without + # `com.apple.security.device.audio-input`, AVCaptureDevice and + # AVAudioApplication silently return "denied" for the mic + # permission request — no OS prompt, no TCC entry. run: | set -euo pipefail - codesign --force --deep --options runtime --sign - Wisp.app + codesign --force --deep --options runtime \ + --entitlements apps/wisp-desktop/wisp-desktop.entitlements \ + --sign - Wisp.app codesign --verify --verbose Wisp.app - name: Create DMG diff --git a/apps/wisp-desktop/src/app.rs b/apps/wisp-desktop/src/app.rs index e640b87..a629896 100644 --- a/apps/wisp-desktop/src/app.rs +++ b/apps/wisp-desktop/src/app.rs @@ -11,7 +11,9 @@ use std::collections::VecDeque; use std::time::Instant; -use wisp_audiokit::{Event, SessionError, SessionResult, SourceLabel}; +use wisp_audiokit::{ + Event, Permission, PermissionStatus, SessionError, SessionResult, SourceLabel, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionState { @@ -22,6 +24,46 @@ pub enum SessionState { Failed, } +/// Snapshot of every permission Wisp gates Record on. +/// +/// The UI is allowed to enter the main transcript view once both fields +/// are `Granted`. While `pending` is `Some(p)`, a previous +/// `request_permission(p)` call is still waiting on the OS dialog — the +/// onboarding row for that permission shows a spinner instead of a button. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Permissions { + pub microphone: PermissionStatus, + pub speech: PermissionStatus, + pub pending: Option, +} + +impl Permissions { + pub fn unknown() -> Self { + Self { + microphone: PermissionStatus::Undetermined, + speech: PermissionStatus::Undetermined, + pending: None, + } + } + + /// True when both required permissions are granted; the UI can show + /// the normal Record screen. + pub fn all_granted(self) -> bool { + self.microphone.is_granted() && self.speech.is_granted() + } + + pub fn set_status( + &mut self, + perm: Permission, + status: PermissionStatus, + ) { + match perm { + Permission::Microphone => self.microphone = status, + Permission::SpeechRecognition => self.speech = status, + } + } +} + #[derive(Debug, Clone)] pub struct Segment { pub source: SourceLabel, @@ -43,6 +85,7 @@ pub struct AppModel { pub segments: Vec, pub recent_log: VecDeque, pub last_error: Option, + pub permissions: Permissions, } impl AppModel { @@ -52,6 +95,7 @@ impl AppModel { segments: Vec::new(), recent_log: VecDeque::new(), last_error: None, + permissions: Permissions::unknown(), } } diff --git a/apps/wisp-desktop/src/main.rs b/apps/wisp-desktop/src/main.rs index 98eac70..8776c89 100644 --- a/apps/wisp-desktop/src/main.rs +++ b/apps/wisp-desktop/src/main.rs @@ -20,13 +20,15 @@ use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use gpui::{ - AppContext, Application, AsyncApp, Bounds, Timer, TitlebarOptions, WindowBounds, WindowOptions, - px, size, + App, AppContext, Application, AsyncApp, Bounds, Entity, Timer, TitlebarOptions, WindowBounds, + WindowHandle, WindowOptions, px, size, }; mod app; +mod permissions; mod session_runner; mod transcript_view; @@ -34,6 +36,13 @@ use app::{AppModel, SessionState}; use session_runner::{SessionRunner, Update}; use transcript_view::{TranscriptView, cursor_blink_period, now, ui_tick_period}; +/// How often we re-poll permission status from the OS while the +/// onboarding screen is up. The user might flip the toggle in System +/// Settings; without periodic re-checks we'd stay stuck on "Denied" until +/// they manually re-focus our window. 1.5s is unhurried but still feels +/// responsive when they come back. +const PERMISSION_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); + fn main() { Application::new().run(|cx| { cx.activate(true); @@ -49,90 +58,142 @@ fn main() { ..Default::default() }; - let window = { - let runner = runner.clone(); - let model_ref = model.clone(); - let output_dir = output_dir.clone(); - cx.open_window(window_options, move |_, cx| { - cx.new(|cx| { - let runner = runner.clone(); - let model_ref = model_ref.clone(); - let output_dir = output_dir.clone(); - let view = TranscriptView { - app: model_ref.clone(), - cursor_visible: true, - scroll_handle: gpui::ScrollHandle::new(), - last_signature: (0, 0), - on_toggle_record: Arc::new(move |_window, cx| { - toggle_recording(&runner, &model_ref, &output_dir, cx); - }), - }; - // Re-render whenever the underlying model changes. - cx.observe(&view.app, |_, _, cx| cx.notify()).detach(); - view - }) - }) - .expect("failed to open Wisp window") - }; + // Populate the model with the initial permission state so the + // window opens straight onto onboarding or the record screen, + // without a flash of the wrong content. + permissions::refresh(&model, cx); - // 1) Pump session-runner updates into the model. - { - let runner = runner.clone(); - let model = model.clone(); - cx.spawn(async move |cx: &mut AsyncApp| { - loop { - Timer::after(std::time::Duration::from_millis(33)).await; - let updates = runner.drain_updates(); - if updates.is_empty() { - continue; - } - let result = model.update(cx, |model, cx| { - for u in updates { - match u { - Update::Started => { - model.set_state(SessionState::Recording { started_at: now() }); - }, - Update::Event(e) => model.ingest(e), - Update::Stopped => { - // Lock in whatever the analyzer last - // had — without this the trailing - // partial stays grey forever. - model.finalize_all_segments(); - model.set_state(SessionState::Idle); - }, - Update::Error(msg) => model.fail(msg), - } - } - cx.notify(); - }); - if result.is_err() { - // Window / app gone; stop pumping. - break; + let window = open_main_window( + cx, + window_options, + runner.clone(), + model.clone(), + output_dir, + ); + + spawn_session_update_pump(cx, runner, model.clone()); + spawn_cursor_blink(cx, window); + spawn_permission_refresh(cx, model); + }); +} + +fn open_main_window( + cx: &mut App, + window_options: WindowOptions, + runner: Arc, + model: Entity, + output_dir: PathBuf, +) -> WindowHandle { + cx.open_window(window_options, move |_, cx| { + cx.new(|cx| { + let model_for_toggle = model.clone(); + let model_for_request = model.clone(); + let view = TranscriptView { + app: model.clone(), + cursor_visible: true, + scroll_handle: gpui::ScrollHandle::new(), + last_signature: (0, 0), + on_toggle_record: Arc::new(move |_window, cx| { + toggle_recording(&runner, &model_for_toggle, &output_dir, cx); + }), + on_request_permission: Arc::new(move |perm, _window, cx| { + permissions::request(perm, model_for_request.clone(), cx); + }), + on_open_settings: Arc::new(move |perm, _window, _cx| { + permissions::open_settings(perm); + // The next periodic permission refresh picks up the + // toggle once the user flips it in System Settings. + }), + }; + // Re-render whenever the underlying model changes. + cx.observe(&view.app, |_, _, cx| cx.notify()).detach(); + view + }) + }) + .expect("failed to open Wisp window") +} + +/// Drain `SessionRunner` updates into the model every ~33ms. +fn spawn_session_update_pump( + cx: &mut App, + runner: Arc, + model: Entity, +) { + cx.spawn(async move |cx: &mut AsyncApp| { + loop { + Timer::after(Duration::from_millis(33)).await; + let updates = runner.drain_updates(); + if updates.is_empty() { + continue; + } + let result = model.update(cx, |model, cx| { + for u in updates { + match u { + Update::Started => { + model.set_state(SessionState::Recording { started_at: now() }); + }, + Update::Event(e) => model.ingest(e), + Update::Stopped => { + // Lock in whatever the analyzer last had — without + // this the trailing partial stays grey forever. + model.finalize_all_segments(); + model.set_state(SessionState::Idle); + }, + Update::Error(msg) => model.fail(msg), } } - }) - .detach(); + cx.notify(); + }); + if result.is_err() { + break; + } } + }) + .detach(); +} - // 2) Cursor blink + status-bar tick. - cx.spawn(async move |cx: &mut AsyncApp| { - let mut elapsed = std::time::Duration::ZERO; - loop { - Timer::after(ui_tick_period()).await; - elapsed += ui_tick_period(); - let ticks = elapsed.as_millis() / cursor_blink_period().as_millis(); - let blink = ticks.is_multiple_of(2); - let result = window.update(cx, |view, _, cx| { - view.cursor_visible = blink; - cx.notify(); - }); - if result.is_err() { - break; - } +/// Toggle the ghost-text cursor and refresh the status-bar elapsed counter. +fn spawn_cursor_blink( + cx: &mut App, + window: WindowHandle, +) { + cx.spawn(async move |cx: &mut AsyncApp| { + let mut elapsed = Duration::ZERO; + loop { + Timer::after(ui_tick_period()).await; + elapsed += ui_tick_period(); + let ticks = elapsed.as_millis() / cursor_blink_period().as_millis(); + let blink = ticks.is_multiple_of(2); + let result = window.update(cx, |view, _, cx| { + view.cursor_visible = blink; + cx.notify(); + }); + if result.is_err() { + break; } - }) - .detach(); - }); + } + }) + .detach(); +} + +/// Re-read permission state from the OS on a fixed interval. The user may +/// have flipped a toggle in System Settings; we have no event-driven way +/// to learn about that, so we poll. Cheap (two +/// `AVAudioApplication`/`SFSpeechRecognizer` getters). +fn spawn_permission_refresh( + cx: &mut App, + model: Entity, +) { + cx.spawn(async move |cx: &mut AsyncApp| { + loop { + Timer::after(PERMISSION_REFRESH_INTERVAL).await; + let result = cx.update(|cx| permissions::refresh(&model, cx)); + if result.is_err() { + break; + } + } + }) + .detach(); } fn toggle_recording( diff --git a/apps/wisp-desktop/src/permissions.rs b/apps/wisp-desktop/src/permissions.rs new file mode 100644 index 0000000..2513071 --- /dev/null +++ b/apps/wisp-desktop/src/permissions.rs @@ -0,0 +1,111 @@ +//! Glue between `wisp_audiokit`'s blocking permission API and the GPUI +//! main loop. +//! +//! The Swift-side `wisp_permission_request` blocks the calling thread on +//! a `DispatchSemaphore` until the user dismisses the OS dialog. Calling +//! that directly from a render callback would freeze the window, so we +//! ship the work to `cx.background_executor()` and write the result back +//! onto the `AppModel` from the main async context. + +use std::process::Command; + +use gpui::{App, AsyncApp, Entity}; +use wisp_audiokit::{Permission, PermissionStatus, check_permission, request_permission}; + +use crate::app::AppModel; + +/// Read the current OS-side status of both permissions and write them +/// into the model. Used at app launch and after the user returns from +/// System Settings (we re-check on every UI tick — see `main.rs`). +pub fn refresh( + model: &Entity, + cx: &mut App, +) { + let microphone = check_permission(Permission::Microphone); + let speech = check_permission(Permission::SpeechRecognition); + model.update(cx, |m, cx| { + let changed = m.permissions.microphone != microphone || m.permissions.speech != speech; + m.permissions.microphone = microphone; + m.permissions.speech = speech; + if changed { + cx.notify(); + } + }); +} + +/// Kick off an OS permission prompt for `perm` on a background thread, +/// write the resulting status back into the model, and clear the pending +/// flag. Marks `perm` pending immediately so the UI can show a spinner. +pub fn request( + perm: Permission, + model: Entity, + cx: &mut App, +) { + // Already in flight — ignore re-entrant clicks. + if model.read(cx).permissions.pending.is_some() { + return; + } + model.update(cx, |m, cx| { + m.permissions.pending = Some(perm); + cx.notify(); + }); + + cx.spawn(async move |cx: &mut AsyncApp| { + // The Swift call blocks for the lifetime of the dialog; run it on + // the background pool so the GPUI main thread stays responsive + // (animations, status bar tick, etc.). + let status = cx + .background_executor() + .spawn(async move { request_permission(perm) }) + .await; + let _ = model.update(cx, |m, cx| { + m.permissions.set_status(perm, status); + m.permissions.pending = None; + cx.notify(); + }); + }) + .detach(); +} + +/// Open the right System Settings → Privacy & Security pane for `perm`. +/// Used when the permission is already `Denied`, because in that state +/// `request_permission` is a no-op and only the user can re-enable it. +pub fn open_settings(perm: Permission) { + let url = match perm { + Permission::Microphone => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" + }, + Permission::SpeechRecognition => { + "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition" + }, + }; + // Best-effort: if `open` fails (e.g. URL scheme not registered) there's + // nothing useful we can show the user without their attention here. + let _ = Command::new("open").arg(url).spawn(); +} + +/// Human-readable label for an onboarding row. +pub fn label(perm: Permission) -> &'static str { + match perm { + Permission::Microphone => "Microphone", + Permission::SpeechRecognition => "Speech Recognition", + } +} + +/// One-sentence rationale shown under the row title. +pub fn rationale(perm: Permission) -> &'static str { + match perm { + Permission::Microphone => "Capture your voice for on-device transcription.", + Permission::SpeechRecognition => "Run Apple's on-device speech model on captured audio.", + } +} + +/// Short status label rendered on the right of an onboarding row. +pub fn status_label(status: PermissionStatus) -> &'static str { + match status { + PermissionStatus::Undetermined => "Not requested", + PermissionStatus::Denied => "Denied", + PermissionStatus::Granted => "Granted", + PermissionStatus::Restricted => "Restricted", + } +} diff --git a/apps/wisp-desktop/src/transcript_view.rs b/apps/wisp-desktop/src/transcript_view.rs index be3018f..3f4daf4 100644 --- a/apps/wisp-desktop/src/transcript_view.rs +++ b/apps/wisp-desktop/src/transcript_view.rs @@ -14,13 +14,21 @@ use gpui::{ Context, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, Render, ScrollHandle, StatefulInteractiveElement, Styled, Window, div, px, rgb, }; -use wisp_audiokit::{SessionError, SourceLabel}; +use wisp_audiokit::{Permission, PermissionStatus, SessionError, SourceLabel}; -use crate::app::{AppModel, Segment, SessionState}; +use crate::app::{AppModel, Permissions, Segment, SessionState}; +use crate::permissions as perms; pub struct TranscriptView { pub app: gpui::Entity, pub on_toggle_record: std::sync::Arc, + /// Request a permission. Fires the OS prompt asynchronously; the + /// resulting status flows back into the model. + pub on_request_permission: + std::sync::Arc, + /// Open the System Settings privacy pane for a permission. Used when + /// the permission is already denied and only the user can re-enable it. + pub on_open_settings: std::sync::Arc, /// Toggled by the cursor-blink animation timer in main.rs so the /// ghost-text caret pulses. pub cursor_visible: bool, @@ -75,6 +83,17 @@ impl Render for TranscriptView { cx: &mut Context, ) -> impl IntoElement { let app = self.app.read(cx); + let permissions = app.permissions; + + // Gate the main UI on having both required permissions. Until then, + // we show an onboarding screen with per-permission rows the user + // can act on. This avoids the previous failure mode where the user + // presses Record and only then learns the app needs permissions + // they may or may not be able to grant. + if !permissions.all_granted() { + return self.render_onboarding(permissions).into_any_element(); + } + let segments = app.segments.clone(); let active_idx = app.active_segment_index(); let state = app.state; @@ -115,6 +134,7 @@ impl Render for TranscriptView { log_count, last_error.as_ref(), )) + .into_any_element() } } @@ -135,6 +155,199 @@ impl TranscriptView { .child(render_brand()) .child(render_record_button(state, toggle)) } + + fn render_onboarding( + &self, + permissions: Permissions, + ) -> impl IntoElement { + let pending = permissions.pending; + let row_mic = self.render_permission_row( + Permission::Microphone, + permissions.microphone, + pending == Some(Permission::Microphone), + ); + let row_speech = self.render_permission_row( + Permission::SpeechRecognition, + permissions.speech, + pending == Some(Permission::SpeechRecognition), + ); + + let card = div() + .flex() + .flex_col() + .gap(px(16.0)) + .w(px(520.0)) + .p(px(24.0)) + .bg(theme::surface()) + .rounded(px(12.0)) + .border_1() + .border_color(theme::border()) + .child( + div() + .text_color(theme::text_primary()) + .font_weight(FontWeight::SEMIBOLD) + .child("Wisp needs a couple of permissions"), + ) + .child( + div() + .text_xs() + .text_color(theme::text_secondary()) + .child("These run entirely on-device. Wisp doesn't send your audio anywhere."), + ) + .child(row_mic) + .child(row_speech); + + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .size_full() + .bg(theme::bg()) + .text_color(theme::text_primary()) + .child(card) + } + + fn render_permission_row( + &self, + perm: Permission, + status: PermissionStatus, + is_pending: bool, + ) -> impl IntoElement { + let title_text = perms::label(perm); + let rationale_text = perms::rationale(perm); + let status_text = perms::status_label(status); + + let info = div() + .flex() + .flex_col() + .gap(px(4.0)) + .flex_grow() + .min_w_0() + .child( + div() + .text_color(theme::text_primary()) + .font_weight(FontWeight::MEDIUM) + .child(title_text), + ) + .child( + div() + .text_xs() + .text_color(theme::text_tertiary()) + .child(rationale_text), + ) + .child( + div() + .text_xs() + .text_color(status_color(status)) + .child(status_text), + ); + + let action = self.render_permission_action(perm, status, is_pending); + + div() + .flex() + .items_center() + .gap(px(12.0)) + .py(px(12.0)) + .px(px(12.0)) + .bg(theme::bg()) + .rounded(px(8.0)) + .border_l_2() + .border_color(status_color(status)) + .child(info) + .child(action) + } + + fn render_permission_action( + &self, + perm: Permission, + status: PermissionStatus, + is_pending: bool, + ) -> gpui::AnyElement { + // Already granted — nothing to do; render a static check label so + // the row stays balanced. + if status == PermissionStatus::Granted { + return div() + .px(px(14.0)) + .py(px(7.0)) + .text_sm() + .text_color(theme::text_tertiary()) + .child("Allowed") + .into_any_element(); + } + // Restricted means a system policy is preventing this; there is no + // user-facing toggle. Just label it. + if status == PermissionStatus::Restricted { + return div() + .px(px(14.0)) + .py(px(7.0)) + .text_sm() + .text_color(theme::text_tertiary()) + .child("Restricted") + .into_any_element(); + } + // A request is already in flight — show a non-interactive label. + if is_pending { + return div() + .px(px(14.0)) + .py(px(7.0)) + .text_sm() + .text_color(theme::text_tertiary()) + .child("Waiting…") + .into_any_element(); + } + + // Undetermined → can re-trigger the OS prompt. + // Denied → can't, OS won't prompt again; jump straight to Settings. + let (label, action_kind) = match status { + PermissionStatus::Denied => ("Open Settings", ActionKind::OpenSettings), + _ => ("Allow", ActionKind::Request), + }; + let on_request = self.on_request_permission.clone(); + let on_open = self.on_open_settings.clone(); + let id_label = match action_kind { + ActionKind::Request => "permission-allow", + ActionKind::OpenSettings => "permission-open-settings", + }; + // Element IDs must be unique per render tree; suffix with the + // permission discriminant so the two rows don't collide. + let suffix = match perm { + Permission::Microphone => "mic", + Permission::SpeechRecognition => "speech", + }; + let id = ElementId::Name(format!("{id_label}-{suffix}").into()); + div() + .id(id) + .px(px(14.0)) + .py(px(7.0)) + .rounded_full() + .bg(theme::record_idle()) + .text_color(theme::text_primary()) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .on_click(move |_event, window, cx| match action_kind { + ActionKind::Request => on_request(perm, window, cx), + ActionKind::OpenSettings => on_open(perm, window, cx), + }) + .child(label) + .into_any_element() + } +} + +#[derive(Debug, Clone, Copy)] +enum ActionKind { + Request, + OpenSettings, +} + +fn status_color(status: PermissionStatus) -> gpui::Rgba { + match status { + PermissionStatus::Granted => theme::mic_accent(), + PermissionStatus::Denied | PermissionStatus::Restricted => theme::record_red(), + PermissionStatus::Undetermined => theme::text_tertiary(), + } } fn render_brand() -> impl IntoElement { diff --git a/apps/wisp-desktop/wisp-desktop.entitlements b/apps/wisp-desktop/wisp-desktop.entitlements new file mode 100644 index 0000000..6c8021e --- /dev/null +++ b/apps/wisp-desktop/wisp-desktop.entitlements @@ -0,0 +1,33 @@ + + + + + + + + com.apple.security.device.audio-input + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.app-sandbox + + + diff --git a/crates/wisp-audiokit-sys/src/lib.rs b/crates/wisp-audiokit-sys/src/lib.rs index 6f8f21f..8c0f015 100644 --- a/crates/wisp-audiokit-sys/src/lib.rs +++ b/crates/wisp-audiokit-sys/src/lib.rs @@ -25,6 +25,18 @@ pub struct WispSession { pub const WISP_SOURCE_MIC: i32 = 0; pub const WISP_SOURCE_SYSTEM: i32 = 1; +/// Permission identifiers passed to [`wisp_permission_status`] / +/// [`wisp_permission_request`]. +pub const WISP_PERMISSION_MICROPHONE: i32 = 0; +pub const WISP_PERMISSION_SPEECH_RECOGNITION: i32 = 1; + +/// Status returned by [`wisp_permission_status`] / +/// [`wisp_permission_request`]. Negative values mean "invalid permission id". +pub const WISP_PERMISSION_STATUS_UNDETERMINED: i32 = 0; +pub const WISP_PERMISSION_STATUS_DENIED: i32 = 1; +pub const WISP_PERMISSION_STATUS_GRANTED: i32 = 2; +pub const WISP_PERMISSION_STATUS_RESTRICTED: i32 = 3; + /// Callback invoked for each transcription result. /// /// `text_utf8` is NOT NUL-terminated — use `text_len`. The pointer is valid @@ -74,4 +86,16 @@ unsafe extern "C" { /// Returns the last error message recorded against this session, or /// null. Invalidated by the next mutating call. pub fn wisp_session_last_error_message(session: *mut WispSession) -> *const c_char; + + /// Returns the current status of the given permission without prompting. + /// `permission` is one of `WISP_PERMISSION_*`; the return value is a + /// `WISP_PERMISSION_STATUS_*` value, or a negative number for an + /// unknown permission id. + pub fn wisp_permission_status(permission: i32) -> c_int; + + /// Trigger the OS permission prompt (only if the status is currently + /// undetermined) and block until the user responds. Returns the + /// resulting `WISP_PERMISSION_STATUS_*`. Safe to call from any thread — + /// the macOS APIs marshal the dialog to the main thread internally. + pub fn wisp_permission_request(permission: i32) -> c_int; } diff --git a/crates/wisp-audiokit/src/lib.rs b/crates/wisp-audiokit/src/lib.rs index 18101a3..c9a67e4 100644 --- a/crates/wisp-audiokit/src/lib.rs +++ b/crates/wisp-audiokit/src/lib.rs @@ -7,6 +7,39 @@ mod error; pub use error::{Result, SessionError}; +/// TCC-style OS permission gated by Wisp at startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Permission { + /// Microphone access. Required for the mic capture path. + Microphone, + /// On-device speech recognition. Required for both pipelines. + SpeechRecognition, +} + +/// Current state of a single [`Permission`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PermissionStatus { + /// The user has not been asked yet; calling [`request_permission`] will + /// trigger the OS dialog. + Undetermined, + /// The user explicitly denied this permission. Re-requesting won't show + /// a dialog — the user has to flip it in System Settings. + Denied, + /// Granted; the corresponding capture path can be used. + Granted, + /// Restricted by a system policy (e.g. parental controls). Only + /// reachable for `SpeechRecognition`. + Restricted, +} + +impl PermissionStatus { + /// Convenience: true iff the underlying capability is usable. + #[must_use] + pub fn is_granted(self) -> bool { + matches!(self, Self::Granted) + } +} + #[cfg(target_os = "macos")] mod imp { use std::ffi::{CStr, CString}; @@ -19,6 +52,50 @@ mod imp { use wisp_core::SourceLabel; use crate::error::{Result, SessionError}; + use crate::{Permission, PermissionStatus}; + + fn permission_to_raw(perm: Permission) -> i32 { + match perm { + Permission::Microphone => sys::WISP_PERMISSION_MICROPHONE, + Permission::SpeechRecognition => sys::WISP_PERMISSION_SPEECH_RECOGNITION, + } + } + + fn status_from_raw(raw: i32) -> PermissionStatus { + match raw { + sys::WISP_PERMISSION_STATUS_GRANTED => PermissionStatus::Granted, + sys::WISP_PERMISSION_STATUS_DENIED => PermissionStatus::Denied, + sys::WISP_PERMISSION_STATUS_RESTRICTED => PermissionStatus::Restricted, + // Treat negative ("invalid permission id") as undetermined too — + // we never pass an invalid id from safe Rust, and conflating the + // two keeps the surface tidy. + _ => PermissionStatus::Undetermined, + } + } + + /// Read the current status of `permission` from the OS. Never prompts. + #[must_use] + pub fn check_permission(permission: Permission) -> PermissionStatus { + // SAFETY: simple value-in, value-out call into Swift; no pointers. + let raw = unsafe { sys::wisp_permission_status(permission_to_raw(permission)) }; + status_from_raw(raw) + } + + /// Show the OS permission prompt for `permission` (only if the user has + /// not been asked yet) and block until they respond. Returns the + /// resulting status. If the status is already determined, returns it + /// immediately without prompting. + /// + /// Safe to call from any thread; the macOS APIs marshal the dialog to + /// the main thread internally. Callers from a UI event loop should run + /// this on a worker thread to keep the UI responsive while the user + /// reads the prompt. + #[must_use] + pub fn request_permission(permission: Permission) -> PermissionStatus { + // SAFETY: simple value-in, value-out call into Swift; no pointers. + let raw = unsafe { sys::wisp_permission_request(permission_to_raw(permission)) }; + status_from_raw(raw) + } /// `WispAudioKit` library version (e.g. `"0.1.0"`). /// @@ -274,6 +351,7 @@ mod imp { use wisp_core::SourceLabel; use crate::error::{Result, SessionError}; + use crate::{Permission, PermissionStatus}; /// `WispAudioKit` library version. Always empty on non-macOS targets. #[must_use] @@ -281,6 +359,20 @@ mod imp { "" } + /// Stub — always reports `Granted` on non-macOS targets so callers can + /// fall through to the (stubbed) session, which will then return + /// `UnsupportedPlatform`. Keeps the workspace buildable on Linux CI. + #[must_use] + pub fn check_permission(_permission: Permission) -> PermissionStatus { + PermissionStatus::Granted + } + + /// Stub — see [`check_permission`]. + #[must_use] + pub fn request_permission(_permission: Permission) -> PermissionStatus { + PermissionStatus::Granted + } + /// One transcription update from a running [`Session`]. #[derive(Debug, Clone, PartialEq)] pub struct SessionResult { @@ -344,7 +436,7 @@ mod imp { } pub use imp::version; -pub use imp::{Event, Session, SessionResult}; +pub use imp::{Event, Session, SessionResult, check_permission, request_permission}; pub use wisp_core::SourceLabel; #[cfg(all(test, target_os = "macos"))] diff --git a/native/WispAudioKit/Sources/WispAudioKit/Bridge.swift b/native/WispAudioKit/Sources/WispAudioKit/Bridge.swift index d98b909..ee07308 100644 --- a/native/WispAudioKit/Sources/WispAudioKit/Bridge.swift +++ b/native/WispAudioKit/Sources/WispAudioKit/Bridge.swift @@ -1,5 +1,7 @@ +@preconcurrency import AVFoundation import Foundation import os.lock +import Speech // MARK: - C-ABI bridge @@ -247,6 +249,116 @@ public func wisp_session_last_error_message(session: OpaquePointer?) -> UnsafePo return handle.errorPointer() } +// MARK: - Permissions + +// +// Two TCC services gate Wisp: microphone (AVAudioApplication) and speech +// recognition (SFSpeechRecognizer). Both have a synchronous status getter +// and an async request API; we expose both shapes so the UI can decide +// between "open the OS prompt" and "deep-link to System Settings" based on +// the current state. +// +// Permission identifiers (kept in sync with wisp_audiokit.h): +// 0 = microphone +// 1 = speech recognition +// +// Status identifiers: +// 0 = undetermined (never asked) +// 1 = denied +// 2 = granted +// 3 = restricted (speech only — e.g. parental controls) +// negative = invalid permission id + +private let wispPermissionMicrophone: Int32 = 0 +private let wispPermissionSpeech: Int32 = 1 + +private let wispPermissionStatusUndetermined: Int32 = 0 +private let wispPermissionStatusDenied: Int32 = 1 +private let wispPermissionStatusGranted: Int32 = 2 +private let wispPermissionStatusRestricted: Int32 = 3 + +/// Returns the current status of the given permission without prompting. +/// +/// Microphone uses `AVCaptureDevice` (the macOS-canonical media capture +/// permission API), not `AVAudioApplication` — the latter is primarily an +/// iOS API and its request method doesn't reliably trigger the TCC prompt +/// on macOS. +@_cdecl("wisp_permission_status") +public func wisp_permission_status(permission: Int32) -> Int32 { + switch permission { + case wispPermissionMicrophone: + avAuthorizationStatusToWisp(AVCaptureDevice.authorizationStatus(for: .audio)) + case wispPermissionSpeech: + switch SFSpeechRecognizer.authorizationStatus() { + case .notDetermined: wispPermissionStatusUndetermined + case .denied: wispPermissionStatusDenied + case .authorized: wispPermissionStatusGranted + case .restricted: wispPermissionStatusRestricted + @unknown default: wispPermissionStatusUndetermined + } + default: + -1 + } +} + +private func avAuthorizationStatusToWisp(_ status: AVAuthorizationStatus) -> Int32 { + switch status { + case .notDetermined: wispPermissionStatusUndetermined + case .denied: wispPermissionStatusDenied + case .authorized: wispPermissionStatusGranted + case .restricted: wispPermissionStatusRestricted + @unknown default: wispPermissionStatusUndetermined + } +} + +/// Triggers the OS permission prompt (if undetermined) and blocks the +/// caller until the user has responded — or returns immediately with the +/// current status if the OS would not show a prompt (already granted / +/// denied / restricted). +/// +/// Called from a background thread by the Rust side; the underlying +/// callbacks fire on arbitrary queues so we just gate on a semaphore. +@_cdecl("wisp_permission_request") +public func wisp_permission_request(permission: Int32) -> Int32 { + switch permission { + case wispPermissionMicrophone: + if AVCaptureDevice.authorizationStatus(for: .audio) != .notDetermined { + return wisp_permission_status(permission: permission) + } + let sem = DispatchSemaphore(value: 0) + let resultSlot = OSAllocatedUnfairLock(initialState: false) + AVCaptureDevice.requestAccess(for: .audio) { granted in + resultSlot.withLock { $0 = granted } + sem.signal() + } + sem.wait() + return resultSlot.withLock { $0 } ? wispPermissionStatusGranted + : wispPermissionStatusDenied + case wispPermissionSpeech: + if SFSpeechRecognizer.authorizationStatus() != .notDetermined { + return wisp_permission_status(permission: permission) + } + let sem = DispatchSemaphore(value: 0) + let resultSlot = OSAllocatedUnfairLock( + initialState: .notDetermined + ) + SFSpeechRecognizer.requestAuthorization { status in + resultSlot.withLock { $0 = status } + sem.signal() + } + sem.wait() + return switch resultSlot.withLock({ $0 }) { + case .notDetermined: wispPermissionStatusUndetermined + case .denied: wispPermissionStatusDenied + case .authorized: wispPermissionStatusGranted + case .restricted: wispPermissionStatusRestricted + @unknown default: wispPermissionStatusUndetermined + } + default: + return -1 + } +} + // MARK: - Internal helpers /// Wraps a raw user-data pointer so it can be captured by `@Sendable` diff --git a/native/WispAudioKit/include/wisp_audiokit.h b/native/WispAudioKit/include/wisp_audiokit.h index 3cd62a9..e369f40 100644 --- a/native/WispAudioKit/include/wisp_audiokit.h +++ b/native/WispAudioKit/include/wisp_audiokit.h @@ -81,6 +81,28 @@ void wisp_session_free(WispSession* session); * session and is invalidated by the next mutating call on it. */ const char* wisp_session_last_error_message(WispSession* session); +/* ----- Permissions -------------------------------------------------------- */ + +/* Permission identifiers. */ +#define WISP_PERMISSION_MICROPHONE 0 +#define WISP_PERMISSION_SPEECH_RECOGNITION 1 + +/* Status returned by wisp_permission_status / wisp_permission_request. + * Negative values are reserved for "invalid permission id" / future use. */ +#define WISP_PERMISSION_STATUS_UNDETERMINED 0 +#define WISP_PERMISSION_STATUS_DENIED 1 +#define WISP_PERMISSION_STATUS_GRANTED 2 +#define WISP_PERMISSION_STATUS_RESTRICTED 3 /* speech only */ + +/* Returns the current status of the given permission without prompting. */ +int32_t wisp_permission_status(int32_t permission); + +/* Triggers the OS permission prompt (if the status is undetermined) and + * blocks the caller until the user responds. If the status is already + * granted/denied/restricted, returns immediately with the current value. + * Safe to call from a background thread. */ +int32_t wisp_permission_request(int32_t permission); + #ifdef __cplusplus } #endif