Skip to main content

app_ui/
recording_ipc.rs

1//! JS-bridge bindings for the M-RECORD.1 coordinated-recording
2//! commands (start / stop / status) + the `recording-status` event.
3//!
4//! Mirror of [`crate::screen_ipc`] / [`crate::mic_ipc`] / etc. The
5//! `__screenStartRecording` / `__screenStopRecording` /
6//! `__screenRecordingStatus` helpers in `index.html` wrap
7//! `window.__TAURI__.core.invoke(...)`.
8
9use serde::{Deserialize, Serialize};
10use wasm_bindgen::JsValue;
11use wasm_bindgen::prelude::*;
12
13/// Mirror of `screen_app::recording::SessionStreams`. Per-channel
14/// flags chosen at session-start time.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[allow(
17    clippy::struct_excessive_bools,
18    reason = "Each bool maps to one of the four physical input channels (camera / screen / mic / system audio). Mirrors the Rust-side `SessionStreams` shape verbatim; a bitflag would diverge the IPC seam."
19)]
20pub struct SessionStreamsView {
21    /// Include the camera channel.
22    pub camera: bool,
23    /// Include the screen-capture channel.
24    pub screen: bool,
25    /// Include the microphone channel.
26    pub microphone: bool,
27    /// Include the system / per-app audio channel.
28    pub system_audio: bool,
29}
30
31impl SessionStreamsView {
32    /// `true` if at least one channel is enabled.
33    #[must_use]
34    pub fn any_enabled(self) -> bool {
35        self.camera || self.screen || self.microphone || self.system_audio
36    }
37}
38
39/// Mirror of `screen_app::recording::RecordingConfig`. Sent to
40/// `start_recording`.
41#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub struct RecordingConfigView {
44    /// Which physical channels to coordinate.
45    pub streams: SessionStreamsView,
46    /// Camera picker selection (FNV-1a id, empty = OS default).
47    pub camera_id: String,
48    /// Microphone picker selection (FNV-1a id, empty = OS default).
49    pub microphone_id: String,
50    /// Screen-source picker selection (`"display-<id>"` /
51    /// `"window-<id>"`, `None` = primary display).
52    pub screen_source_id: Option<String>,
53    /// Output file path. `None` means "use the default location"
54    /// (M-EXPORT.4 owns the default).
55    pub output_path: Option<String>,
56    /// Output container/codec format slug.
57    pub format: Option<String>,
58}
59
60/// Mirror of `screen_app::recording::RecordingStatusView`. Returned
61/// by `recording_status` and pushed as the `recording-status` event
62/// every 500 ms while a session is active.
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub struct RecordingStatusViewIpc {
66    /// `None` when no session is active.
67    pub session_id: Option<u64>,
68    /// One of `"Idle"` / `"Starting"` / `"Running"` / `"Stopping"`.
69    pub state: String,
70    /// Elapsed time in milliseconds.
71    pub elapsed_ms: u64,
72    /// One entry per enabled stream.
73    pub streams: Vec<StreamHealthView>,
74}
75
76/// Mirror of `screen_app::recording::StreamHealth`.
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub struct StreamHealthView {
80    /// `"Camera"` / `"Screen"` / `"Microphone"` / `"SystemAudio"`.
81    pub kind: String,
82    /// Per-channel lifecycle (free-form string from the per-channel
83    /// enum's `Debug` repr — `"Idle"` / `"Starting"` / `"Running"` /
84    /// `"Stopping"`).
85    pub lifecycle: String,
86    /// Cumulative frame / chunk count since session start.
87    pub frame_count: u64,
88    /// Milliseconds since the most recent frame, if any.
89    pub last_frame_ms_ago: Option<u64>,
90}
91
92/// Mirror of `screen_app::recording::PendingExportView` (M-SAVE.1).
93/// A finished recording sitting in scratch awaiting the user's format
94/// choice. Present in [`RecordingSummaryView::pending_export`] after
95/// `stop_recording` and returned by `recording_pending_export`.
96#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub struct PendingExportView {
99    /// Recording duration in milliseconds.
100    pub duration_ms: u64,
101    /// Suggested base filename (no extension), e.g.
102    /// `"Screen-2026-05-25-123700"`. The Save panel appends the
103    /// extension for the chosen format.
104    pub suggested_basename: String,
105}
106
107/// Mirror of `screen_app::recording::RecordingSummary`. Returned by
108/// `stop_recording`.
109#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub struct RecordingSummaryView {
112    /// The session id that just stopped.
113    pub session_id: u64,
114    /// Total session duration in milliseconds.
115    pub elapsed_ms: u64,
116    /// Final per-stream tally.
117    pub streams: Vec<StreamHealthView>,
118    /// Final output path on the legacy save-on-stop path. As of
119    /// M-SAVE.1 this is `None` (export is deferred to the Save panel);
120    /// `pending_export` carries the handoff instead.
121    pub output_path: Option<String>,
122    /// Set (M-SAVE.1) when the stopped recording is awaiting export —
123    /// the Save panel keys off this to appear.
124    #[serde(default)]
125    pub pending_export: Option<PendingExportView>,
126}
127
128impl RecordingStatusViewIpc {
129    /// Empty / no-session snapshot.
130    #[must_use]
131    pub fn idle() -> Self {
132        Self {
133            session_id: None,
134            state: "Idle".to_string(),
135            elapsed_ms: 0,
136            streams: Vec::new(),
137        }
138    }
139
140    /// `true` when the master session is `Running` (i.e. at least
141    /// one stream produced its first frame). Used by M-RECORD.3 to
142    /// lock the per-channel pickers.
143    #[must_use]
144    pub fn is_recording(&self) -> bool {
145        matches!(self.state.as_str(), "Starting" | "Running" | "Stopping")
146    }
147}
148
149#[wasm_bindgen]
150extern "C" {
151    /// `__screenStartRecording(config)` —
152    /// `Promise<u64>` (session id) or string error.
153    #[wasm_bindgen(js_name = __screenStartRecording, catch)]
154    pub async fn start_recording_js(config: JsValue) -> Result<JsValue, JsValue>;
155
156    /// `__screenStopRecording()` — `Promise<RecordingSummary>`.
157    #[wasm_bindgen(js_name = __screenStopRecording, catch)]
158    pub async fn stop_recording_js() -> Result<JsValue, JsValue>;
159
160    /// `__screenRecordingStatus()` — `Promise<RecordingStatusView>`.
161    #[wasm_bindgen(js_name = __screenRecordingStatus, catch)]
162    pub async fn recording_status_js() -> Result<JsValue, JsValue>;
163
164    /// `__screenDefaultRecordingOutputPath(formatSlug?: string)` —
165    /// `Promise<string>` returning the auto-generated default path
166    /// (M-EXPORT.4).
167    #[wasm_bindgen(js_name = __screenDefaultRecordingOutputPath, catch)]
168    pub async fn default_output_path_js(format_slug: JsValue) -> Result<JsValue, JsValue>;
169
170    /// `__screenRevealRecordingInFileManager(path: string)` —
171    /// `Promise<void>`. Opens the OS file manager focused on the
172    /// given path (M-EXPORT.4).
173    #[wasm_bindgen(js_name = __screenRevealRecordingInFileManager, catch)]
174    pub async fn reveal_in_file_manager_js(path: String) -> Result<JsValue, JsValue>;
175
176    /// `__screenRecordingPendingExport()` —
177    /// `Promise<PendingExportView | null>` (M-SAVE.1).
178    #[wasm_bindgen(js_name = __screenRecordingPendingExport, catch)]
179    async fn recording_pending_export_js() -> Result<JsValue, JsValue>;
180
181    /// `__screenExportRecording(formatSlug?, outputDir?)` —
182    /// `Promise<string>` (final path) or string error (M-SAVE.1).
183    #[wasm_bindgen(js_name = __screenExportRecording, catch)]
184    async fn export_recording_js(format: JsValue, output_dir: JsValue) -> Result<JsValue, JsValue>;
185
186    /// `__screenDiscardRecording()` — `Promise<void>` (M-SAVE.1).
187    #[wasm_bindgen(js_name = __screenDiscardRecording, catch)]
188    async fn discard_recording_js() -> Result<JsValue, JsValue>;
189}
190
191/// The recording awaiting export, if any (M-SAVE.1). `None` when
192/// nothing is pending or on IPC failure.
193pub async fn recording_pending_export() -> Option<PendingExportView> {
194    match recording_pending_export_js().await {
195        Ok(value) => serde_wasm_bindgen::from_value(value).ok(),
196        Err(_) => None,
197    }
198}
199
200/// Export the pending recording. `format` is a slug (`"mp4-h264"` /
201/// `"webm-vp9"`); `output_dir` overrides the configured folder when
202/// `Some`. Returns the final absolute path on success (M-SAVE.1).
203///
204/// # Errors
205///
206/// Surfaces the Rust-side error string (nothing pending, move
207/// failure, or "not yet wired" for transcode formats pre-M-SAVE.2).
208pub async fn export_recording(
209    format: Option<&str>,
210    output_dir: Option<&str>,
211) -> Result<String, String> {
212    let format_arg = format.map_or(JsValue::NULL, JsValue::from_str);
213    let dir_arg = output_dir.map_or(JsValue::NULL, JsValue::from_str);
214    match export_recording_js(format_arg, dir_arg).await {
215        Ok(value) => value
216            .as_string()
217            .ok_or_else(|| "export returned a non-string path".to_owned()),
218        Err(err) => Err(js_error_string(&err)),
219    }
220}
221
222/// Discard the pending recording (delete its scratch). Best-effort;
223/// errors are swallowed (M-SAVE.1).
224pub async fn discard_recording() {
225    let _ = discard_recording_js().await;
226}
227
228/// Resolve the default output path the recorder would write to if
229/// the user started recording right now with the given format.
230/// Returns empty string on IPC failure (caller treats as "use no
231/// override").
232pub async fn default_output_path(format_slug: Option<&str>) -> String {
233    let arg = match format_slug {
234        Some(s) => JsValue::from_str(s),
235        None => JsValue::NULL,
236    };
237    match default_output_path_js(arg).await {
238        Ok(value) => value.as_string().unwrap_or_default(),
239        Err(_) => String::new(),
240    }
241}
242
243/// Open the OS file manager focused on `path` (M-EXPORT.4).
244pub async fn reveal_in_file_manager(path: &str) -> Result<(), String> {
245    reveal_in_file_manager_js(path.to_string())
246        .await
247        .map(|_| ())
248        .map_err(|err| js_error_string(&err))
249}
250
251/// Start a coordinated recording session. Returns the session id on
252/// success.
253pub async fn start_recording(config: RecordingConfigView) -> Result<u64, String> {
254    let arg = serde_wasm_bindgen::to_value(&config)
255        .map_err(|err| format!("encode config failed: {err}"))?;
256    match start_recording_js(arg).await {
257        Ok(value) => serde_wasm_bindgen::from_value(value)
258            .map_err(|err| format!("decode session_id failed: {err}")),
259        Err(err) => Err(js_error_string(&err)),
260    }
261}
262
263/// Stop the active recording session.
264pub async fn stop_recording() -> Result<RecordingSummaryView, String> {
265    match stop_recording_js().await {
266        Ok(value) => serde_wasm_bindgen::from_value(value)
267            .map_err(|err| format!("decode summary failed: {err}")),
268        Err(err) => Err(js_error_string(&err)),
269    }
270}
271
272/// Synchronous mount-time snapshot of the recording status.
273pub async fn recording_status() -> RecordingStatusViewIpc {
274    match recording_status_js().await {
275        Ok(value) => {
276            serde_wasm_bindgen::from_value(value).unwrap_or_else(|_| RecordingStatusViewIpc::idle())
277        }
278        Err(_) => RecordingStatusViewIpc::idle(),
279    }
280}
281
282fn js_error_string(err: &JsValue) -> String {
283    err.as_string().unwrap_or_else(|| format!("{err:?}"))
284}
285
286// ---- M-RECORD.3 — shared "recording active" listener helper -----
287
288/// Subscribe to the `recording-status` event and update `lock` to
289/// match `RecordingStatusViewIpc::is_recording()`. Used by each of
290/// the four per-channel pickers to disable their master toggle while
291/// a session is `Running` / `Starting` / `Stopping` so the user
292/// can't yank an input mid-record (M-RECORD.3).
293///
294/// Also fetches the synchronous initial status via
295/// `recording_status` on mount so a picker remounted mid-session
296/// (tray-popover → main window) starts in the locked state.
297#[cfg(target_arch = "wasm32")]
298pub fn install_recording_lock_listener(lock: leptos::prelude::RwSignal<bool>) {
299    use js_sys::Reflect;
300    use leptos::prelude::*;
301    use leptos::task::spawn_local;
302
303    // Initial poll.
304    spawn_local(async move {
305        let view = recording_status().await;
306        lock.set(view.is_recording());
307    });
308
309    let Some(window) = web_sys::window() else {
310        return;
311    };
312    let Ok(tauri_obj) = Reflect::get(&window, &JsValue::from_str("__TAURI__")) else {
313        return;
314    };
315    let Ok(event_obj) = Reflect::get(&tauri_obj, &JsValue::from_str("event")) else {
316        return;
317    };
318    let Ok(listen_fn) = Reflect::get(&event_obj, &JsValue::from_str("listen")) else {
319        return;
320    };
321    if !listen_fn.is_function() {
322        return;
323    }
324    let callback = wasm_bindgen::closure::Closure::wrap(Box::new(move |evt: JsValue| {
325        let Ok(payload) = Reflect::get(&evt, &JsValue::from_str("payload")) else {
326            return;
327        };
328        if let Ok(parsed) = serde_wasm_bindgen::from_value::<RecordingStatusViewIpc>(payload) {
329            lock.set(parsed.is_recording());
330        }
331    }) as Box<dyn FnMut(JsValue)>);
332    let listen_fn: js_sys::Function = wasm_bindgen::JsCast::unchecked_into(listen_fn);
333    let _ = listen_fn.call2(
334        event_obj.as_ref(),
335        &JsValue::from_str("recording-status"),
336        callback.as_ref().unchecked_ref(),
337    );
338    callback.forget();
339}
340
341/// Subscribe to the `recording-status` event and update `status` to
342/// the full pushed snapshot. Companion to
343/// [`install_recording_lock_listener`] — used when a component needs
344/// the elapsed-ms / per-stream-health detail (e.g. the live
345/// `RecorderPage` Start↔Stop cycle) rather than just the locked-or-not
346/// boolean.
347///
348/// Also fires a one-shot synchronous poll via `recording_status` so
349/// a remounted component starts with the current value rather than
350/// the `RecordingStatusViewIpc::idle()` default.
351#[cfg(target_arch = "wasm32")]
352pub fn install_recording_status_listener(
353    status: leptos::prelude::RwSignal<RecordingStatusViewIpc>,
354) {
355    use js_sys::Reflect;
356    use leptos::prelude::*;
357    use leptos::task::spawn_local;
358
359    spawn_local(async move {
360        let view = recording_status().await;
361        status.set(view);
362    });
363
364    let Some(window) = web_sys::window() else {
365        return;
366    };
367    let Ok(tauri_obj) = Reflect::get(&window, &JsValue::from_str("__TAURI__")) else {
368        return;
369    };
370    let Ok(event_obj) = Reflect::get(&tauri_obj, &JsValue::from_str("event")) else {
371        return;
372    };
373    let Ok(listen_fn) = Reflect::get(&event_obj, &JsValue::from_str("listen")) else {
374        return;
375    };
376    if !listen_fn.is_function() {
377        return;
378    }
379    let callback = wasm_bindgen::closure::Closure::wrap(Box::new(move |evt: JsValue| {
380        let Ok(payload) = Reflect::get(&evt, &JsValue::from_str("payload")) else {
381            return;
382        };
383        if let Ok(parsed) = serde_wasm_bindgen::from_value::<RecordingStatusViewIpc>(payload) {
384            status.set(parsed);
385        }
386    }) as Box<dyn FnMut(JsValue)>);
387    let listen_fn: js_sys::Function = wasm_bindgen::JsCast::unchecked_into(listen_fn);
388    let _ = listen_fn.call2(
389        event_obj.as_ref(),
390        &JsValue::from_str("recording-status"),
391        callback.as_ref().unchecked_ref(),
392    );
393    callback.forget();
394}
395
396/// Native (non-wasm) stub of [`install_recording_status_listener`].
397#[cfg(not(target_arch = "wasm32"))]
398pub fn install_recording_status_listener(
399    _status: leptos::prelude::RwSignal<RecordingStatusViewIpc>,
400) {
401}
402
403/// Native (non-wasm) stub of [`install_recording_lock_listener`].
404/// Unit tests + non-browser builds get a no-op so the `RwSignal`
405/// stays at its default `false`.
406#[cfg(not(target_arch = "wasm32"))]
407pub fn install_recording_lock_listener(_lock: leptos::prelude::RwSignal<bool>) {}