Skip to main content

app_ui/
system_audio_ipc.rs

1//! JS-bridge bindings for the M-AUDIO-SYS.2 / AUT-282 system-audio
2//! commands.
3//!
4//! Mirrors [`crate::camera_ipc`] / [`crate::mic_ipc`] for the
5//! per-app speaker-capture path. Each function invokes a `__screen*`
6//! helper in `index.html`'s inline script, which wraps
7//! `window.__TAURI__.core.invoke(...)`.
8
9use serde::{Deserialize, Serialize};
10use wasm_bindgen::JsCast;
11use wasm_bindgen::JsValue;
12use wasm_bindgen::prelude::*;
13
14/// Mirror of `crates/app/src/commands.rs::AudioAppView` (M-AUDIO-SYS.2).
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct AudioAppView {
17    /// Process identifier observed at enumeration time. Use
18    /// `bundle_id` for cross-restart persistence; PIDs drift.
19    pub pid: u32,
20    /// Bundle identifier (`"com.spotify.client"`).
21    pub bundle_id: String,
22    /// Human-readable name (`"Spotify"`).
23    pub display_name: String,
24    /// 32×32 PNG icon bytes. Empty in v0; the picker renders a
25    /// placeholder when empty.
26    pub icon_png_bytes: Vec<u8>,
27}
28
29/// Mirror of `crates/app/src/commands.rs::AudioAppFilterView`. The
30/// tagged-enum serde shape lets the Tauri command pick one of the
31/// three filter modes without an extra `mode: String` field.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub enum AudioAppFilterView {
34    /// Capture every app's audio.
35    AllAudio,
36    /// Capture only these apps (by bundle id).
37    OnlyApps(Vec<String>),
38    /// Capture everything except these apps.
39    ExcludeApps(Vec<String>),
40}
41
42#[wasm_bindgen]
43extern "C" {
44    /// `__screenListAudioApps()` — returns `Promise<AudioAppView[]>`
45    /// or rejects with a string error.
46    #[wasm_bindgen(js_name = __screenListAudioApps, catch)]
47    pub async fn list_audio_apps_js() -> Result<JsValue, JsValue>;
48
49    /// `__screenStartSystemAudio()` — returns `Promise<void>` or
50    /// rejects with a string error.
51    #[wasm_bindgen(js_name = __screenStartSystemAudio, catch)]
52    pub async fn start_system_audio_js() -> Result<JsValue, JsValue>;
53
54    /// `__screenStopSystemAudio()` — returns `Promise<void>`.
55    #[wasm_bindgen(js_name = __screenStopSystemAudio, catch)]
56    pub async fn stop_system_audio_js() -> Result<JsValue, JsValue>;
57
58    /// `__screenSetSystemAudioFilter(filter)` — returns
59    /// `Promise<void>` or rejects with a string error.
60    #[wasm_bindgen(js_name = __screenSetSystemAudioFilter, catch)]
61    pub async fn set_system_audio_filter_js(filter: JsValue) -> Result<JsValue, JsValue>;
62
63    /// `__screenSystemAudioStatus()` — returns `Promise<boolean>`.
64    #[wasm_bindgen(js_name = __screenSystemAudioStatus, catch)]
65    pub async fn system_audio_status_js() -> Result<JsValue, JsValue>;
66
67    /// `__screenOpenSettingsScreenRecording()` (M-RECP.6 / AUT-272)
68    /// — shells out to open System Settings → Privacy & Security →
69    /// Screen Recording. macOS only; Linux/Windows no-op.
70    #[wasm_bindgen(js_name = __screenOpenSettingsScreenRecording, catch)]
71    pub async fn open_settings_screen_recording_js() -> Result<JsValue, JsValue>;
72
73    /// `__screenRequestScreenRecordingPermission()` — triggers the
74    /// macOS Screen & System Audio Recording TCC request.
75    #[wasm_bindgen(js_name = __screenRequestScreenRecordingPermission, catch)]
76    pub async fn request_screen_recording_permission_js() -> Result<JsValue, JsValue>;
77}
78
79/// Result shape carrying either the typed list or a string error so
80/// the picker can render the error inline.
81#[derive(Debug)]
82pub enum ListAudioAppsResult {
83    /// Successful enumeration.
84    Ok(Vec<AudioAppView>),
85    /// SCK refused (often: TCC permission denied).
86    Err(String),
87}
88
89/// Async helper: enumerate every running app SCK can see.
90pub async fn list_audio_apps() -> ListAudioAppsResult {
91    match list_audio_apps_js().await {
92        Ok(value) => match serde_wasm_bindgen::from_value(value) {
93            Ok(apps) => ListAudioAppsResult::Ok(apps),
94            Err(err) => ListAudioAppsResult::Err(format!("decode failed: {err}")),
95        },
96        Err(err) => ListAudioAppsResult::Err(js_error_string(&err)),
97    }
98}
99
100/// Async helper: start the system-audio session. Returns `Ok(())`
101/// or `Err(message)` where the message is the SCK error description.
102pub async fn start_system_audio_capture() -> Result<(), String> {
103    start_system_audio_js()
104        .await
105        .map(|_| ())
106        .map_err(|err| js_error_string(&err))
107}
108
109/// Async helper: stop the active session.
110pub async fn stop_system_audio_capture() {
111    let _ = stop_system_audio_js().await;
112}
113
114/// Async helper: apply a per-app filter.
115pub async fn set_system_audio_filter(filter: AudioAppFilterView) -> Result<(), String> {
116    let value = serde_wasm_bindgen::to_value(&filter)
117        .map_err(|err| format!("serialise filter failed: {err}"))?;
118    set_system_audio_filter_js(value)
119        .await
120        .map(|_| ())
121        .map_err(|err| js_error_string(&err))
122}
123
124/// Async helper: status snapshot. `true` when a session is up.
125pub async fn system_audio_status() -> bool {
126    match system_audio_status_js().await {
127        Ok(value) => serde_wasm_bindgen::from_value(value).unwrap_or(false),
128        Err(_) => false,
129    }
130}
131
132/// Async helper: shell out to open System Settings → Screen
133/// Recording (M-RECP.6 / AUT-272). Silently no-ops outside Tauri
134/// and silently swallows spawn errors — same shape as the other
135/// deep-link helpers.
136pub async fn open_settings_screen_recording() {
137    let _ = open_settings_screen_recording_js().await;
138}
139
140/// Async helper: trigger Screen Recording TCC before opening Settings.
141/// After `tccutil reset ScreenCapture`, this call is what makes macOS
142/// recreate the app row in the Screen & System Audio Recording list.
143pub async fn request_screen_recording_permission() {
144    let _ = request_screen_recording_permission_js().await;
145}
146
147/// Subscribe to the `system-audio-level` Tauri event
148/// (M-AUDIO.METER / AUT-287) emitted by the SCK delegate at the
149/// stream's natural buffer rate (~20-100 Hz). Handler receives an
150/// EMA-smoothed master-stream RMS in `[0.0, ~1.0]`. Mirror of
151/// [`crate::mic_ipc::subscribe_mic_level`].
152pub fn subscribe_system_audio_level(handler: impl Fn(f32) + 'static) {
153    use wasm_bindgen::closure::Closure;
154    let Some(window) = web_sys::window() else {
155        return;
156    };
157    let Ok(tauri) = js_sys::Reflect::get(&window, &"__TAURI__".into()) else {
158        return;
159    };
160    let Ok(event) = js_sys::Reflect::get(&tauri, &"event".into()) else {
161        return;
162    };
163    let Ok(listen) = js_sys::Reflect::get(&event, &"listen".into()) else {
164        return;
165    };
166    let Ok(listen_fn) = listen.dyn_into::<js_sys::Function>() else {
167        return;
168    };
169    let cb = Closure::wrap(Box::new(move |payload: JsValue| {
170        #[allow(
171            clippy::cast_possible_truncation,
172            reason = "RMS in [0, 1] easily fits f32"
173        )]
174        let level = js_sys::Reflect::get(&payload, &"payload".into())
175            .ok()
176            .and_then(|v| v.as_f64())
177            .map_or(0.0_f32, |v| v as f32);
178        handler(level);
179    }) as Box<dyn Fn(JsValue)>);
180    let _ = listen_fn.call2(
181        &event,
182        &"system-audio-level".into(),
183        cb.as_ref().unchecked_ref(),
184    );
185    cb.forget();
186}
187
188/// Best-effort: turn a `JsValue` error into a human-readable string
189/// for inline display in the picker.
190fn js_error_string(err: &JsValue) -> String {
191    err.as_string().unwrap_or_else(|| format!("{err:?}"))
192}