Skip to main content

screen_app/
commands.rs

1//! Tauri `#[command]` wrappers around [`PlayerSession`].
2//!
3//! Every command is a one-liner — the heavy lifting is in
4//! [`super::player_session`]. Splitting them out keeps the IPC surface
5//! easy to audit (one file, four functions) and isolates the Tauri
6//! framework dep from the testable `PlayerSession`.
7
8#![allow(
9    clippy::needless_pass_by_value,
10    reason = "tauri::command requires State<T> by value (the macro's signature inspection rejects &State<T>)"
11)]
12
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15
16use tauri::{Manager, PhysicalPosition, State};
17
18use crate::audio::{MicCaptureHandle, MicCapturePipeline, MicCaptureState, MicError, MicLifecycle};
19use crate::player_session::{PlayerSession, PlayerStatus};
20use crate::preview::{
21    CameraError, CameraPipeline, CameraPipelineHandle, DiagnosticsSnapshot, PreviewDiagnostics,
22    PreviewLifecycle, PreviewState,
23};
24use crate::recording::{
25    RecordingConfig, RecordingSession, RecordingState, RecordingStatusView, RecordingSummary,
26    SessionState, SessionStreams, StreamHealth, StreamKind,
27};
28use crate::recp::bubble_position::{BubblePosition, default_position, is_on_any_monitor};
29use crate::recp::settings_deep_link::{SettingsPane, open_command};
30use crate::recp::tray_positioning::{MonitorBounds, pick_monitor, position_window_top_right};
31#[cfg(target_os = "macos")]
32use crate::screen_capture::ScreenCaptureState;
33#[cfg(target_os = "macos")]
34use crate::system_audio::SystemAudioCaptureState;
35use crate::tray::bubble_toggle::{BubbleAction, BubbleVisibility};
36use crate::tray::toggle::{Action, TrayPopoverState};
37
38/// Tauri-managed wrapper around the tray-popover toggle state machine
39/// (M-TRAY.0 / AUT-249). Held in `tauri::State` so the click handler in
40/// `main.rs` and the `tray_toggle_popover` command share one source of
41/// truth. `Mutex` rather than `parking_lot::Mutex` to avoid adding a new
42/// workspace dep just for the tray; contention is non-existent (only
43/// the click handler ever touches it).
44#[derive(Default)]
45pub struct TrayState(pub Mutex<TrayPopoverState>);
46
47/// Tauri-managed state for the webcam-bubble window (M-BUBBLE.0 /
48/// AUT-273 + M-BUBBLE.3 / AUT-276). Tracks both the visibility state
49/// machine and the in-memory last-known position so position
50/// persistence survives hide/show cycles.
51///
52/// `last_position = None` means "no remembered position — first-show
53/// will compute a sensible default." Once set (either by loading from
54/// disk on first show, by `WindowEvent::Moved` mid-session, or by
55/// `set_last_position` for tests), the value is the source of truth
56/// for the next show.
57#[derive(Default)]
58pub struct BubbleState {
59    visibility: Mutex<BubbleVisibility>,
60    last_position: Mutex<Option<BubblePosition>>,
61}
62
63impl BubbleState {
64    /// Snapshot the current remembered position (or `None` if unset).
65    /// Used by the `WindowEvent::Moved` handler in `main.rs` to keep
66    /// the in-memory cache fresh during drags.
67    #[must_use]
68    pub fn last_position(&self) -> Option<BubblePosition> {
69        *self
70            .last_position
71            .lock()
72            .unwrap_or_else(std::sync::PoisonError::into_inner)
73    }
74
75    /// Replace the remembered position. Cheap (one mutex acquire + an
76    /// `i32` pair copy) so safe to call on every `WindowEvent::Moved`.
77    pub fn set_last_position(&self, pos: BubblePosition) {
78        *self
79            .last_position
80            .lock()
81            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pos);
82    }
83}
84
85/// Default inset (px) from the monitor edge for the bubble's
86/// first-open position. Matches typical macOS-overlay convention.
87const BUBBLE_DEFAULT_INSET_PX: i32 = 16;
88
89/// Default bubble dimensions used when the live window can't be
90/// queried (shouldn't happen — `tauri.conf.json` declares 200×200 —
91/// but defensive so the show path never blocks on a query failure).
92const BUBBLE_FALLBACK_W: i32 = 260;
93const BUBBLE_FALLBACK_H: i32 = 320;
94
95/// Webcam-bubble toggle command (M-BUBBLE.0 / AUT-273).
96///
97/// Resolves the bound bubble window by its `tauri.conf.json` label
98/// (`webcam-bubble`), advances the state machine, then performs the
99/// corresponding `show()`/`hide()` on the window. Returns `()` to
100/// match the existing tray-toggle command shape — failures log via
101/// `tracing::warn!` so the click is never user-facing silent.
102///
103/// Persistence (M-BUBBLE.3 / AUT-276): on Show, restore the last
104/// remembered position (in-memory or, if first show of the session,
105/// loaded from `bubble-position.txt` in the app's config dir). On
106/// Hide, snapshot the window's current position into the in-memory
107/// state and persist to disk so a future app launch reopens at the
108/// same spot.
109///
110/// Notably does NOT call `set_focus()` on show — the bubble is a
111/// peripheral overlay and shouldn't steal focus from the `AppShell`.
112#[tauri::command]
113pub fn toggle_webcam_bubble(app: tauri::AppHandle, state: State<'_, BubbleState>) {
114    let Some(window) = app.get_webview_window("webcam-bubble") else {
115        tracing::warn!("webcam-bubble window not found; tauri.conf.json may be missing it");
116        return;
117    };
118    let action = {
119        let mut guard = state
120            .visibility
121            .lock()
122            .unwrap_or_else(std::sync::PoisonError::into_inner);
123        guard.on_click()
124    };
125    apply_bubble_action(&app, &state, &window, action);
126}
127
128/// Explicit setter for the webcam bubble visibility. ISS-05 — the
129/// recorder's `camera_enabled` `RwSignal` defaults to `true` while
130/// `BubbleVisibility::default()` is `Hidden`, so the always-flip
131/// [`toggle_webcam_bubble`] path was one click out of phase from
132/// every page mount. The setter aligns the bubble to the caller's
133/// source of truth instead, and no-ops when already in the requested
134/// state — safe to spam from a reactive subscription.
135#[tauri::command]
136pub fn set_webcam_bubble_visibility(
137    visible: bool,
138    app: tauri::AppHandle,
139    state: State<'_, BubbleState>,
140) {
141    let Some(window) = app.get_webview_window("webcam-bubble") else {
142        tracing::warn!("webcam-bubble window not found; tauri.conf.json may be missing it");
143        return;
144    };
145    let action = {
146        let mut guard = state
147            .visibility
148            .lock()
149            .unwrap_or_else(std::sync::PoisonError::into_inner);
150        guard.set(visible)
151    };
152    if let Some(action) = action {
153        apply_bubble_action(&app, &state, &window, action);
154    }
155}
156
157/// Execute a [`BubbleAction`] against the bubble window. Shared by
158/// the toggle + setter command paths so the position-cache + persist
159/// behaviour stays identical regardless of which command was called.
160fn apply_bubble_action(
161    app: &tauri::AppHandle,
162    state: &BubbleState,
163    window: &tauri::WebviewWindow,
164    action: BubbleAction,
165) {
166    match action {
167        BubbleAction::Show => {
168            restore_bubble_position(app, state, window);
169            if let Err(err) = window.show() {
170                tracing::warn!(?err, "failed to show webcam-bubble window");
171            }
172        }
173        BubbleAction::Hide => {
174            snapshot_and_persist_bubble_position(app, state, window);
175            if let Err(err) = window.hide() {
176                tracing::warn!(?err, "failed to hide webcam-bubble window");
177            }
178        }
179    }
180}
181
182/// Look up the bubble window's last-known position (in-memory first;
183/// then disk; then `default_position` on the primary monitor) and
184/// apply it via `set_position` BEFORE `show()` so the window doesn't
185/// flicker through a stale OS-default location.
186fn restore_bubble_position(
187    app: &tauri::AppHandle,
188    state: &BubbleState,
189    window: &tauri::WebviewWindow,
190) {
191    // 1. Hot path: in-memory state set by a previous Hide / Moved.
192    if let Some(pos) = state.last_position() {
193        apply_position(window, pos);
194        return;
195    }
196    // 2. Cold path: try disk load. If found, hydrate the in-memory
197    //    cache so future shows hit the hot path.
198    if let Some(pos) = load_bubble_position(app) {
199        let monitors = collect_monitor_bounds(app);
200        let (w, h) = window_dims(window);
201        if is_on_any_monitor(pos, w, h, &monitors) {
202            state.set_last_position(pos);
203            apply_position(window, pos);
204            return;
205        }
206        tracing::info!(
207            ?pos,
208            "saved bubble position is off-screen (display unplugged?); falling back to default"
209        );
210    }
211    // 3. Fallback: compute default for the primary monitor.
212    if let Some(pos) = compute_default_position(app, window) {
213        state.set_last_position(pos);
214        apply_position(window, pos);
215    }
216}
217
218/// Read the window's current outer position, store it in the
219/// in-memory state, and persist to disk. Called on Hide so a
220/// subsequent show (this session OR a later launch) restores the
221/// user's chosen position.
222fn snapshot_and_persist_bubble_position(
223    app: &tauri::AppHandle,
224    state: &BubbleState,
225    window: &tauri::WebviewWindow,
226) {
227    let Ok(physical) = window.outer_position() else {
228        tracing::warn!("could not read webcam-bubble position; persistence skipped this cycle");
229        return;
230    };
231    let pos = BubblePosition {
232        x: physical.x,
233        y: physical.y,
234    };
235    state.set_last_position(pos);
236    if let Err(err) = save_bubble_position(app, pos) {
237        tracing::warn!(?err, "failed to persist webcam-bubble position to disk");
238    }
239}
240
241/// Apply a position to the bubble window using a `PhysicalPosition`
242/// (the same coordinate system `outer_position()` returns + the same
243/// coordinate system `MonitorBounds` is in, per
244/// `crate::recp::tray_positioning`).
245fn apply_position(window: &tauri::WebviewWindow, pos: BubblePosition) {
246    if let Err(err) = window.set_position(PhysicalPosition::new(pos.x, pos.y)) {
247        tracing::warn!(?err, "set_position on webcam-bubble failed");
248    }
249}
250
251/// Build a `MonitorBounds` vec from `app.available_monitors()`.
252/// Empty on failure — callers must handle that case.
253fn collect_monitor_bounds(app: &tauri::AppHandle) -> Vec<MonitorBounds> {
254    let Ok(monitors) = app.available_monitors() else {
255        return Vec::new();
256    };
257    monitors
258        .iter()
259        .map(|m| MonitorBounds {
260            x: m.position().x,
261            y: m.position().y,
262            width: i32::try_from(m.size().width).unwrap_or(i32::MAX),
263            height: i32::try_from(m.size().height).unwrap_or(i32::MAX),
264        })
265        .collect()
266}
267
268/// Resolve the window's physical inner-size into integer width/height,
269/// falling back to the `tauri.conf.json` declared 200×200 if the live
270/// query fails.
271fn window_dims(window: &tauri::WebviewWindow) -> (i32, i32) {
272    window
273        .inner_size()
274        .map_or((BUBBLE_FALLBACK_W, BUBBLE_FALLBACK_H), |size| {
275            (
276                i32::try_from(size.width).unwrap_or(BUBBLE_FALLBACK_W),
277                i32::try_from(size.height).unwrap_or(BUBBLE_FALLBACK_H),
278            )
279        })
280}
281
282/// First-launch default: bottom-right of the primary monitor with a
283/// 16 px inset. Returns `None` only when the OS reports zero monitors
284/// — defensive; in practice `available_monitors()` always yields ≥1
285/// when a webview is up.
286fn compute_default_position(
287    app: &tauri::AppHandle,
288    window: &tauri::WebviewWindow,
289) -> Option<BubblePosition> {
290    let monitors = collect_monitor_bounds(app);
291    let primary = monitors.first().copied()?;
292    let (w, h) = window_dims(window);
293    Some(default_position(w, h, primary, BUBBLE_DEFAULT_INSET_PX))
294}
295
296/// Persisted-position file path: `<app-config-dir>/bubble-position.txt`.
297/// The format is `"{x},{y}\n"` — two integers + a comma + a newline.
298/// We deliberately avoid `serde_json` (no new workspace dep) and
299/// avoid TOML (overkill for two integers); the file is human-readable
300/// + trivially repairable + small enough to parse by hand.
301fn bubble_position_path(app: &tauri::AppHandle) -> Option<PathBuf> {
302    let dir = app.path().app_config_dir().ok()?;
303    Some(dir.join("bubble-position.txt"))
304}
305
306/// Persist `pos` to disk. Creates the app-config dir if it doesn't
307/// exist yet (first-ever app launch).
308fn save_bubble_position(app: &tauri::AppHandle, pos: BubblePosition) -> std::io::Result<()> {
309    let path = bubble_position_path(app).ok_or_else(|| {
310        std::io::Error::new(std::io::ErrorKind::NotFound, "app config dir unavailable")
311    })?;
312    if let Some(parent) = path.parent() {
313        std::fs::create_dir_all(parent)?;
314    }
315    std::fs::write(&path, encode_position(pos))
316}
317
318/// Load `BubblePosition` from disk; returns `None` on missing file,
319/// I/O error, or malformed contents.
320fn load_bubble_position(app: &tauri::AppHandle) -> Option<BubblePosition> {
321    let path = bubble_position_path(app)?;
322    let raw = std::fs::read_to_string(&path).ok()?;
323    decode_position(&raw)
324}
325
326/// Persistence file-format version prefix. Bumping this string causes
327/// `decode_position` to reject any file written by an earlier version,
328/// which falls through to `compute_default_position` and re-applies the
329/// current default-corner rule (M-BUBBLE.3 originally shipped
330/// bottom-right; the design pass moved the default to bottom-left, and
331/// stale `v1` files were keeping the bubble in the old corner).
332const BUBBLE_POSITION_FORMAT_VERSION: &str = "v2";
333
334/// Format helper extracted for unit testing.
335#[must_use]
336fn encode_position(pos: BubblePosition) -> String {
337    format!("{}:{},{}\n", BUBBLE_POSITION_FORMAT_VERSION, pos.x, pos.y)
338}
339
340/// Parse helper extracted for unit testing. Requires the
341/// `BUBBLE_POSITION_FORMAT_VERSION` prefix so old-format files get
342/// rejected (returns `None`), letting the caller fall through to
343/// `compute_default_position` with the current default-corner rule.
344#[must_use]
345fn decode_position(raw: &str) -> Option<BubblePosition> {
346    let trimmed = raw.trim();
347    let body = trimmed.strip_prefix(&format!("{BUBBLE_POSITION_FORMAT_VERSION}:"))?;
348    let (x, y) = body.split_once(',')?;
349    Some(BubblePosition {
350        x: x.trim().parse().ok()?,
351        y: y.trim().parse().ok()?,
352    })
353}
354
355/// Update the bubble window's in-memory position cache. Called from
356/// `main.rs`'s `on_window_event` handler whenever the user drags the
357/// bubble. Persistence happens on Hide (not on every Moved) to avoid
358/// hammering the disk during a drag — per-frame `Moved` events on
359/// macOS would otherwise cause thousands of writes per drag.
360pub fn update_bubble_position_from_event(state: &BubbleState, physical_x: i32, physical_y: i32) {
361    state.set_last_position(BubblePosition {
362        x: physical_x,
363        y: physical_y,
364    });
365}
366
367/// Toggle whether the webcam-bubble window passes mouse events
368/// through to whatever's underneath (M-BUBBLE.1 v0 / AUT-274).
369///
370/// When `enabled = true`, the entire bubble window is mouse-event
371/// transparent — clicks and hovers reach the window below. Useful
372/// when recording the bubble overlaying slides / a browser, so the
373/// user can interact with the underlying app without the bubble
374/// catching the click. To disable (let the user drag the bubble
375/// again), the `AppShell`'s "Click-through bubble" button is the
376/// out-of-band trigger; the bubble itself can't receive the click
377/// while passthrough is on (chicken-and-egg).
378///
379/// Implementation is the macOS-blessed
380/// `NSWindow.setIgnoresMouseEvents:` path exposed through Tauri 2's
381/// `WebviewWindow::set_ignore_cursor_events`. The same call works on
382/// Windows (`WS_EX_TRANSPARENT`) and Linux (compositor-dependent).
383/// **Whole-window** — clicks on the visible bubble circle ALSO pass
384/// through when enabled. Per-pixel hit-testing (only the circle
385/// intercepts, corners pass through) needs an `NSView` subclass via
386/// `objc2` and is deferred to a v1 follow-up under the same ticket.
387#[tauri::command]
388pub fn set_bubble_clickthrough(app: tauri::AppHandle, enabled: bool) {
389    let Some(window) = app.get_webview_window("webcam-bubble") else {
390        tracing::warn!("webcam-bubble window not found; clickthrough toggle no-op");
391        return;
392    };
393    if let Err(err) = window.set_ignore_cursor_events(enabled) {
394        tracing::warn!(
395            ?err,
396            enabled,
397            "set_ignore_cursor_events on webcam-bubble failed"
398        );
399    }
400}
401
402/// `true` if `path` looks like the file `save_bubble_position` would
403/// produce. Used in the persistence integration test (which writes a
404/// canned file and verifies `load_bubble_position` reads it back).
405#[doc(hidden)]
406#[must_use]
407pub fn __debug_is_bubble_position_path(path: &Path) -> bool {
408    path.file_name().is_some_and(|n| n == "bubble-position.txt")
409}
410
411/// Tray-popover toggle command (M-TRAY.0 / AUT-249).
412///
413/// Resolves the bound popover window by its `tauri.conf.json` label
414/// (`tray-popover`), advances the state machine, then performs the
415/// corresponding `show()`/`hide()` on the window. Returning `()` rather
416/// than `Result` matches the existing player_* commands' shape — failure
417/// paths log via `tracing::warn!` so the click is never user-facing
418/// silent.
419#[tauri::command]
420pub fn tray_toggle_popover(app: tauri::AppHandle, state: State<'_, TrayState>) {
421    toggle_tray_popover(&app, &state);
422}
423
424/// Pure function variant of [`tray_toggle_popover`] — not a Tauri
425/// command. Calls [`toggle_tray_popover_at`] with no click position so
426/// the window opens at its previous position (or the OS-default
427/// position on first show). Used by the IPC bus and the no-position
428/// fallback for synthetic clicks in tests.
429pub fn toggle_tray_popover(app: &tauri::AppHandle, state: &TrayState) {
430    toggle_tray_popover_at(app, state, None);
431}
432
433/// Like [`toggle_tray_popover`] but anchors the popover under
434/// `click_position` (M-RECP.1 / AUT-262 wiring). When the state
435/// machine resolves to `Action::Show` AND `click_position` is set,
436/// we look up the monitor the click happened on, compute the
437/// below-click anchor, and `set_position` BEFORE showing the window.
438/// Without the explicit `set_position` Tauri restores the last-known
439/// position (or the OS default), which is the source of the "popover
440/// doesn't follow the tray icon" bug.
441pub fn toggle_tray_popover_at(
442    app: &tauri::AppHandle,
443    state: &TrayState,
444    click_position: Option<(i32, i32)>,
445) {
446    let Some(window) = app.get_webview_window("tray-popover") else {
447        tracing::warn!("tray-popover window not found; tauri.conf.json may be missing it");
448        return;
449    };
450    let action = {
451        let mut guard = state
452            .0
453            .lock()
454            .unwrap_or_else(std::sync::PoisonError::into_inner);
455        guard.on_click()
456    };
457    match action {
458        Action::Show => {
459            if let Some((click_x, click_y)) = click_position {
460                anchor_window_to_click(app, &window, click_x, click_y);
461            }
462            if let Err(err) = window.show() {
463                tracing::warn!(?err, "failed to show tray-popover window");
464                return;
465            }
466            if let Err(err) = window.set_focus() {
467                tracing::warn!(?err, "failed to focus tray-popover window");
468            }
469            // Debug builds: surface the webview console so we can
470            // diagnose blank-page / wasm-panic regressions without a
471            // bundled-app context-menu DevTools toggle.
472            #[cfg(debug_assertions)]
473            window.open_devtools();
474        }
475        Action::Hide => {
476            if let Err(err) = window.hide() {
477                tracing::warn!(?err, "failed to hide tray-popover window");
478            }
479        }
480    }
481}
482
483/// Pick the right monitor for `(click_x, click_y)` and place the
484/// `tray-popover` window's top-left below the click. Logs and bails
485/// out without setting a position when monitors can't be queried —
486/// the window will still `show()` at its last-known position so the
487/// user doesn't lose access to the recorder.
488fn anchor_window_to_click(
489    app: &tauri::AppHandle,
490    window: &tauri::WebviewWindow,
491    click_x: i32,
492    click_y: i32,
493) {
494    let monitors = match app.available_monitors() {
495        Ok(list) => list,
496        Err(err) => {
497            tracing::warn!(
498                ?err,
499                "available_monitors failed; popover stays at last position"
500            );
501            return;
502        }
503    };
504    let bounds: Vec<MonitorBounds> = monitors
505        .iter()
506        .map(|m| MonitorBounds {
507            x: m.position().x,
508            y: m.position().y,
509            width: i32::try_from(m.size().width).unwrap_or(i32::MAX),
510            height: i32::try_from(m.size().height).unwrap_or(i32::MAX),
511        })
512        .collect();
513    // `inner_size()` gives the size of the webview content rect. We
514    // only need the width — the top-right anchor is independent of
515    // window height. Falling through on lookup failure uses the
516    // conf-declared width as a crude fallback so we still get a
517    // sensible anchor.
518    let window_w = window
519        .inner_size()
520        .map_or(1200, |size| i32::try_from(size.width).unwrap_or(1200));
521    let Some((target_x, target_y)) = compute_popover_anchor(click_x, click_y, window_w, &bounds)
522    else {
523        tracing::warn!("no monitors reported; popover stays at last position");
524        return;
525    };
526    // Monitor bounds, window inner_size, and the tray click position
527    // are all in PHYSICAL pixels (`PhysicalPosition` / `PhysicalSize`
528    // from Tauri 2). The previous `LogicalPosition::new` here applied
529    // the value as logical pixels, so on a 2× Retina display the
530    // popover landed at twice the intended position and the right
531    // edge fell off the screen whenever the user clicked the tray
532    // icon near the menubar's right side. Match the coordinate space
533    // the geometry was computed in — same fix the bubble window's
534    // `apply_position` already uses.
535    if let Err(err) = window.set_position(PhysicalPosition::new(target_x, target_y)) {
536        tracing::warn!(?err, "set_position on tray-popover failed");
537    }
538}
539
540/// Pure compute step shared by [`anchor_window_to_click`] (runtime)
541/// and the unit tests (no Tauri). Returns the popover's target
542/// top-left position (anchored top-right of the picked monitor) in
543/// screen coordinates, or `None` if the monitor list is empty.
544///
545/// Splitting this out exists so the click → monitor-pick →
546/// top-right-anchor pipeline is verifiable without spinning up a
547/// Tauri mock app — see the unit tests below.
548fn compute_popover_anchor(
549    click_x: i32,
550    click_y: i32,
551    window_w: i32,
552    monitors: &[MonitorBounds],
553) -> Option<(i32, i32)> {
554    let monitor = pick_monitor(click_x, click_y, monitors)?;
555    Some(position_window_top_right(window_w, monitor))
556}
557
558/// Open a video file and start it paused at frame 0.
559#[tauri::command]
560pub fn player_open(state: State<'_, PlayerSession>, path: String) -> Result<PlayerStatus, String> {
561    state.open(&PathBuf::from(path))
562}
563
564/// Resume playback. No-op when nothing is loaded.
565#[tauri::command]
566pub fn player_play(state: State<'_, PlayerSession>) {
567    state.play();
568}
569
570/// Pause playback. No-op when nothing is loaded.
571#[tauri::command]
572pub fn player_pause(state: State<'_, PlayerSession>) {
573    state.pause();
574}
575
576/// Snapshot the current status. The shell normally subscribes to the
577/// pushed `player-status` events instead of polling, but this command
578/// is useful on initial mount to seed the UI before the first event.
579#[tauri::command]
580#[must_use]
581pub fn player_status(state: State<'_, PlayerSession>) -> PlayerStatus {
582    state.status()
583}
584
585/// View-model shape for the camera-list IPC command (M-CAM.2 /
586/// AUT-256). Mirrors `media::CameraDevice` but lives in
587/// `crates/app/` so the IPC schema is owned by the shell crate
588/// rather than the media crate.
589#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
590pub struct CameraView {
591    /// Stable device id.
592    pub id: String,
593    /// Human-readable label.
594    pub label: String,
595    /// First in the enumeration order.
596    pub is_default: bool,
597}
598
599impl From<media::CameraDevice> for CameraView {
600    fn from(value: media::CameraDevice) -> Self {
601        Self {
602            id: value.id,
603            label: value.label,
604            is_default: value.is_default,
605        }
606    }
607}
608
609/// Camera permission probe (M-CAM.2 / AUT-256). Stub-returns
610/// `Granted` on every platform today; full macOS implementation via
611/// `AVCaptureDevice.authorizationStatus(for:)` is M-RECP.0 territory.
612#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
613pub enum CameraPermission {
614    /// User has granted camera access.
615    Granted,
616    /// macOS has not yet asked the user — the next capture call
617    /// will trigger the OS-level prompt.
618    NotDetermined,
619    /// User has explicitly denied access.
620    Denied,
621}
622
623/// Enumerate attached cameras (M-CAM.2 / AUT-256).
624///
625/// Wraps `media::list_cameras()` and converts each `CameraDevice`
626/// to a `CameraView`. Returns an empty `Vec` (not an error) if
627/// `gst-device-monitor-1.0` isn't on `PATH` or no cameras are
628/// attached — Leptos consumers should runtime-skip in that case.
629#[tauri::command]
630#[must_use]
631pub fn list_cameras() -> Vec<CameraView> {
632    media::list_cameras()
633        .into_iter()
634        .map(CameraView::from)
635        .collect()
636}
637
638/// Probe the OS for camera permission (M-CAM.2 / AUT-256 +
639/// M-RECP.7 / AUT-285).
640///
641/// macOS: real `AVCaptureDevice.authorizationStatusForMediaType:`
642/// call via `objc2-av-foundation`. Returns `Granted` /
643/// `NotDetermined` / `Denied` per the live TCC state.
644///
645/// Non-macOS: returns `Granted` (no TCC-equivalent the recorder
646/// needs to probe for camera on Linux / Windows).
647#[tauri::command]
648#[must_use]
649pub fn camera_permission_status() -> CameraPermission {
650    #[cfg(target_os = "macos")]
651    {
652        av_authorization_status(AvMediaTypeKind::Video)
653    }
654    #[cfg(not(target_os = "macos"))]
655    {
656        CameraPermission::Granted
657    }
658}
659
660// ---------------------------------------------------------------
661// Settings deep-link commands (M-RECP.0 / AUT-261 — camera,
662// M-RECP.6 / AUT-272 — screen recording, M-RECP.8 / AUT-286 — mic)
663//
664// Each wraps `settings_deep_link::open_command(pane)` and shells
665// out via `std::process::Command`. Returns the underlying spawn
666// error as a string so the Leptos picker can render it inline.
667// macOS + Windows return real URLs; Linux is a no-op (the desktop
668// environment determines the right command — no universal handle).
669// ---------------------------------------------------------------
670
671/// Shell out to open System Settings → Privacy & Security → Camera.
672/// Falls back to a no-op on Linux (no universal Settings deep-link).
673///
674/// # Errors
675///
676/// Returns the OS spawn error as a string if `Command::spawn` fails
677/// (e.g. `open` not on PATH on macOS — should never happen).
678#[tauri::command]
679pub fn open_settings_camera() -> Result<(), String> {
680    open_settings_pane(SettingsPane::Camera)
681}
682
683/// Shell out to open System Settings → Privacy & Security →
684/// Microphone. Linux no-op.
685///
686/// # Errors
687///
688/// Returns the OS spawn error as a string.
689#[tauri::command]
690pub fn open_settings_microphone() -> Result<(), String> {
691    open_settings_pane(SettingsPane::Microphone)
692}
693
694/// Shell out to open System Settings → Privacy & Security →
695/// Screen Recording. macOS only — Windows + Linux return a no-op
696/// `Ok(())` because neither has a system-level Screen Recording
697/// pane the recorder can deep-link to.
698///
699/// # Errors
700///
701/// Returns the OS spawn error as a string.
702#[tauri::command]
703pub fn open_settings_screen_recording() -> Result<(), String> {
704    open_settings_pane(SettingsPane::ScreenRecording)
705}
706
707/// Shared shell-out helper. Resolves the OS-specific argv from
708/// [`open_command`] and spawns it. Returns `Ok(())` even when no
709/// deep-link is known for the pane on this OS (Linux, or Screen
710/// Recording on Windows) — the caller treats "no error" as
711/// "instruction displayed."
712fn open_settings_pane(pane: SettingsPane) -> Result<(), String> {
713    let Some(command_parts) = open_command(pane) else {
714        tracing::info!(
715            ?pane,
716            "open_settings_pane: no deep-link known for this OS — no-op"
717        );
718        return Ok(());
719    };
720    let Some((program, rest)) = command_parts.split_first() else {
721        return Err("open_command returned empty command".into());
722    };
723    std::process::Command::new(program)
724        .args(rest)
725        .spawn()
726        .map_err(|err| format!("failed to open settings pane {pane:?}: {err}"))?;
727    tracing::info!(?pane, ?command_parts, "open_settings_pane: spawned");
728    Ok(())
729}
730
731/// Start the camera preview pipeline (M-CAM.2 / AUT-256).
732///
733/// Today: pure state-machine transition. M-CAM.3 (AUT-257) fills in
734/// the actual gst → wisp → readback → frame-channel pipeline behind
735/// this transition.
736///
737/// # Errors
738///
739/// Returns [`CameraError`] when the state machine refuses (already
740/// running) or — once M-CAM.3 lands — when the gst pipeline fails
741/// to produce frames.
742#[tauri::command]
743pub fn start_preview(
744    app: tauri::AppHandle,
745    state: State<'_, PreviewState>,
746    pipeline_state: State<'_, CameraPipelineHandle>,
747    camera_id: String,
748) -> Result<(), CameraError> {
749    // Advance lifecycle Idle → Starting. Re-entrant calls (already
750    // Starting / Running / Stopping) are no-ops so the caller can
751    // safely double-invoke.
752    {
753        let mut guard = state
754            .0
755            .lock()
756            .unwrap_or_else(std::sync::PoisonError::into_inner);
757        let new_state = guard.try_start();
758        if new_state == *guard {
759            return Ok(());
760        }
761        *guard = new_state;
762    }
763    tracing::info!(
764        camera_id = %camera_id,
765        "preview Starting — spawning gst worker pinned to picked camera (M-CAM.4)"
766    );
767    // Spawn the M-CAM.3 worker, now M-CAM.4-routed: the camera_id
768    // string is resolved to its OS-native gst source element inside
769    // the worker via `media::camera::find_by_id`. The worker advances
770    // Starting → Running on first frame; on gst spawn failure (or
771    // CameraNotFound) it logs + resets the lifecycle to Idle so the
772    // UI shows a recovery state.
773    let pipeline = CameraPipeline::spawn(app, camera_id)?;
774    pipeline_state.install(pipeline);
775    Ok(())
776}
777
778/// Stop the camera preview pipeline (M-CAM.2 / AUT-256 + M-CAM.3 /
779/// AUT-257).
780///
781/// Drops the [`CameraPipeline`] worker — which cancels the loop,
782/// joins the thread, and (via `gstreamer_video::VideoStream`'s own
783/// `Drop`) kills the gst-launch child. The worker thread itself
784/// resets the lifecycle to `Idle` on its way out, but we also do it
785/// here as a belt-and-braces guard in case the worker already exited
786/// (gst failure path).
787#[tauri::command]
788pub fn stop_preview(
789    state: State<'_, PreviewState>,
790    pipeline_state: State<'_, CameraPipelineHandle>,
791) {
792    {
793        let mut guard = state
794            .0
795            .lock()
796            .unwrap_or_else(std::sync::PoisonError::into_inner);
797        *guard = guard.try_stop();
798    }
799    // Drop the pipeline — Drop impl cancels + joins. This blocks
800    // briefly (one gst frame interval, ~33ms at 30fps); acceptable
801    // for a user-initiated stop.
802    pipeline_state.shutdown();
803    {
804        let mut guard = state
805            .0
806            .lock()
807            .unwrap_or_else(std::sync::PoisonError::into_inner);
808        *guard = guard.finish_stop();
809    }
810    tracing::info!("preview Stopped");
811}
812
813/// Snapshot the current preview lifecycle (M-CAM.2 / AUT-256).
814///
815/// Useful for Leptos to seed its `RecorderPreviewState` enum on
816/// first mount before the pushed frame events drive it.
817#[tauri::command]
818#[must_use]
819pub fn preview_status(state: State<'_, PreviewState>) -> PreviewLifecycle {
820    *state
821        .0
822        .lock()
823        .unwrap_or_else(std::sync::PoisonError::into_inner)
824}
825
826/// Snapshot the camera-pipeline diagnostics (M-CAM.3 / AUT-257
827/// diagnostic addition).
828///
829/// Returns total frames received, source dims, source fps × 100,
830/// and the absolute path of the first-frame PNG dump (if one was
831/// written this session). Leptos polls this every 500ms while the
832/// Recorder surface is open to render a small overlay showing the
833/// pipeline is alive — see the `<CameraDiagnostics />` component in
834/// `crates/app-ui/src/camera_diagnostics.rs`.
835///
836/// Wait-free on the hot path (atomic loads only, no mutex on the
837/// counters); the dump-path read takes a `Mutex<Option<PathBuf>>`
838/// briefly but only when the snapshot is requested.
839#[tauri::command]
840#[must_use]
841pub fn preview_diagnostics(state: State<'_, PreviewDiagnostics>) -> DiagnosticsSnapshot {
842    state.snapshot()
843}
844
845// ---------------------------------------------------------------
846// Microphone IPC surface (M-MIC.1 / AUT-278)
847// ---------------------------------------------------------------
848
849/// View-model shape for the microphone-list IPC command (M-MIC.1 /
850/// AUT-278). Mirrors [`media::MicrophoneDevice`] but lives in
851/// `crates/app/` so the IPC schema is owned by the shell crate.
852/// Same shape contract as [`CameraView`] keeps the Leptos-side
853/// picker code symmetrical between camera + mic.
854#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
855pub struct MicrophoneView {
856    /// Stable device id (`mic-…`).
857    pub id: String,
858    /// Human-readable label (`"MacBook Pro Microphone"` etc.).
859    pub label: String,
860    /// `true` for the OS-default mic.
861    pub is_default: bool,
862    /// Native channel count from the gst caps line (1 = mono, 2 =
863    /// stereo). `0` means unknown — Leptos should default to 2.
864    pub channels: u8,
865    /// Native sample rate (typically 48000 / 44100). `0` means
866    /// unknown — Leptos should default to 48000.
867    pub sample_rate_hz: u32,
868    /// Platform-native device identifier (M-MIC.3 / AUT-284).
869    /// Round-tripped back through `start_mic_capture` so the worker
870    /// can route it into the per-OS gst element (`osxaudiosrc
871    /// device-uid=…` etc.). Empty when the underlying gst plugin
872    /// didn't expose `unique-id` for this device — the worker
873    /// falls back to `autoaudiosrc` in that case.
874    pub native_id: String,
875}
876
877impl From<media::MicrophoneDevice> for MicrophoneView {
878    fn from(value: media::MicrophoneDevice) -> Self {
879        Self {
880            id: value.id,
881            label: value.label,
882            is_default: value.is_default,
883            channels: value.channels,
884            sample_rate_hz: value.sample_rate_hz,
885            native_id: value.native_id,
886        }
887    }
888}
889
890/// Enumerate attached microphones (M-MIC.1 / AUT-278).
891///
892/// Wraps [`media::list_microphones`]. Empty `Vec` (not an error)
893/// when `gst-device-monitor-1.0` isn't on `PATH` or no mics are
894/// attached — Leptos consumers should runtime-skip in that case.
895#[tauri::command]
896#[must_use]
897pub fn list_microphones() -> Vec<MicrophoneView> {
898    media::list_microphones()
899        .into_iter()
900        .map(MicrophoneView::from)
901        .collect()
902}
903
904/// Probe the OS for microphone permission (M-MIC.2 / AUT-279 +
905/// M-RECP.7 / AUT-285).
906///
907/// macOS: real `AVCaptureDevice.authorizationStatusForMediaType:`
908/// call via `objc2-av-foundation`. Returns `Granted` /
909/// `NotDetermined` / `Denied` per the live TCC state.
910///
911/// Non-macOS: returns `Granted`.
912///
913/// Reuses [`CameraPermission`] rather than introducing a separate
914/// `MicrophonePermission` enum — the three states are
915/// structurally identical and the picker components key off the
916/// variant tags, not the type name.
917#[tauri::command]
918#[must_use]
919pub fn microphone_permission_status() -> CameraPermission {
920    #[cfg(target_os = "macos")]
921    {
922        av_authorization_status(AvMediaTypeKind::Audio)
923    }
924    #[cfg(not(target_os = "macos"))]
925    {
926        CameraPermission::Granted
927    }
928}
929
930/// Discriminator for [`av_authorization_status`] — avoids leaking
931/// `AVMediaType` (a Foundation type) into non-macOS callers.
932#[cfg(target_os = "macos")]
933#[derive(Clone, Copy, Debug)]
934enum AvMediaTypeKind {
935    Video,
936    Audio,
937}
938
939/// Shared macOS-only probe. Maps `AVAuthorizationStatus` to the
940/// recorder's three-state [`CameraPermission`] enum. `Restricted`
941/// (enterprise-managed) collapses into `Denied` since the user
942/// can't grant it themselves. Future-proof: unknown variants fail
943/// open as `Granted` to avoid bricking the picker on a future macOS
944/// release.
945#[cfg(target_os = "macos")]
946#[allow(
947    unsafe_code,
948    reason = "AVFoundation FFI interop — every unsafe block has a SAFETY comment above it justifying soundness."
949)]
950fn av_authorization_status(kind: AvMediaTypeKind) -> CameraPermission {
951    use objc2_av_foundation::{
952        AVAuthorizationStatus, AVCaptureDevice, AVMediaTypeAudio, AVMediaTypeVideo,
953    };
954    // SAFETY: the `AVMediaType*` statics are Objective-C externals
955    // marked `Option<&'static AVMediaType>`. They're populated by
956    // AVFoundation's framework init, which runs before any Rust
957    // code in a macOS process. Both should always be Some on a
958    // healthy system — `.expect` documents the invariant.
959    let media_type = match kind {
960        AvMediaTypeKind::Video => unsafe { AVMediaTypeVideo }.expect("AVMediaTypeVideo present"),
961        AvMediaTypeKind::Audio => unsafe { AVMediaTypeAudio }.expect("AVMediaTypeAudio present"),
962    };
963    // SAFETY: `authorizationStatusForMediaType:` is a class method
964    // (no instance state) and documented thread-safe. The only
965    // failure mode is being passed a media-type other than Video /
966    // Audio, which throws an NSInvalidArgumentException — we only
967    // ever pass those two constants above.
968    let status = unsafe { AVCaptureDevice::authorizationStatusForMediaType(media_type) };
969    match status {
970        AVAuthorizationStatus::Authorized => CameraPermission::Granted,
971        AVAuthorizationStatus::NotDetermined => CameraPermission::NotDetermined,
972        AVAuthorizationStatus::Denied | AVAuthorizationStatus::Restricted => {
973            CameraPermission::Denied
974        }
975        _ => {
976            // Future variant — fail-open so the picker stays usable.
977            // Logged so a future macOS release surprise is diagnosable.
978            tracing::warn!(
979                ?kind,
980                ?status,
981                "av_authorization_status: unknown variant; defaulting to Granted"
982            );
983            CameraPermission::Granted
984        }
985    }
986}
987
988/// Proactively request macOS TCC permissions for all four protected
989/// resources (M-PIX.9 of M-RECORD-EXPORT-REAL-PIXELS). Fires the
990/// OS-level prompts that register `com.screen.app` in the TCC
991/// database — without this, pickers enumerate empty on first launch
992/// because no entry exists yet.
993///
994/// Returns the status of each resource after the user responds
995/// (`Authorized` / `Denied` / `NotDetermined`). Blocks for up to
996/// ~30 seconds while the user clicks; returns the current status
997/// if the user dismisses without choosing.
998///
999/// Order matters: camera first (sync, quickest to dismiss), then
1000/// microphone, then screen-recording via SCK (which fires its own
1001/// prompt the first time `SCShareableContent.current` is called).
1002#[tauri::command]
1003#[allow(
1004    clippy::unused_async,
1005    reason = "Tauri commands must be async to keep a uniform signature across platforms; the macOS branch awaits spawn_blocking, the stub branch returns synchronously."
1006)]
1007pub async fn request_all_permissions() -> RequestPermissionsResult {
1008    #[cfg(target_os = "macos")]
1009    {
1010        // All three prompts run on a Tauri-provided worker thread
1011        // (each blocks for up to 30 s waiting on user input). Doing
1012        // them sequentially is fine — user can only click one
1013        // dialog at a time.
1014        tauri::async_runtime::spawn_blocking(|| {
1015            let camera = request_av_access_blocking(AvMediaTypeKind::Video);
1016            let microphone = request_av_access_blocking(AvMediaTypeKind::Audio);
1017            let screen_recording = request_screen_recording_access_blocking();
1018            RequestPermissionsResult {
1019                camera,
1020                microphone,
1021                screen_recording,
1022            }
1023        })
1024        .await
1025        .unwrap_or(RequestPermissionsResult {
1026            camera: CameraPermission::NotDetermined,
1027            microphone: CameraPermission::NotDetermined,
1028            screen_recording: CameraPermission::NotDetermined,
1029        })
1030    }
1031    #[cfg(not(target_os = "macos"))]
1032    {
1033        RequestPermissionsResult {
1034            camera: CameraPermission::Granted,
1035            microphone: CameraPermission::Granted,
1036            screen_recording: CameraPermission::Granted,
1037        }
1038    }
1039}
1040
1041/// IPC view for the M-PIX.9 batch-request result. Each field is the
1042/// post-prompt status the OS reported.
1043#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1044pub struct RequestPermissionsResult {
1045    /// Camera access status after the prompt.
1046    pub camera: CameraPermission,
1047    /// Microphone access status after the prompt.
1048    pub microphone: CameraPermission,
1049    /// Screen Recording access status after the prompt.
1050    pub screen_recording: CameraPermission,
1051}
1052
1053/// Wrapper around `AVCaptureDevice.requestAccessForMediaType:
1054/// completionHandler:`. Triggers the macOS prompt (registers the
1055/// bundle id in TCC), waits up to 30 seconds for the user's
1056/// response, returns the final status.
1057///
1058/// Blocks the calling thread on the channel `recv_timeout` — called
1059/// from inside `tauri::async_runtime::spawn_blocking` in the
1060/// `request_all_permissions` outer command so the Tauri runtime
1061/// stays unblocked.
1062#[cfg(target_os = "macos")]
1063#[allow(
1064    unsafe_code,
1065    reason = "AVFoundation FFI interop — every unsafe block has a SAFETY justification."
1066)]
1067fn request_av_access_blocking(kind: AvMediaTypeKind) -> CameraPermission {
1068    use objc2_av_foundation::{AVCaptureDevice, AVMediaTypeAudio, AVMediaTypeVideo};
1069    use std::sync::Arc;
1070    use std::sync::Mutex;
1071    use std::sync::mpsc::channel;
1072
1073    // SAFETY: framework-init populated externals — see
1074    // av_authorization_status for the matching SAFETY comment.
1075    let media_type = match kind {
1076        AvMediaTypeKind::Video => unsafe { AVMediaTypeVideo }.expect("AVMediaTypeVideo present"),
1077        AvMediaTypeKind::Audio => unsafe { AVMediaTypeAudio }.expect("AVMediaTypeAudio present"),
1078    };
1079
1080    let (tx, rx) = channel::<bool>();
1081    let tx_arc: Arc<Mutex<Option<std::sync::mpsc::Sender<bool>>>> = Arc::new(Mutex::new(Some(tx)));
1082    let tx_for_block = Arc::clone(&tx_arc);
1083    let block = block2::RcBlock::new(move |granted: objc2::runtime::Bool| {
1084        if let Some(sender) = tx_for_block
1085            .lock()
1086            .unwrap_or_else(std::sync::PoisonError::into_inner)
1087            .take()
1088        {
1089            let _ = sender.send(granted.as_bool());
1090        }
1091    });
1092    // SAFETY: requestAccess is documented + the completion block
1093    // signature matches `(BOOL) -> void`.
1094    unsafe {
1095        AVCaptureDevice::requestAccessForMediaType_completionHandler(media_type, &block);
1096    }
1097    let _ = rx.recv_timeout(std::time::Duration::from_secs(30));
1098    av_authorization_status(kind)
1099}
1100
1101/// Query the platform Screen Recording grant without touching SCK.
1102///
1103/// This uses CoreGraphics' screen-capture TCC preflight API, which is
1104/// the cheap permission check Apple exposes for this privacy class.
1105#[tauri::command]
1106#[must_use]
1107pub fn screen_recording_permission_status() -> CameraPermission {
1108    #[cfg(target_os = "macos")]
1109    {
1110        screen_recording_permission_status_macos()
1111    }
1112    #[cfg(not(target_os = "macos"))]
1113    {
1114        CameraPermission::Granted
1115    }
1116}
1117
1118/// Proactively trigger the Screen Recording TCC request.
1119///
1120/// This is intentionally separate from `screen_recording_permission_status`:
1121/// status is a side-effect-free preflight, while this function is what
1122/// causes macOS to show the Screen & System Audio Recording consent sheet
1123/// and add the current app identity to the Settings list.
1124#[tauri::command]
1125#[allow(
1126    clippy::unused_async,
1127    reason = "Tauri commands must be async to keep a uniform signature across platforms; the macOS branch awaits spawn_blocking, the stub branch returns synchronously."
1128)]
1129pub async fn request_screen_recording_permission() -> CameraPermission {
1130    #[cfg(target_os = "macos")]
1131    {
1132        tauri::async_runtime::spawn_blocking(request_screen_recording_access_blocking)
1133            .await
1134            .unwrap_or(CameraPermission::NotDetermined)
1135    }
1136    #[cfg(not(target_os = "macos"))]
1137    {
1138        CameraPermission::Granted
1139    }
1140}
1141
1142/// Trigger the Screen Recording TCC flow with CoreGraphics rather than
1143/// using `SCShareableContent` as an accidental permission probe.
1144///
1145/// SCK enumeration can fail for reasons other than missing TCC. Keeping
1146/// the permission request on `CGRequestScreenCaptureAccess` lets the UI
1147/// distinguish "permission not active for this app identity" from "SCK
1148/// source enumeration failed after permission was granted".
1149#[cfg(target_os = "macos")]
1150fn request_screen_recording_access_blocking() -> CameraPermission {
1151    use objc2_core_graphics::{CGPreflightScreenCaptureAccess, CGRequestScreenCaptureAccess};
1152
1153    if CGPreflightScreenCaptureAccess() {
1154        return CameraPermission::Granted;
1155    }
1156    let _ = CGRequestScreenCaptureAccess();
1157    screen_recording_permission_status_macos()
1158}
1159
1160#[cfg(target_os = "macos")]
1161fn screen_recording_permission_status_macos() -> CameraPermission {
1162    use objc2_core_graphics::CGPreflightScreenCaptureAccess;
1163
1164    if CGPreflightScreenCaptureAccess() {
1165        CameraPermission::Granted
1166    } else {
1167        CameraPermission::Denied
1168    }
1169}
1170
1171#[cfg(target_os = "macos")]
1172fn ensure_screen_recording_access() -> Result<(), String> {
1173    match screen_recording_permission_status_macos() {
1174        CameraPermission::Granted => Ok(()),
1175        CameraPermission::Denied | CameraPermission::NotDetermined => {
1176            let requested = request_screen_recording_access_blocking();
1177            if matches!(requested, CameraPermission::Granted) {
1178                return Ok(());
1179            }
1180            Err(
1181                "Screen Recording permission is not active for this app identity. Enable screen-app.app in System Settings → Privacy & Security → Screen & System Audio Recording, then quit and reopen the app without rebuilding. If this persists on macOS 15+, rebuild with SCREEN_CODESIGN_IDENTITY set to an Apple Development or Developer ID signing identity; ad-hoc signatures cannot reliably satisfy ScreenCapture TCC.".into(),
1182            )
1183        }
1184    }
1185}
1186
1187/// Start the microphone capture worker (M-MIC.1 / AUT-278).
1188///
1189/// Advances [`MicLifecycle`] Idle → Starting and spawns a
1190/// [`MicCapturePipeline`]. Re-entrant calls while a session is
1191/// running cleanly tear down the previous worker (the handle's
1192/// `install` swap drops the old `Pipeline`, which drops the gst
1193/// child) before starting the new one.
1194///
1195/// # Errors
1196///
1197/// Returns [`MicError::GstFailed`] when the worker thread can't
1198/// spawn (effectively never happens). gst-side failures (no mic
1199/// attached, permission denied, etc.) are reported via the
1200/// `mic_status` snapshot returning to `Idle` after the worker
1201/// thread's error path runs.
1202#[tauri::command]
1203pub fn start_mic_capture(
1204    app: tauri::AppHandle,
1205    state: State<'_, MicCaptureState>,
1206    pipeline_state: State<'_, MicCaptureHandle>,
1207    mic_id: String,
1208) -> Result<(), MicError> {
1209    // M-MIC.3 / AUT-284 — resolve the FNV-1a mic_id to the
1210    // platform-native device identifier (osxaudiosrc device-uid /
1211    // pulsesrc device / wasapisrc device) by re-enumerating.
1212    //
1213    // Three cases:
1214    // 1. Empty mic_id → caller wants OS default. Pass empty native_id
1215    //    through; `from_microphone` routes to `autoaudiosrc`.
1216    // 2. Non-empty mic_id present in the live enumeration → use its
1217    //    native_id (which may itself be empty if the device didn't
1218    //    expose `unique-id`; that's a legit fall to autoaudiosrc and
1219    //    we log it).
1220    // 3. Non-empty mic_id NOT present in the live enumeration →
1221    //    stale picker state. Return Err(NotFound) so the UI
1222    //    re-enumerates instead of silently recording the wrong mic.
1223    //    M-RECORD-EXPORT tightening — was silently falling through.
1224    let native_id = if mic_id.is_empty() {
1225        String::new()
1226    } else if let Some(device) = media::microphone::find_by_id(&mic_id) {
1227        if device.native_id.is_empty() {
1228            tracing::warn!(
1229                mic_id = %mic_id,
1230                label = %device.label,
1231                "start_mic_capture: device enumerated but exposed no `unique-id`; \
1232                 falling back to autoaudiosrc (OS default) — picker selection will NOT pin"
1233            );
1234        }
1235        device.native_id
1236    } else {
1237        tracing::warn!(
1238            mic_id = %mic_id,
1239            "start_mic_capture: mic_id not present in live enumeration (stale picker?)"
1240        );
1241        return Err(MicError::NotFound(mic_id));
1242    };
1243
1244    // Re-entrant calls: if a session is already up, tear it down
1245    // first so the new mic-id wins. Mirrors the M-CAM.2/.3
1246    // start_preview re-entrance contract — except the camera path
1247    // doesn't yet handle re-entrance (its docs say "the caller is
1248    // expected to first stop the existing session"). Here we do the
1249    // teardown ourselves so the picker UX (M-MIC.2) doesn't need to
1250    // sequence stop_mic_capture + start_mic_capture for every swap.
1251    let was_active = pipeline_state.is_active();
1252    if was_active {
1253        tracing::info!(
1254            mic_id = %mic_id,
1255            "start_mic_capture: tearing down previous session for re-entrant start"
1256        );
1257        pipeline_state.shutdown();
1258        // Force the lifecycle through Stopping → Idle so the
1259        // try_start below sees Idle.
1260        let mut guard = state
1261            .0
1262            .lock()
1263            .unwrap_or_else(std::sync::PoisonError::into_inner);
1264        *guard = guard.try_stop().finish_stop();
1265    }
1266
1267    {
1268        let mut guard = state
1269            .0
1270            .lock()
1271            .unwrap_or_else(std::sync::PoisonError::into_inner);
1272        let new_state = guard.try_start();
1273        if new_state == *guard {
1274            return Ok(());
1275        }
1276        *guard = new_state;
1277    }
1278    tracing::info!(
1279        mic_id = %mic_id,
1280        native_id = %native_id,
1281        "mic-capture Starting — spawning gst worker (preview, mixer-detached)"
1282    );
1283    // Preview path: `mixer = None`. The worker computes RMS for the
1284    // level meter but does NOT forward samples to the shared
1285    // AudioMixer — otherwise preview audio would accumulate during
1286    // device picking and contaminate the next recording.
1287    let pipeline = MicCapturePipeline::spawn(app, mic_id, native_id, None)?;
1288    pipeline_state.install(pipeline);
1289    Ok(())
1290}
1291
1292/// Stop the microphone capture worker (M-MIC.1 / AUT-278).
1293///
1294/// Drops the [`MicCapturePipeline`] (which cancels the loop, joins
1295/// the thread, and — via `GstreamerAudioCapture`'s own `Drop` —
1296/// kills + reaps the gst-launch child). The worker thread resets
1297/// the lifecycle to `Idle` on its way out; we also do it here as a
1298/// belt-and-braces guard in case the worker had already exited via
1299/// a gst failure path.
1300#[tauri::command]
1301pub fn stop_mic_capture(
1302    state: State<'_, MicCaptureState>,
1303    pipeline_state: State<'_, MicCaptureHandle>,
1304) {
1305    {
1306        let mut guard = state
1307            .0
1308            .lock()
1309            .unwrap_or_else(std::sync::PoisonError::into_inner);
1310        *guard = guard.try_stop();
1311    }
1312    // Drop the pipeline — Drop cancels + joins. Blocks briefly
1313    // (one chunk interval, ~100 ms at our 4800-frame chunks);
1314    // acceptable for a user-initiated stop.
1315    pipeline_state.shutdown();
1316    {
1317        let mut guard = state
1318            .0
1319            .lock()
1320            .unwrap_or_else(std::sync::PoisonError::into_inner);
1321        *guard = guard.finish_stop();
1322    }
1323    tracing::info!("mic-capture Stopped");
1324}
1325
1326/// Snapshot the current mic-capture lifecycle (M-MIC.1 / AUT-278).
1327///
1328/// Useful for Leptos to seed UI state on first mount before the
1329/// (future) push-event mic-level stream drives it.
1330#[tauri::command]
1331#[must_use]
1332pub fn mic_status(state: State<'_, MicCaptureState>) -> MicLifecycle {
1333    *state
1334        .0
1335        .lock()
1336        .unwrap_or_else(std::sync::PoisonError::into_inner)
1337}
1338
1339// ---------------------------------------------------------------
1340// System-audio IPC surface (M-AUDIO-SYS.2 / AUT-282)
1341// ---------------------------------------------------------------
1342
1343/// View-model for the per-app picker (M-AUDIO-SYS.2 / AUT-282).
1344/// Mirrors [`media::sck_audio::AudioApp`] but lives on the shell
1345/// crate so the IPC schema is owned here, not in `media`.
1346#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1347pub struct AudioAppView {
1348    /// Process identifier observed at enumeration time. The Leptos
1349    /// side persists `bundle_id` for cross-restart durability, not
1350    /// `pid`.
1351    pub pid: u32,
1352    /// Bundle identifier (e.g. `"com.spotify.client"`).
1353    pub bundle_id: String,
1354    /// Human-readable display name (`"Spotify"`).
1355    pub display_name: String,
1356    /// 32×32 PNG icon bytes. Empty in v0; populated in M-AUDIO-SYS.1.1.
1357    pub icon_png_bytes: Vec<u8>,
1358}
1359
1360#[cfg(target_os = "macos")]
1361impl From<media::sck_audio::AudioApp> for AudioAppView {
1362    fn from(value: media::sck_audio::AudioApp) -> Self {
1363        Self {
1364            pid: value.pid,
1365            bundle_id: value.bundle_id,
1366            display_name: value.display_name,
1367            icon_png_bytes: value.icon_png_bytes,
1368        }
1369    }
1370}
1371
1372/// IPC-facing view of `media::sck_audio::AudioAppFilter`. Matches
1373/// the underlying enum 1-to-1 but lives in the shell crate so the
1374/// serde shape is owned here.
1375#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1376pub enum AudioAppFilterView {
1377    /// Capture every app's audio.
1378    AllAudio,
1379    /// Capture audio from only these apps (by bundle id).
1380    OnlyApps(Vec<String>),
1381    /// Capture audio from every app except these.
1382    ExcludeApps(Vec<String>),
1383}
1384
1385#[cfg(target_os = "macos")]
1386impl From<AudioAppFilterView> for media::sck_audio::AudioAppFilter {
1387    fn from(value: AudioAppFilterView) -> Self {
1388        match value {
1389            AudioAppFilterView::AllAudio => Self::AllAudio,
1390            AudioAppFilterView::OnlyApps(ids) => Self::OnlyApps(ids),
1391            AudioAppFilterView::ExcludeApps(ids) => Self::ExcludeApps(ids),
1392        }
1393    }
1394}
1395
1396/// Enumerate every running app SCK can see (M-AUDIO-SYS.2 / AUT-282).
1397///
1398/// Returns an empty Vec on non-macOS targets (system audio is
1399/// macOS-only); the Leptos picker treats empty as "no apps available"
1400/// and renders the empty state.
1401///
1402/// # Errors
1403///
1404/// Returns the underlying SCK error (TCC permission denied,
1405/// enumeration failed, etc.) as a string so the Leptos picker
1406/// can show it inline.
1407#[tauri::command]
1408pub fn list_audio_apps() -> Result<Vec<AudioAppView>, String> {
1409    #[cfg(target_os = "macos")]
1410    {
1411        ensure_screen_recording_access()?;
1412        media::sck_audio::list_audio_apps()
1413            .map(|apps| apps.into_iter().map(AudioAppView::from).collect())
1414            .map_err(|err| err.to_string())
1415    }
1416    #[cfg(not(target_os = "macos"))]
1417    {
1418        Ok(Vec::new())
1419    }
1420}
1421
1422/// Start the system-audio capture session (M-AUDIO-SYS.2 / AUT-282).
1423///
1424/// Triggers the macOS Screen Recording permission prompt on first
1425/// run. On subsequent runs the session starts cleanly.
1426///
1427/// # Errors
1428///
1429/// Returns the underlying SCK error message as a string.
1430#[cfg(target_os = "macos")]
1431#[tauri::command]
1432pub fn start_system_audio_capture(
1433    app: tauri::AppHandle,
1434    state: State<'_, SystemAudioCaptureState>,
1435) -> Result<(), String> {
1436    ensure_screen_recording_access()?;
1437    state
1438        .start(&app, media::sck_audio::SystemAudioConfig::default())
1439        .map_err(|err| err.to_string())
1440}
1441
1442/// Non-macOS stub for `start_system_audio_capture`. Returns a
1443/// "not supported" error so the Leptos picker can show the user
1444/// they're on the wrong platform.
1445#[cfg(not(target_os = "macos"))]
1446#[tauri::command]
1447pub fn start_system_audio_capture() -> Result<(), String> {
1448    Err("system audio capture requires macOS 13.0+".into())
1449}
1450
1451/// Stop the active system-audio session, if any (M-AUDIO-SYS.2).
1452#[cfg(target_os = "macos")]
1453#[tauri::command]
1454pub fn stop_system_audio_capture(state: State<'_, SystemAudioCaptureState>) {
1455    state.stop();
1456}
1457
1458/// Non-macOS stub for `stop_system_audio_capture`. No-op since no
1459/// session can have been started on this platform.
1460#[cfg(not(target_os = "macos"))]
1461#[tauri::command]
1462pub fn stop_system_audio_capture() {}
1463
1464/// Apply a per-app filter to the active system-audio session
1465/// (M-AUDIO-SYS.2 / AUT-282).
1466///
1467/// The picker should call `start_system_audio_capture` first; if no
1468/// session is active this command returns an error.
1469///
1470/// # Errors
1471///
1472/// Returns the underlying SCK error message as a string.
1473#[cfg(target_os = "macos")]
1474#[tauri::command]
1475pub fn set_system_audio_filter(
1476    state: State<'_, SystemAudioCaptureState>,
1477    filter: AudioAppFilterView,
1478) -> Result<(), String> {
1479    let internal: media::sck_audio::AudioAppFilter = filter.into();
1480    state.set_filter(&internal).map_err(|err| err.to_string())
1481}
1482
1483/// Non-macOS stub for `set_system_audio_filter`. Returns the same
1484/// "not supported" error as the start command so the Leptos picker
1485/// can surface a consistent message on every platform.
1486///
1487/// # Errors
1488///
1489/// Always returns `"system audio capture requires macOS 13.0+"`.
1490#[cfg(not(target_os = "macos"))]
1491#[tauri::command]
1492pub fn set_system_audio_filter(_filter: AudioAppFilterView) -> Result<(), String> {
1493    Err("system audio capture requires macOS 13.0+".into())
1494}
1495
1496/// Whether a system-audio session is currently active
1497/// (M-AUDIO-SYS.2 / AUT-282). Drives the picker's master toggle
1498/// display.
1499#[cfg(target_os = "macos")]
1500#[tauri::command]
1501#[must_use]
1502pub fn system_audio_status(state: State<'_, SystemAudioCaptureState>) -> bool {
1503    state.is_active()
1504}
1505
1506/// Non-macOS stub for `system_audio_status`. Always returns `false`
1507/// since no session can have been started on this platform.
1508#[cfg(not(target_os = "macos"))]
1509#[tauri::command]
1510#[must_use]
1511pub fn system_audio_status() -> bool {
1512    false
1513}
1514
1515// ---------------------------------------------------------------
1516// Screen-capture IPC surface (M-SCK.1 / AUT-268 + M-SCK.2 / AUT-269,
1517// lifecycle-only — frame channel deferred per the PR scope).
1518// ---------------------------------------------------------------
1519
1520/// View-model for a display source (M-SCK.1 / AUT-268). Mirrors
1521/// `media::screen::DisplaySource` but lives in the shell crate so
1522/// the IPC schema is owned here.
1523#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1524pub struct DisplaySourceView {
1525    /// Stable id (`display-<displayID>`).
1526    pub id: String,
1527    /// Human-readable label.
1528    pub label: String,
1529    /// Width in points.
1530    pub width: u32,
1531    /// Height in points.
1532    pub height: u32,
1533    /// `true` for the first display in the enumeration.
1534    pub is_primary: bool,
1535}
1536
1537#[cfg(target_os = "macos")]
1538impl From<media::screen::DisplaySource> for DisplaySourceView {
1539    fn from(value: media::screen::DisplaySource) -> Self {
1540        Self {
1541            id: value.id,
1542            label: value.label,
1543            width: value.width,
1544            height: value.height,
1545            is_primary: value.is_primary,
1546        }
1547    }
1548}
1549
1550/// View-model for a window source (M-SCK.1 / AUT-268).
1551#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1552pub struct WindowSourceView {
1553    /// Stable id for the current session (`window-<windowID>`).
1554    pub id: String,
1555    /// Window title (or empty).
1556    pub label: String,
1557    /// Width in points.
1558    pub width: u32,
1559    /// Height in points.
1560    pub height: u32,
1561    /// Owning app bundle id.
1562    pub bundle_id: String,
1563    /// Owning app display name.
1564    pub display_name: String,
1565}
1566
1567#[cfg(target_os = "macos")]
1568impl From<media::screen::WindowSource> for WindowSourceView {
1569    fn from(value: media::screen::WindowSource) -> Self {
1570        Self {
1571            id: value.id,
1572            label: value.label,
1573            width: value.width,
1574            height: value.height,
1575            bundle_id: value.bundle_id,
1576            display_name: value.display_name,
1577        }
1578    }
1579}
1580
1581/// Enumerate every display SCK can see (M-SCK.1 / AUT-268).
1582/// Returns empty Vec on non-macOS targets.
1583///
1584/// # Errors
1585///
1586/// Returns the SCK error as a string when SCK refuses (TCC denied,
1587/// enumeration failed).
1588#[tauri::command]
1589pub fn list_screen_displays() -> Result<Vec<DisplaySourceView>, String> {
1590    #[cfg(target_os = "macos")]
1591    {
1592        ensure_screen_recording_access()?;
1593        media::screen::list_displays()
1594            .map(|v| v.into_iter().map(DisplaySourceView::from).collect())
1595            .map_err(|err| err.to_string())
1596    }
1597    #[cfg(not(target_os = "macos"))]
1598    {
1599        Ok(Vec::new())
1600    }
1601}
1602
1603/// Enumerate every visible window SCK can see (M-SCK.1 / AUT-268).
1604///
1605/// # Errors
1606///
1607/// Returns the SCK error as a string.
1608#[tauri::command]
1609pub fn list_screen_windows() -> Result<Vec<WindowSourceView>, String> {
1610    #[cfg(target_os = "macos")]
1611    {
1612        ensure_screen_recording_access()?;
1613        media::screen::list_windows()
1614            .map(|v| v.into_iter().map(WindowSourceView::from).collect())
1615            .map_err(|err| err.to_string())
1616    }
1617    #[cfg(not(target_os = "macos"))]
1618    {
1619        Ok(Vec::new())
1620    }
1621}
1622
1623/// Start the screen-capture session targeting the picker-selected
1624/// source (M-SCK.2 / AUT-269 + M-SCK.0.1 / AUT-291). `source_id` is
1625/// `Some("display-<id>")` / `Some("window-<id>")` / `None` (primary
1626/// display). Defaults to 1920×1080 @ 30 fps with cursor shown.
1627/// Triggers the macOS Screen Recording TCC prompt on first run.
1628///
1629/// Re-entrant: passing a fresh `source_id` to a live session tears
1630/// down the existing `SCStream` and starts a new one (the picker UX
1631/// for swapping mid-record is a single click; M-SCK.0.1's
1632/// `updateContentFilter` swap-in-place is a future optimization).
1633///
1634/// # Errors
1635///
1636/// Returns the SCK error as a string. Malformed `source_id` (wrong
1637/// prefix / non-numeric tail) surfaces as
1638/// `"malformed display source id ..."` / `"malformed window source
1639/// id ..."`. Unknown source id (display unplugged / window closed
1640/// between enumeration and start) surfaces as `"<kind> id ... not
1641/// present"`.
1642#[cfg(target_os = "macos")]
1643#[tauri::command]
1644pub fn start_screen_capture(
1645    app: tauri::AppHandle,
1646    state: State<'_, ScreenCaptureState>,
1647    source_id: Option<String>,
1648) -> Result<(), String> {
1649    use media::sck_video::{ScreenCaptureConfig, ScreenCaptureSource};
1650    ensure_screen_recording_access()?;
1651    let source = match source_id.as_deref() {
1652        None | Some("") => ScreenCaptureSource::PrimaryDisplay,
1653        Some(id) if id.starts_with("display-") => ScreenCaptureSource::Display(id.to_string()),
1654        Some(id) if id.starts_with("window-") => ScreenCaptureSource::Window(id.to_string()),
1655        Some(other) => {
1656            return Err(format!(
1657                "unknown source_id prefix `{other}` (expected `display-…` or `window-…`)"
1658            ));
1659        }
1660    };
1661    // AUT-269 — this is the *preview* path: capture downscaled (so the ~15 fps
1662    // webview poll stays cheap) and exclude the recorder's own windows so the
1663    // preview doesn't capture itself (the screen-of-its-own-screen feedback).
1664    let mut config = ScreenCaptureConfig::for_source(source);
1665    config.width = crate::screen_capture::PREVIEW_WIDTH;
1666    config.height = crate::screen_capture::PREVIEW_HEIGHT;
1667    config.excluded_window_ids = crate::screen_capture::own_window_cg_ids(&app);
1668    state.start(config).map_err(|err| err.to_string())
1669}
1670
1671/// Non-macOS stub for `start_screen_capture`. Returns the
1672/// requires-macOS-13.0 error so the Leptos picker surfaces a
1673/// consistent message across platforms. Signature matches the macOS
1674/// variant so the IPC schema stays uniform.
1675///
1676/// # Errors
1677///
1678/// Always returns `"screen capture requires macOS 13.0+"`.
1679#[cfg(not(target_os = "macos"))]
1680#[tauri::command]
1681pub fn start_screen_capture(_source_id: Option<String>) -> Result<(), String> {
1682    Err("screen capture requires macOS 13.0+".into())
1683}
1684
1685/// Latest downscaled screen-preview frame as raw BGRA bytes (AUT-269), as a
1686/// [`tauri::ipc::Response`] (an `ArrayBuffer` in JS — no JSON). Empty when no
1687/// preview frame has arrived. Mirrors [`latest_camera_frame_bgra`]; the webview
1688/// polls it at ~15 fps and `putImageData`s it into the recorder canvas.
1689#[cfg(target_os = "macos")]
1690#[tauri::command]
1691#[must_use]
1692pub fn latest_screen_frame_bgra(state: State<'_, ScreenCaptureState>) -> tauri::ipc::Response {
1693    tauri::ipc::Response::new(state.latest_frame())
1694}
1695
1696/// Non-macOS stub for `latest_screen_frame_bgra`. Always empty.
1697#[cfg(not(target_os = "macos"))]
1698#[tauri::command]
1699#[must_use]
1700pub fn latest_screen_frame_bgra() -> tauri::ipc::Response {
1701    tauri::ipc::Response::new(Vec::new())
1702}
1703
1704/// Stop the active screen-capture session, if any.
1705#[cfg(target_os = "macos")]
1706#[tauri::command]
1707pub fn stop_screen_capture(state: State<'_, ScreenCaptureState>) {
1708    state.stop();
1709}
1710
1711/// Non-macOS stub for `stop_screen_capture`. No-op.
1712#[cfg(not(target_os = "macos"))]
1713#[tauri::command]
1714pub fn stop_screen_capture() {}
1715
1716/// `true` when a screen-capture session is currently running
1717/// (M-SCK.2 / AUT-269). The Leptos picker reads this on mount + on
1718/// every chevron-toggle to seed UI state.
1719#[cfg(target_os = "macos")]
1720#[tauri::command]
1721#[must_use]
1722pub fn screen_capture_status(state: State<'_, ScreenCaptureState>) -> bool {
1723    state.is_active()
1724}
1725
1726/// Non-macOS stub for `screen_capture_status`. Always `false`.
1727#[cfg(not(target_os = "macos"))]
1728#[tauri::command]
1729#[must_use]
1730pub fn screen_capture_status() -> bool {
1731    false
1732}
1733
1734/// Cumulative frame counter for the active session
1735/// (M-SCK.2 / AUT-269). Returns `0` when no session is active. Used
1736/// by the Leptos diagnostic overlay + future frame-rate monitor.
1737#[cfg(target_os = "macos")]
1738#[tauri::command]
1739#[must_use]
1740pub fn screen_capture_frame_count(state: State<'_, ScreenCaptureState>) -> u64 {
1741    state.frames_received()
1742}
1743
1744/// Non-macOS stub. Always 0.
1745#[cfg(not(target_os = "macos"))]
1746#[tauri::command]
1747#[must_use]
1748pub fn screen_capture_frame_count() -> u64 {
1749    0
1750}
1751
1752// ---- M-RECORD.1 — coordinated recording IPC --------------------------
1753
1754/// Start a coordinated recording session (M-RECORD.1 of M-RECORD-EXPORT).
1755///
1756/// Spawns each enabled per-channel pipeline (camera / screen /
1757/// microphone / system audio) inside one [`RecordingSession`]
1758/// orchestrator. Picker selections (`camera_id`, `microphone_id`,
1759/// `screen_source_id`) are threaded through to the existing per-
1760/// channel start paths so M-CAM.4 / M-MIC.3 / M-SCK.0.1 routing
1761/// applies inside a session too.
1762///
1763/// **Rollback discipline:** if any one stream fails to start, the
1764/// session aborts — the streams that already started are stopped
1765/// best-effort and the function returns `Err`.
1766///
1767/// **Lifecycle:** session enters `Starting`. The per-channel
1768/// pipelines transition `Starting → Running` independently as each
1769/// produces its first frame; the `recording-status` event push
1770/// (M-RECORD.1 follow-up commit) will roll those up into the master
1771/// `Running` transition.
1772///
1773/// # Errors
1774///
1775/// - `"a recording session is already active"` — re-entrant call
1776///   without a prior `stop_recording`. Idempotent-by-design rather
1777///   than implicit-replace because mid-session changes invalidate
1778///   the M-EXPORT encoder state.
1779/// - `"no streams enabled — pick at least one input"` — caller
1780///   passed `SessionStreams { camera: false, ... }`.
1781/// - `"screen + system audio capture require macOS 13.0+"` —
1782///   non-macOS caller enabled either of those channels.
1783/// - Underlying per-channel start error, prefixed with the channel
1784///   name (e.g. `"camera start failed: ..."`).
1785#[tauri::command]
1786#[allow(
1787    clippy::too_many_lines,
1788    reason = "Top-level orchestrator: input validation + per-channel start (camera, screen, mic, sys-audio) + encoder spin-up + session persist. Splitting per-channel helpers would just push the line count one level down while obscuring the rollback contract — every per-channel start must roll back the prior ones on failure, which is most natural as a flat sequence here."
1789)]
1790pub fn start_recording(
1791    app: tauri::AppHandle,
1792    recording_state: State<'_, RecordingState>,
1793    preview_state: State<'_, PreviewState>,
1794    camera_handle: State<'_, CameraPipelineHandle>,
1795    mic_state: State<'_, MicCaptureState>,
1796    mic_handle: State<'_, MicCaptureHandle>,
1797    config: RecordingConfig,
1798) -> Result<u64, String> {
1799    if recording_state.is_active() {
1800        return Err("a recording session is already active".into());
1801    }
1802    // M-SAVE.1 — refuse to start on top of an un-exported recording.
1803    // The Save panel keeps the Record button disabled while this is
1804    // true, but guard here too so a desynced UI can't orphan the
1805    // previous scratch file.
1806    if recording_state.has_pending_export() {
1807        return Err("a finished recording is awaiting export — export or discard it first".into());
1808    }
1809    if !config.streams.any_enabled() {
1810        return Err("no streams enabled — pick at least one input".into());
1811    }
1812    #[cfg(not(target_os = "macos"))]
1813    {
1814        if config.streams.screen || config.streams.system_audio {
1815            return Err("screen + system audio capture require macOS 13.0+".into());
1816        }
1817    }
1818    #[cfg(target_os = "macos")]
1819    {
1820        if config.streams.screen || config.streams.system_audio {
1821            ensure_screen_recording_access()?;
1822        }
1823    }
1824
1825    let session = RecordingSession::starting(config.streams);
1826    let session_id = session.id;
1827    tracing::info!(
1828        session_id,
1829        camera = config.streams.camera,
1830        screen = config.streams.screen,
1831        microphone = config.streams.microphone,
1832        system_audio = config.streams.system_audio,
1833        "start_recording: spawning per-channel pipelines"
1834    );
1835
1836    let mut started: Vec<StreamKind> = Vec::new();
1837
1838    // Camera — re-uses the M-CAM.4 routing from start_preview.
1839    if config.streams.camera {
1840        if let Err(err) = start_camera_for_session(
1841            &app,
1842            &preview_state,
1843            &camera_handle,
1844            config.camera_id.clone(),
1845        ) {
1846            rollback_started(&app, &started);
1847            return Err(format!("camera start failed: {err}"));
1848        }
1849        started.push(StreamKind::Camera);
1850    }
1851
1852    // Microphone — re-uses the M-MIC.3 native_id resolution.
1853    if config.streams.microphone {
1854        let mixer = crate::recording::SharedAudioMixer::clone(&recording_state.audio_mixer);
1855        if let Err(err) = start_mic_for_session(
1856            &app,
1857            &mic_state,
1858            &mic_handle,
1859            config.microphone_id.clone(),
1860            mixer,
1861        ) {
1862            rollback_started(&app, &started);
1863            return Err(format!("microphone start failed: {err}"));
1864        }
1865        started.push(StreamKind::Microphone);
1866    }
1867
1868    // M-QUAL.2 — the recording canvas + encoder run at the screen
1869    // source's *native* backing-pixel resolution, captured from the
1870    // screen-start below. Stays at the 1920×1080 default for a
1871    // camera-only recording (no screen source to size against).
1872    #[cfg(target_os = "macos")]
1873    let mut screen_native_dims = (
1874        media::sck_video::DEFAULT_WIDTH,
1875        media::sck_video::DEFAULT_HEIGHT,
1876    );
1877
1878    // Screen (macOS-only) — re-uses M-SCK.0.1 source routing.
1879    #[cfg(target_os = "macos")]
1880    if config.streams.screen {
1881        match start_screen_for_session(&app, config.screen_source_id.as_deref()) {
1882            Ok(dims) => screen_native_dims = dims,
1883            Err(err) => {
1884                rollback_started(&app, &started);
1885                return Err(format!("screen start failed: {err}"));
1886            }
1887        }
1888        started.push(StreamKind::Screen);
1889    }
1890
1891    // System audio (macOS-only).
1892    #[cfg(target_os = "macos")]
1893    if config.streams.system_audio {
1894        if let Err(err) = start_sys_audio_for_session(&app) {
1895            rollback_started(&app, &started);
1896            return Err(format!("system audio start failed: {err}"));
1897        }
1898        started.push(StreamKind::SystemAudio);
1899    }
1900
1901    // M-EXPORT.3 + M-PIX.6 — spin up the encoder. Two feed-thread
1902    // variants:
1903    //
1904    // - If any video channel (camera or screen) is enabled, use
1905    //   the M-PIX.6 real-capture feed: pulls composed frames from
1906    //   the wisp render pump + mixed audio from the AudioMixer.
1907    // - Otherwise (audio-only or no-channels-enabled debug),
1908    //   fall back to the M-EXPORT.3 test-pattern feed so the
1909    //   encoder still produces a valid container.
1910    //
1911    // M-SAVE.1 — always encode to a scratch MP4/H.264 (the canonical
1912    // intermediate). The export *format* is chosen later in the Save
1913    // panel: an MP4 export moves this file as-is; a WebM export
1914    // transcodes it. `config.output_path` / `config.format` are no
1915    // longer consulted at record time. Failure here rolls back the
1916    // per-channel streams too.
1917    let scratch_path = scratch_file_path(&app, session_id)?;
1918    // M-QUAL.2 — encode at the native screen resolution resolved
1919    // above (matches the SCK capture caps + the compose canvas), capped
1920    // to the H.264 hardware-encoder limit for >4K displays (AUT-334).
1921    // Camera-only / non-macOS keep the 1920×1080 `for_output` default.
1922    #[cfg(target_os = "macos")]
1923    let encoder_config = media::encode::EncoderConfig {
1924        width: screen_native_dims.0,
1925        height: screen_native_dims.1,
1926        ..media::encode::EncoderConfig::for_output(
1927            scratch_path,
1928            media::encode::OutputFormat::Mp4H264Aac,
1929        )
1930    };
1931    #[cfg(not(target_os = "macos"))]
1932    let encoder_config = media::encode::EncoderConfig::for_output(
1933        scratch_path,
1934        media::encode::OutputFormat::Mp4H264Aac,
1935    );
1936    let session_id_for_palette = session.id;
1937    let has_video = config.streams.camera || config.streams.screen;
1938    let handle_result = if has_video {
1939        use wisp::recording::StreamDimensions;
1940        // Scene dims match what the capture sides emit. On macOS this
1941        // is the screen source's native resolution (M-QUAL.2),
1942        // resolved at screen-start and equal to the encoder caps so
1943        // the screen sprite fills the canvas 1:1. On non-macOS neither
1944        // screen nor system-audio runs yet (rolled back at top of fn),
1945        // so the default 1920×1080 stands in.
1946        #[cfg(target_os = "macos")]
1947        let (sck_w, sck_h) = screen_native_dims;
1948        #[cfg(not(target_os = "macos"))]
1949        let (sck_w, sck_h) = (1920_u32, 1080_u32);
1950        let screen_dims = StreamDimensions::new(sck_w, sck_h);
1951        let cam_dims = StreamDimensions::new(
1952            crate::preview::pipeline::PREVIEW_WIDTH,
1953            crate::preview::pipeline::PREVIEW_HEIGHT,
1954        );
1955        // For disabled channels, hand the compose pipeline a fresh
1956        // empty `FrameSlot` instead of the long-lived shared one — the
1957        // shared slot may still hold a frame written by an in-flight
1958        // preview pipeline (cam: picker preview / bubble window;
1959        // screen: future preview), or a stale frame from a previous
1960        // session. Reading from a fresh slot guarantees the compose
1961        // pipeline never sees a frame for a channel the user toggled
1962        // off, so `has_camera_frame` / `has_screen_frame` stay false
1963        // and the matching sprite is hidden (see
1964        // `recording_compose.rs::compose_frame`).
1965        let camera_slot = if config.streams.camera {
1966            crate::recording::FrameSlot::clone(&recording_state.camera_frame_slot)
1967        } else {
1968            crate::recording::new_frame_slot()
1969        };
1970        let screen_slot = if config.streams.screen {
1971            crate::recording::FrameSlot::clone(&recording_state.screen_frame_slot)
1972        } else {
1973            crate::recording::new_frame_slot()
1974        };
1975        let mixer = crate::recording::SharedAudioMixer::clone(&recording_state.audio_mixer);
1976        crate::recording::EncoderHandle::start_with_real_capture(
1977            encoder_config,
1978            camera_slot,
1979            screen_slot,
1980            mixer,
1981            screen_dims,
1982            cam_dims,
1983        )
1984    } else {
1985        crate::recording::EncoderHandle::start_with_test_pattern(
1986            encoder_config,
1987            session_id_for_palette,
1988        )
1989    };
1990    match handle_result {
1991        Ok(handle) => {
1992            tracing::info!(
1993                session_id,
1994                output_path = %handle.output_path.display(),
1995                feed_kind = if has_video { "real-capture" } else { "test-pattern" },
1996                "start_recording: encoder started"
1997            );
1998            recording_state.install_encoder(handle);
1999        }
2000        Err(err) => {
2001            rollback_started(&app, &started);
2002            return Err(format!("encoder start failed: {err}"));
2003        }
2004    }
2005
2006    {
2007        let mut guard = recording_state
2008            .session
2009            .lock()
2010            .unwrap_or_else(std::sync::PoisonError::into_inner);
2011        *guard = Some(session);
2012    }
2013    // ED.17: start capturing the cursor track for the editor's overlay /
2014    // auto-zoom. No-op on non-macOS; macOS polls the global pointer with no
2015    // Input-Monitoring permission. Normalized against the captured display
2016    // (ISS-17).
2017    recording_state.start_cursor_capture(config.screen_source_id.as_deref());
2018    // ED.17 / ISS-16: also capture the click log (auto-zoom + ED.19 ripples)
2019    // via a listen-only CGEventTap. No-op on non-macOS; on macOS it degrades
2020    // to an empty log if Input-Monitoring permission isn't granted.
2021    recording_state.start_click_capture(config.screen_source_id.as_deref());
2022    spawn_status_emitter(app.clone(), session_id);
2023    Ok(session_id)
2024}
2025
2026/// Directory for in-progress / awaiting-export scratch recordings
2027/// (M-SAVE.1). Under the app *cache* dir so it's app-scoped and on
2028/// the home volume (so the export `rename` into `~/Movies/Screen`
2029/// etc. is atomic rather than a cross-device copy). `None` only if
2030/// the platform path resolver fails.
2031fn scratch_dir(app: &tauri::AppHandle) -> Option<PathBuf> {
2032    Some(app.path().app_cache_dir().ok()?.join("recordings-scratch"))
2033}
2034
2035/// Scratch file path for `session_id` — `scratch-<id>.mp4`. The
2036/// scratch is always MP4/H.264 (the canonical intermediate the Save
2037/// panel moves or transcodes).
2038///
2039/// # Errors
2040///
2041/// `"app cache dir unavailable …"` when the platform path resolver
2042/// fails (no `$HOME`, sandbox without a cache dir).
2043fn scratch_file_path(app: &tauri::AppHandle, session_id: u64) -> Result<PathBuf, String> {
2044    let dir = scratch_dir(app).ok_or("app cache dir unavailable for scratch recording")?;
2045    Ok(dir.join(format!("scratch-{session_id}.mp4")))
2046}
2047
2048/// Clear every file in the scratch dir (M-SAVE.1). Called once at app
2049/// startup from `main.rs`: any scratch left by a crash or an
2050/// un-exported recording from a previous run is abandoned (v0 has no
2051/// cross-launch export recovery). Best-effort — logs and continues on
2052/// failure.
2053pub fn clean_scratch_dir(app: &tauri::AppHandle) {
2054    let Some(dir) = scratch_dir(app) else {
2055        return;
2056    };
2057    if !dir.exists() {
2058        return;
2059    }
2060    match std::fs::remove_dir_all(&dir) {
2061        Ok(()) => {
2062            tracing::info!(dir = %dir.display(), "clean_scratch_dir: cleared scratch recordings");
2063        }
2064        Err(err) => {
2065            tracing::warn!(?err, dir = %dir.display(), "clean_scratch_dir: failed to clear scratch dir");
2066        }
2067    }
2068}
2069
2070/// Stop the active recording session (M-RECORD.1).
2071///
2072/// Tears down each enabled per-channel pipeline in reverse start
2073/// order. The returned [`RecordingSummary`] carries the final
2074/// per-stream tally + the encoded file path (M-EXPORT.4 populates
2075/// the path; today it's `None`).
2076///
2077/// # Errors
2078///
2079/// - `"no recording session is active"` — caller invoked stop
2080///   without a matching start.
2081#[tauri::command]
2082pub fn stop_recording(
2083    app: tauri::AppHandle,
2084    recording_state: State<'_, RecordingState>,
2085    // No PreviewState / CameraPipelineHandle here: the camera worker
2086    // backs the live preview and is owned by start_preview /
2087    // stop_preview, so recording stop does NOT touch it (see the camera
2088    // note in the teardown below).
2089    mic_state: State<'_, MicCaptureState>,
2090    mic_handle: State<'_, MicCaptureHandle>,
2091) -> Result<RecordingSummary, String> {
2092    let Some(mut session) = recording_state.snapshot() else {
2093        return Err("no recording session is active".into());
2094    };
2095    session.begin_stop();
2096    {
2097        let mut guard = recording_state
2098            .session
2099            .lock()
2100            .unwrap_or_else(std::sync::PoisonError::into_inner);
2101        *guard = Some(session.clone());
2102    }
2103
2104    let final_health = build_stream_health_snapshot(&app, session.streams, session.started_at);
2105
2106    // Reverse start order so teardown mirrors construction.
2107    #[cfg(target_os = "macos")]
2108    if session.streams.system_audio {
2109        let _ = stop_sys_audio_for_session(&app);
2110    }
2111    #[cfg(target_os = "macos")]
2112    if session.streams.screen {
2113        let _ = stop_screen_for_session(&app);
2114    }
2115    if session.streams.microphone {
2116        stop_mic_for_session(&mic_state, &mic_handle);
2117    }
2118    // Camera is intentionally NOT stopped here (M-QUAL.6). Unlike the
2119    // screen/mic/sys-audio captures (recording-only), the camera worker
2120    // backs the *live preview* (the webcam bubble) and is owned by
2121    // start_preview / stop_preview. Recording only borrows its frames
2122    // via the shared CameraFrameSlot. Tearing it down on stop froze the
2123    // preview on its last frame until the next record; leaving it
2124    // running keeps the bubble live. It stops when the user disables the
2125    // camera or closes the recorder (stop_preview).
2126
2127    session.finish_stop();
2128    let elapsed_ms = u64::try_from(session.elapsed().as_millis()).unwrap_or(u64::MAX);
2129
2130    // M-SAVE.1 — finalize the encoder to its scratch file, then stash
2131    // it as a *pending export* instead of writing the final file now.
2132    // The Save panel picks the format + folder; `export_recording`
2133    // moves (MP4) or transcodes (WebM) the scratch into place and
2134    // generates the AVIF poster next to the *exported* file (M-SAVE.2).
2135    let pending_export = if let Some(handle) = recording_state.take_encoder() {
2136        let scratch_path = handle.output_path.clone();
2137        match handle.finalize_now() {
2138            Ok(final_scratch) => {
2139                tracing::info!(
2140                    session_id = session.id,
2141                    scratch = %final_scratch.display(),
2142                    "stop_recording: encoder finalized to scratch; awaiting export"
2143                );
2144                let pending = crate::recording::PendingExport {
2145                    scratch_path: final_scratch,
2146                    duration_ms: elapsed_ms,
2147                    started_at_unix_secs: session.started_at_unix_secs,
2148                };
2149                let view = pending.view();
2150                recording_state.set_pending_export(pending);
2151                Some(view)
2152            }
2153            Err(err) => {
2154                // A finalize failure usually means the mux never wrote
2155                // its index / moov atom — the scratch is unplayable.
2156                // Drop it and surface no pending export; the UI shows
2157                // the recording as failed (neither path nor pending).
2158                tracing::error!(?err, scratch = %scratch_path.display(), "stop_recording: finalize failed; discarding scratch");
2159                let _ = std::fs::remove_file(&scratch_path);
2160                None
2161            }
2162        }
2163    } else {
2164        None
2165    };
2166
2167    // ED.17: stop the cursor poller + stash the resampled track for the
2168    // Record→Edit handoff (`open_in_editor` consumes it). Always called so the
2169    // poller thread never outlives the recording.
2170    recording_state.finish_cursor_capture(edit::DEFAULT_PROJECT_FPS);
2171    // ED.17 / ISS-16: stop the click tap + stash the resampled click log
2172    // (same handoff). Always called so the run-loop worker never outlives the
2173    // recording.
2174    recording_state.finish_click_capture(edit::DEFAULT_PROJECT_FPS);
2175
2176    let summary = RecordingSummary {
2177        session_id: session.id,
2178        elapsed_ms,
2179        streams: final_health,
2180        // Stop no longer writes the final file (M-SAVE.1) — export does.
2181        output_path: None,
2182        pending_export,
2183    };
2184
2185    {
2186        let mut guard = recording_state
2187            .session
2188            .lock()
2189            .unwrap_or_else(std::sync::PoisonError::into_inner);
2190        *guard = None;
2191    }
2192    tracing::info!(
2193        session_id = summary.session_id,
2194        elapsed_ms,
2195        "stop_recording: session torn down"
2196    );
2197    Ok(summary)
2198}
2199
2200/// Live snapshot of the recording session for the picker LED ramp
2201/// + elapsed counter. Returns `RecordingStatusView::idle()` when no
2202/// session is active. Also published via the `recording-status`
2203/// event every 500 ms while a session is running.
2204#[tauri::command]
2205#[must_use]
2206pub fn recording_status(
2207    app: tauri::AppHandle,
2208    recording_state: State<'_, RecordingState>,
2209) -> RecordingStatusView {
2210    let Some(session) = recording_state.snapshot() else {
2211        return RecordingStatusView::idle();
2212    };
2213    let elapsed_ms = u64::try_from(session.elapsed().as_millis()).unwrap_or(u64::MAX);
2214    let streams = build_stream_health_snapshot(&app, session.streams, session.started_at);
2215    RecordingStatusView {
2216        session_id: Some(session.id),
2217        state: session.state,
2218        elapsed_ms,
2219        streams,
2220    }
2221}
2222
2223// ---- M-SAVE.1 — deferred export (Save panel) ---------------------
2224
2225/// The recording currently sitting in scratch awaiting export, if
2226/// any (M-SAVE.1). The Save panel polls this on mount (and after
2227/// `stop_recording`) to decide whether to appear. `None` when nothing
2228/// is awaiting export.
2229#[tauri::command]
2230#[must_use]
2231pub fn recording_pending_export(
2232    recording_state: State<'_, RecordingState>,
2233) -> Option<crate::recording::PendingExportView> {
2234    recording_state.pending_export_view()
2235}
2236
2237/// Export the pending recording to `output_dir` in `format`, then
2238/// return the final absolute path (M-SAVE.1 / .2).
2239///
2240/// - `format` — slug (`"mp4-h264"` / `"webm-vp9"`); `None` → default
2241///   (`mp4-h264`). The scratch is MP4/H.264: an **MP4** export is a
2242///   fast move; a **WebM** export re-encodes the scratch to VP9 + Opus
2243///   (M-SAVE.2). H.265 / AV1 aren't exposed in the UI and return an
2244///   "unsupported" error.
2245/// - `output_dir` — override folder; `None` / empty → the persisted
2246///   default ([`recorder_settings::resolved_output_dir`](crate::recorder_settings::resolved_output_dir)).
2247///
2248/// Runs the move / transcode + the AVIF poster on the blocking thread
2249/// pool (`spawn_blocking`) so the webview stays responsive during a
2250/// multi-second `WebM` transcode. On success the chosen format is
2251/// persisted as the Save-panel default; on failure the pending export
2252/// is restored so the user can retry.
2253///
2254/// # Errors
2255///
2256/// - `"no recording is awaiting export"` — nothing pending.
2257/// - move / transcode failure (permissions, disk full, gst error) —
2258///   surfaced verbatim.
2259/// - `"export to … is not supported"` — H.265 / AV1.
2260#[tauri::command]
2261pub async fn export_recording(
2262    app: tauri::AppHandle,
2263    format: Option<String>,
2264    output_dir: Option<String>,
2265) -> Result<String, String> {
2266    use media::encode::OutputFormat;
2267
2268    // An async command can't hold a `State<'_>` borrow across `.await`,
2269    // so resolve `RecordingState` via `app.state()` at each touch point.
2270    let pending = app
2271        .state::<RecordingState>()
2272        .take_pending_export()
2273        .ok_or("no recording is awaiting export")?;
2274    // ED.17: a raw export doesn't use the cursor track or click log — drop
2275    // them so they can't leak onto a later, unrelated edit.
2276    app.state::<RecordingState>().clear_cursor_track();
2277    app.state::<RecordingState>().clear_clicks();
2278    let format = format
2279        .as_deref()
2280        .and_then(OutputFormat::from_slug)
2281        .unwrap_or_default();
2282    let dir = output_dir.filter(|s| !s.trim().is_empty()).map_or_else(
2283        || crate::recorder_settings::resolved_output_dir(&app),
2284        PathBuf::from,
2285    );
2286    let final_path = dir.join(crate::recording_paths::default_filename(
2287        pending.started_at_unix_secs,
2288        format,
2289    ));
2290
2291    // Move (MP4) / transcode (WebM) + poster generation all spawn gst
2292    // subprocesses or touch disk; run them off the main thread.
2293    let scratch = pending.scratch_path.clone();
2294    let final_for_job = final_path.clone();
2295    let job = tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
2296        crate::recording_paths::ensure_parent_dir(&final_for_job)
2297            .map_err(|err| format!("failed to create output dir: {err}"))?;
2298        // The scratch is MP4/H.264: MP4 export is a move, WebM a
2299        // transcode. H.265 / AV1 aren't offered in the UI.
2300        match format {
2301            OutputFormat::Mp4H264Aac => {
2302                crate::recording_paths::move_file(&scratch, &final_for_job).map_err(|err| {
2303                    format!(
2304                        "failed to move recording to {}: {err}",
2305                        final_for_job.display()
2306                    )
2307                })?;
2308            }
2309            OutputFormat::WebmVp9Opus => {
2310                media::encode::transcode_to_webm(&scratch, &final_for_job)
2311                    .map_err(|err| format!("WebM transcode failed: {err}"))?;
2312                let _ = std::fs::remove_file(&scratch);
2313            }
2314            OutputFormat::Mp4H265Aac | OutputFormat::WebmAv1Opus => {
2315                return Err(format!("export to {} is not supported", format.slug()));
2316            }
2317        }
2318        // Best-effort AVIF poster next to the exported file.
2319        match media::encode::generate_poster(&final_for_job) {
2320            Ok(Some(poster)) => {
2321                tracing::info!(poster = %poster.display(), "export_recording: poster written");
2322            }
2323            Ok(None) => tracing::debug!("export_recording: poster skipped (avifenc missing)"),
2324            Err(err) => tracing::warn!(?err, "export_recording: poster generation failed"),
2325        }
2326        Ok(())
2327    })
2328    .await
2329    .map_err(|err| format!("export task failed to join: {err}"))?;
2330
2331    match job {
2332        Ok(()) => {
2333            // Persist the chosen format as the Save-panel default.
2334            let mut settings = crate::recorder_settings::load(&app);
2335            settings.last_format = Some(format.slug().to_owned());
2336            if let Err(err) = crate::recorder_settings::save(&app, &settings) {
2337                tracing::warn!(?err, "export_recording: failed to persist last_format");
2338            }
2339            tracing::info!(output = %final_path.display(), format = format.slug(), "export_recording: file exported");
2340            Ok(final_path.to_string_lossy().into_owned())
2341        }
2342        Err(err) => {
2343            // Restore so the user can retry with a different format /
2344            // folder. The MP4 move and a failed WebM transcode both
2345            // leave the scratch in place (only a *successful* WebM
2346            // export removes it), so the restored path is still valid.
2347            app.state::<RecordingState>().set_pending_export(pending);
2348            Err(err)
2349        }
2350    }
2351}
2352
2353/// Discard the pending recording — delete its scratch file and clear
2354/// the awaiting-export state (M-SAVE.1). No-op when nothing is pending;
2355/// a missing / unremovable scratch is logged, not surfaced. Returns
2356/// `Result` (always `Ok` today) to keep the IPC signature stable.
2357#[tauri::command]
2358#[allow(
2359    clippy::unnecessary_wraps,
2360    reason = "IPC signature stability — a delete failure may be surfaced in future."
2361)]
2362pub fn discard_recording(recording_state: State<'_, RecordingState>) -> Result<(), String> {
2363    // ED.17: a discarded recording's cursor track + click log are moot — drop.
2364    recording_state.clear_cursor_track();
2365    recording_state.clear_clicks();
2366    if let Some(pending) = recording_state.take_pending_export() {
2367        match std::fs::remove_file(&pending.scratch_path) {
2368            Ok(()) => {
2369                tracing::info!(scratch = %pending.scratch_path.display(), "discard_recording: scratch removed");
2370            }
2371            Err(err) => {
2372                tracing::warn!(?err, scratch = %pending.scratch_path.display(), "discard_recording: scratch already gone / unremovable");
2373            }
2374        }
2375    }
2376    Ok(())
2377}
2378
2379// ---- M-RECORD.1 internal helpers ---------------------------------
2380
2381/// Direct-call equivalent of `start_preview` — bypasses the
2382/// `#[tauri::command]` layer so the session orchestrator can
2383/// coordinate with the existing `PreviewState` lifecycle.
2384fn start_camera_for_session(
2385    app: &tauri::AppHandle,
2386    preview_state: &PreviewState,
2387    camera_handle: &CameraPipelineHandle,
2388    camera_id: String,
2389) -> Result<(), CameraError> {
2390    {
2391        let mut guard = preview_state
2392            .0
2393            .lock()
2394            .unwrap_or_else(std::sync::PoisonError::into_inner);
2395        let new_state = guard.try_start();
2396        // Re-entrant attempt (already Starting/Running): treat as
2397        // success — the session is reusing the existing pipeline.
2398        if new_state == *guard {
2399            return Ok(());
2400        }
2401        *guard = new_state;
2402    }
2403    let pipeline = CameraPipeline::spawn(app.clone(), camera_id)?;
2404    camera_handle.install(pipeline);
2405    Ok(())
2406}
2407
2408fn stop_camera_for_session(preview_state: &PreviewState, camera_handle: &CameraPipelineHandle) {
2409    {
2410        let mut guard = preview_state
2411            .0
2412            .lock()
2413            .unwrap_or_else(std::sync::PoisonError::into_inner);
2414        *guard = guard.try_stop();
2415    }
2416    camera_handle.shutdown();
2417    {
2418        let mut guard = preview_state
2419            .0
2420            .lock()
2421            .unwrap_or_else(std::sync::PoisonError::into_inner);
2422        *guard = guard.finish_stop();
2423    }
2424}
2425
2426fn start_mic_for_session(
2427    app: &tauri::AppHandle,
2428    mic_state: &MicCaptureState,
2429    mic_handle: &MicCaptureHandle,
2430    mic_id: String,
2431    mixer: crate::recording::SharedAudioMixer,
2432) -> Result<(), MicError> {
2433    let native_id = if mic_id.is_empty() {
2434        String::new()
2435    } else if let Some(device) = media::microphone::find_by_id(&mic_id) {
2436        device.native_id
2437    } else {
2438        return Err(MicError::NotFound(mic_id));
2439    };
2440
2441    // Tear down any prior session held by an out-of-band caller
2442    // (e.g. the picker's preview pipeline driving the level meter).
2443    if mic_handle.is_active() {
2444        mic_handle.shutdown();
2445        let mut guard = mic_state
2446            .0
2447            .lock()
2448            .unwrap_or_else(std::sync::PoisonError::into_inner);
2449        *guard = guard.try_stop().finish_stop();
2450    }
2451    {
2452        let mut guard = mic_state
2453            .0
2454            .lock()
2455            .unwrap_or_else(std::sync::PoisonError::into_inner);
2456        let prev_state = *guard;
2457        let new_state = guard.try_start();
2458        if new_state == *guard {
2459            // State was already Starting/Running/Stopping — no new
2460            // pipeline is spawned. The recording session won't see
2461            // any mic samples until whatever owns the prior worker
2462            // tears it down. Surfaces as a silent-audio recording,
2463            // which is the bug class this warning exists to catch.
2464            tracing::warn!(
2465                ?prev_state,
2466                "start_mic_for_session: state desynced from handle; no pipeline spawned"
2467            );
2468            return Ok(());
2469        }
2470        *guard = new_state;
2471    }
2472    // Recording path: pass `Some(mixer)` so the worker forwards samples
2473    // into the shared AudioMixer for the encoder feed thread to pull.
2474    let pipeline = MicCapturePipeline::spawn(app.clone(), mic_id, native_id, Some(mixer))?;
2475    mic_handle.install(pipeline);
2476    Ok(())
2477}
2478
2479fn stop_mic_for_session(mic_state: &MicCaptureState, mic_handle: &MicCaptureHandle) {
2480    {
2481        let mut guard = mic_state
2482            .0
2483            .lock()
2484            .unwrap_or_else(std::sync::PoisonError::into_inner);
2485        *guard = guard.try_stop();
2486    }
2487    mic_handle.shutdown();
2488    {
2489        let mut guard = mic_state
2490            .0
2491            .lock()
2492            .unwrap_or_else(std::sync::PoisonError::into_inner);
2493        *guard = guard.finish_stop();
2494    }
2495}
2496
2497/// Start the recording screen capture at the source's **native**
2498/// backing-pixel resolution and return the resolved `(width, height)`
2499/// so the caller threads the same dims into the encoder + compose
2500/// canvas (M-QUAL.2). Returns the dims on success.
2501#[cfg(target_os = "macos")]
2502fn start_screen_for_session(
2503    app: &tauri::AppHandle,
2504    source_id: Option<&str>,
2505) -> Result<(u32, u32), String> {
2506    use media::sck_video::{ScreenCaptureConfig, ScreenCaptureSource};
2507    let Some(state) = app.try_state::<ScreenCaptureState>() else {
2508        return Err("ScreenCaptureState not managed".into());
2509    };
2510    let source = match source_id {
2511        None | Some("") => ScreenCaptureSource::PrimaryDisplay,
2512        Some(id) if id.starts_with("display-") => ScreenCaptureSource::Display(id.to_string()),
2513        Some(id) if id.starts_with("window-") => ScreenCaptureSource::Window(id.to_string()),
2514        Some(other) => return Err(format!("unknown source_id prefix `{other}`")),
2515    };
2516    // Resolve the display's native resolution (before `source` is moved
2517    // into the config).
2518    let native_dims = media::sck_video::resolve_native_screen_dims(&source);
2519    // Cap the recording to 1080p regardless of monitor. Native Retina
2520    // capture (M-QUAL.2) is too heavy for the live compose → read-back →
2521    // encode loop to sustain 30fps, which under-delivers frames and makes
2522    // recordings play fast (the encoder timestamps by count); 1080p keeps
2523    // the pipeline comfortably real-time. The SCK capture buffer, the
2524    // compose canvas, and the encoder caps all derive from the returned
2525    // dims, so this single aspect-preserving cap keeps every stage 1:1 and
2526    // well under the H.264 4096-edge limit (subsumes the AUT-334 clamp).
2527    let dims = media::encode::cap_recording_dims(native_dims.0, native_dims.1);
2528    if dims != native_dims {
2529        tracing::info!(
2530            native_width = native_dims.0,
2531            native_height = native_dims.1,
2532            encode_width = dims.0,
2533            encode_height = dims.1,
2534            "capping recording to 1080p for real-time capture (aspect preserved)"
2535        );
2536    }
2537    // M-PIX.2 — plumb the shared screen frame slot from
2538    // RecordingState into the SCK delegate so it writes BGRA bytes
2539    // there for the encoder feed thread.
2540    let frame_slot = app
2541        .try_state::<RecordingState>()
2542        .map(|s| crate::recording::FrameSlot::clone(&s.screen_frame_slot));
2543    // Exclude the recorder's own webcam-bubble window from the
2544    // capture so it doesn't dup with the wisp-composited cam bubble
2545    // (only relevant for display sources; window-source filter
2546    // targets a single window and ignores the exclusion list).
2547    let excluded_window_ids = crate::screen_capture::bubble_window_cg_id(app)
2548        .map(|id| vec![id])
2549        .unwrap_or_default();
2550    let mut config = ScreenCaptureConfig::for_source(source);
2551    config.width = dims.0;
2552    config.height = dims.1;
2553    config.excluded_window_ids = excluded_window_ids;
2554    state
2555        .start_with_frame_slot(config, frame_slot)
2556        .map_err(|e| e.to_string())?;
2557    Ok(dims)
2558}
2559
2560#[cfg(target_os = "macos")]
2561fn stop_screen_for_session(app: &tauri::AppHandle) -> Result<(), String> {
2562    let Some(state) = app.try_state::<ScreenCaptureState>() else {
2563        return Err("ScreenCaptureState not managed".into());
2564    };
2565    state.stop();
2566    Ok(())
2567}
2568
2569#[cfg(target_os = "macos")]
2570fn start_sys_audio_for_session(app: &tauri::AppHandle) -> Result<(), String> {
2571    let Some(state) = app.try_state::<SystemAudioCaptureState>() else {
2572        return Err("SystemAudioCaptureState not managed".into());
2573    };
2574    // M-PIX.4 — plumb the shared AudioMixer from RecordingState so
2575    // the SCK delegate forwards system-audio F32 samples into it
2576    // for the encoder feed thread to pull.
2577    let mixer = app
2578        .try_state::<RecordingState>()
2579        .map(|s| crate::recording::SharedAudioMixer::clone(&s.audio_mixer));
2580    state
2581        .start_with_mixer(app, media::sck_audio::SystemAudioConfig::default(), mixer)
2582        .map_err(|e| e.to_string())
2583}
2584
2585#[cfg(target_os = "macos")]
2586fn stop_sys_audio_for_session(app: &tauri::AppHandle) -> Result<(), String> {
2587    let Some(state) = app.try_state::<SystemAudioCaptureState>() else {
2588        return Err("SystemAudioCaptureState not managed".into());
2589    };
2590    state.stop();
2591    Ok(())
2592}
2593
2594/// Roll back partially-started channels after a per-channel start
2595/// failure mid-session. Best-effort — each stop swallows its own
2596/// errors since we're already on the error path.
2597fn rollback_started(app: &tauri::AppHandle, started: &[StreamKind]) {
2598    tracing::warn!(?started, "start_recording: rolling back partial start");
2599    for kind in started.iter().rev() {
2600        match kind {
2601            StreamKind::Camera => {
2602                if let (Some(preview), Some(handle)) = (
2603                    app.try_state::<PreviewState>(),
2604                    app.try_state::<CameraPipelineHandle>(),
2605                ) {
2606                    stop_camera_for_session(&preview, &handle);
2607                }
2608            }
2609            StreamKind::Microphone => {
2610                if let (Some(state), Some(handle)) = (
2611                    app.try_state::<MicCaptureState>(),
2612                    app.try_state::<MicCaptureHandle>(),
2613                ) {
2614                    stop_mic_for_session(&state, &handle);
2615                }
2616            }
2617            #[cfg(target_os = "macos")]
2618            StreamKind::Screen => {
2619                let _ = stop_screen_for_session(app);
2620            }
2621            #[cfg(target_os = "macos")]
2622            StreamKind::SystemAudio => {
2623                let _ = stop_sys_audio_for_session(app);
2624            }
2625            #[cfg(not(target_os = "macos"))]
2626            StreamKind::Screen | StreamKind::SystemAudio => {
2627                // Can never have been started — guarded out at top of
2628                // start_recording.
2629            }
2630        }
2631    }
2632}
2633
2634/// Build the per-stream `StreamHealth` snapshot by querying each
2635/// enabled channel's existing State<> handle. Called by both
2636/// `recording_status` (live polling) and `stop_recording` (final
2637/// summary). `last_frame_ms_ago` is left `None` for now — the
2638/// per-channel handles don't yet expose a `last_frame_at` timestamp
2639/// (TODO M-RECORD-EXPORT follow-up; M-RECORD.2's LED ramp already
2640/// handles `None` as "no recent frame, render yellow/red based on
2641/// session age").
2642fn build_stream_health_snapshot(
2643    app: &tauri::AppHandle,
2644    streams: SessionStreams,
2645    _started_at: std::time::Instant,
2646) -> Vec<StreamHealth> {
2647    let mut out: Vec<StreamHealth> = Vec::new();
2648    for kind in streams.enabled_kinds() {
2649        let (lifecycle, frame_count) = match kind {
2650            StreamKind::Camera => {
2651                let life = app.try_state::<PreviewState>().map_or_else(
2652                    || "Idle".into(),
2653                    |s| {
2654                        format!(
2655                            "{:?}",
2656                            *s.0.lock()
2657                                .unwrap_or_else(std::sync::PoisonError::into_inner)
2658                        )
2659                    },
2660                );
2661                let count = app
2662                    .try_state::<PreviewDiagnostics>()
2663                    .map_or(0, |s| s.snapshot().frames_received);
2664                (life, count)
2665            }
2666            StreamKind::Microphone => {
2667                let life = app.try_state::<MicCaptureState>().map_or_else(
2668                    || "Idle".into(),
2669                    |s| {
2670                        format!(
2671                            "{:?}",
2672                            *s.0.lock()
2673                                .unwrap_or_else(std::sync::PoisonError::into_inner)
2674                        )
2675                    },
2676                );
2677                // No frame-counter exposed today; left 0.
2678                (life, 0)
2679            }
2680            #[cfg(target_os = "macos")]
2681            StreamKind::Screen => {
2682                let count = app
2683                    .try_state::<ScreenCaptureState>()
2684                    .map_or(0, |s| s.frames_received());
2685                let life = if app
2686                    .try_state::<ScreenCaptureState>()
2687                    .is_some_and(|s| s.is_active())
2688                {
2689                    "Running".into()
2690                } else {
2691                    "Idle".into()
2692                };
2693                (life, count)
2694            }
2695            #[cfg(not(target_os = "macos"))]
2696            StreamKind::Screen => ("Idle".into(), 0),
2697            #[cfg(target_os = "macos")]
2698            StreamKind::SystemAudio => {
2699                let active = app
2700                    .try_state::<SystemAudioCaptureState>()
2701                    .is_some_and(|s| s.is_active());
2702                (
2703                    if active {
2704                        "Running".into()
2705                    } else {
2706                        "Idle".into()
2707                    },
2708                    0,
2709                )
2710            }
2711            #[cfg(not(target_os = "macos"))]
2712            StreamKind::SystemAudio => ("Idle".into(), 0),
2713        };
2714        out.push(StreamHealth {
2715            kind,
2716            lifecycle,
2717            frame_count,
2718            last_frame_ms_ago: None,
2719        });
2720    }
2721    out
2722}
2723
2724/// Spawn the 500 ms event-push thread. Loops emitting
2725/// `recording-status` until the session is gone from
2726/// `RecordingState`. Self-terminates on session end so callers don't
2727/// need to track the `JoinHandle`. Plain `std::thread` rather than a
2728/// tokio task — Tauri's `Emitter` is sync-friendly and avoids
2729/// adding a direct tokio dep (Tauri uses tokio internally but
2730/// doesn't re-export `tokio::time::interval`).
2731fn spawn_status_emitter(app: tauri::AppHandle, session_id: u64) {
2732    use tauri::Emitter;
2733    std::thread::Builder::new()
2734        .name(format!("recording-status-emitter-{session_id}"))
2735        .spawn(move || {
2736            loop {
2737                std::thread::sleep(std::time::Duration::from_millis(500));
2738                let Some(state) = app.try_state::<RecordingState>() else {
2739                    break;
2740                };
2741                let Some(session) = state.snapshot() else {
2742                    break;
2743                };
2744                if session.id != session_id {
2745                    // A new session started before this thread
2746                    // observed its predecessor's end. Newer thread
2747                    // takes over.
2748                    break;
2749                }
2750                let elapsed_ms = u64::try_from(session.elapsed().as_millis()).unwrap_or(u64::MAX);
2751                let view = RecordingStatusView {
2752                    session_id: Some(session.id),
2753                    state: session.state,
2754                    elapsed_ms,
2755                    streams: build_stream_health_snapshot(
2756                        &app,
2757                        session.streams,
2758                        session.started_at,
2759                    ),
2760                };
2761                // M-RECORD.1: fold per-stream Running observation up
2762                // to the master session — every enabled stream
2763                // non-Idle → advance Starting → Running.
2764                if session.state == SessionState::Starting
2765                    && !view.streams.is_empty()
2766                    && view.streams.iter().all(|h| h.lifecycle != "Idle")
2767                    && let Some(s) = app.try_state::<RecordingState>()
2768                {
2769                    let mut guard = s
2770                        .session
2771                        .lock()
2772                        .unwrap_or_else(std::sync::PoisonError::into_inner);
2773                    if let Some(ref mut sess) = *guard {
2774                        sess.mark_running();
2775                    }
2776                }
2777                if let Err(err) = app.emit("recording-status", &view) {
2778                    tracing::trace!(?err, "emit recording-status failed");
2779                }
2780            }
2781            tracing::debug!(session_id, "status-emitter thread exiting");
2782        })
2783        .expect("recording-status-emitter thread spawn must succeed");
2784}
2785
2786// ---- M-EXPORT.4 — file save + reveal IPC ----------------------------
2787
2788/// Resolve the default output path for a recording starting now
2789/// with the given format slug. Returns the absolute path as a
2790/// string (the JS side feeds it back into `start_recording`'s
2791/// `output_path` if the user doesn't override).
2792///
2793/// `format_slug` is one of `"mp4-h264"`, `"mp4-h265"`, `"webm-vp9"`,
2794/// `"webm-av1"`. Unknown slugs fall back to the default
2795/// (`mp4-h264`).
2796///
2797/// As of M-SAVE.0 the directory comes from
2798/// [`recorder_settings::resolved_output_dir`](crate::recorder_settings::resolved_output_dir)
2799/// — the user's persisted choice if set, else the per-OS default —
2800/// so the chosen folder is honored everywhere this path is computed.
2801#[tauri::command]
2802#[must_use]
2803pub fn default_recording_output_path(app: tauri::AppHandle, format_slug: Option<String>) -> String {
2804    use media::encode::OutputFormat;
2805    let format = format_slug
2806        .as_deref()
2807        .and_then(OutputFormat::from_slug)
2808        .unwrap_or_default();
2809    let now_secs = std::time::SystemTime::now()
2810        .duration_since(std::time::UNIX_EPOCH)
2811        .map_or(0, |d| d.as_secs());
2812    let dir = crate::recorder_settings::resolved_output_dir(&app);
2813    dir.join(crate::recording_paths::default_filename(now_secs, format))
2814        .to_string_lossy()
2815        .into_owned()
2816}
2817
2818// ---- M-SAVE.0 — output-directory picker + persistence --------------
2819
2820/// Open a native folder picker and return the chosen absolute path,
2821/// or `None` if the user cancelled. Does **not** persist the choice —
2822/// the caller ([`set_output_dir`]) does. Opens at the current
2823/// configured directory when it exists.
2824///
2825/// Runs on the blocking thread pool via `spawn_blocking`: the
2826/// dialog-plugin `blocking_*` variants block the calling thread until
2827/// the user responds, and deadlock if that's the main thread.
2828///
2829/// # Errors
2830///
2831/// Errors only if the dialog task fails to join; a user cancel is
2832/// `Ok(None)`.
2833#[tauri::command]
2834pub async fn pick_output_dir(app: tauri::AppHandle) -> Result<Option<String>, String> {
2835    use tauri_plugin_dialog::DialogExt;
2836    let initial = crate::recorder_settings::resolved_output_dir(&app);
2837    let chosen = tauri::async_runtime::spawn_blocking(move || {
2838        let builder = app.dialog().file();
2839        let builder = if initial.is_dir() {
2840            builder.set_directory(&initial)
2841        } else {
2842            builder
2843        };
2844        builder.blocking_pick_folder()
2845    })
2846    .await
2847    .map_err(|err| format!("folder-picker task failed: {err}"))?;
2848    Ok(chosen
2849        .and_then(|fp| fp.into_path().ok())
2850        .map(|p| p.to_string_lossy().into_owned()))
2851}
2852
2853/// Return the currently-configured output directory — the persisted
2854/// override if the user set one, otherwise the per-OS default. Always
2855/// returns an absolute path string (never empty).
2856#[tauri::command]
2857#[must_use]
2858pub fn get_output_dir(app: tauri::AppHandle) -> String {
2859    crate::recorder_settings::resolved_output_dir(&app)
2860        .to_string_lossy()
2861        .into_owned()
2862}
2863
2864/// Persist `dir` as the default output directory for future
2865/// recordings. An empty / whitespace-only string clears the override,
2866/// reverting to the per-OS default.
2867///
2868/// # Errors
2869///
2870/// Returns an error string when the settings file can't be written
2871/// (app-config dir unavailable, read-only filesystem, …).
2872#[tauri::command]
2873pub fn set_output_dir(app: tauri::AppHandle, dir: String) -> Result<(), String> {
2874    let mut settings = crate::recorder_settings::load(&app);
2875    settings.output_dir = if dir.trim().is_empty() {
2876        None
2877    } else {
2878        Some(std::path::PathBuf::from(dir))
2879    };
2880    crate::recorder_settings::save(&app, &settings)
2881}
2882
2883/// Return the latest BGRA frame from the camera capture slot
2884/// (M-PIX.8). Used by `<CameraPreview />`'s 15fps poll to paint
2885/// the live webcam into the canvas. Returns raw bytes via
2886/// `tauri::ipc::Response` so the JS side receives an `ArrayBuffer`
2887/// directly (no JSON-array serialization overhead).
2888///
2889/// Empty `Response` when no frame is available yet — the JS side
2890/// skips painting on this tick.
2891///
2892/// Reads the same `CameraFrameSlot` the encoder feed thread reads
2893/// from. The capture worker writes latest-frame-wins, so both
2894/// consumers see the most recent frame; neither blocks the other
2895/// (the preview's `take()` clears the slot, but the next capture
2896/// tick re-fills within ~33 ms at 30 fps).
2897#[tauri::command]
2898#[must_use]
2899pub fn latest_camera_frame_bgra(
2900    recording_state: State<'_, RecordingState>,
2901) -> tauri::ipc::Response {
2902    let bytes = recording_state
2903        .camera_frame_slot
2904        .lock()
2905        .unwrap_or_else(std::sync::PoisonError::into_inner)
2906        .clone()
2907        .unwrap_or_default();
2908    tauri::ipc::Response::new(bytes)
2909}
2910
2911/// Open the OS file manager focused on the given recording file
2912/// (M-EXPORT.4). macOS: `open -R`. Windows:
2913/// `explorer /select,`. Linux: `xdg-open <parent-dir>` (no portable
2914/// "select" verb).
2915///
2916/// # Errors
2917///
2918/// Returns the spawn error as a string when the file-manager binary
2919/// isn't on PATH.
2920#[tauri::command]
2921pub fn reveal_recording_in_file_manager(path: String) -> Result<(), String> {
2922    let p = std::path::PathBuf::from(&path);
2923    crate::recording_paths::reveal_in_file_manager(&p)
2924}
2925
2926/// Test-only entry point for `WebDriver` e2e suites. Emits a
2927/// `file-dropped` event with the same shape as the real OS drag-drop
2928/// handler in `main.rs`. Gated on `debug_assertions` so it's stripped
2929/// from release builds; `main.rs` likewise registers it conditionally
2930/// in `generate_handler!`.
2931///
2932/// Why this exists: `WebDriver` clients can't synthesize OS-level
2933/// drag-drop events. Without this command, the e2e tests would have
2934/// to use platform-specific tools (`xdotool` on Linux, etc.) which are
2935/// fragile and don't help the rest of the test suite.
2936///
2937/// # Errors
2938///
2939/// Returns the underlying [`tauri::Error`] message string if the event
2940/// emit fails (no listeners is not an error — `Emitter::emit` returns
2941/// `Ok(())` regardless).
2942#[cfg(debug_assertions)]
2943#[tauri::command]
2944pub fn __test_drop_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
2945    use tauri::Emitter;
2946    app.emit("file-dropped", path).map_err(|e| e.to_string())
2947}
2948
2949/// Test-only entry point: synthesize a `DragDropEvent::Enter` for the
2950/// `WebDriver` e2e suite. Emits the same `file-drag-enter` event as the
2951/// real OS drag-enter handler. Debug-only, parallel to [`__test_drop_file`].
2952///
2953/// # Errors
2954///
2955/// Returns the underlying [`tauri::Error`] message if the event emit
2956/// fails.
2957#[cfg(debug_assertions)]
2958#[tauri::command]
2959pub fn __test_drag_enter(app: tauri::AppHandle) -> Result<(), String> {
2960    use tauri::Emitter;
2961    app.emit("file-drag-enter", ()).map_err(|e| e.to_string())
2962}
2963
2964/// Test-only entry point: synthesize a `DragDropEvent::Leave`.
2965/// Pair with [`__test_drag_enter`].
2966///
2967/// # Errors
2968///
2969/// Returns the underlying [`tauri::Error`] message if the event emit
2970/// fails.
2971#[cfg(debug_assertions)]
2972#[tauri::command]
2973pub fn __test_drag_leave(app: tauri::AppHandle) -> Result<(), String> {
2974    use tauri::Emitter;
2975    app.emit("file-drag-leave", ()).map_err(|e| e.to_string())
2976}
2977
2978#[cfg(test)]
2979mod tests {
2980    use super::*;
2981
2982    fn mon(x: i32, y: i32, w: i32, h: i32) -> MonitorBounds {
2983        MonitorBounds {
2984            x,
2985            y,
2986            width: w,
2987            height: h,
2988        }
2989    }
2990
2991    #[test]
2992    fn anchor_returns_none_when_no_monitors() {
2993        assert_eq!(compute_popover_anchor(500, 12, 800, &[]), None);
2994    }
2995
2996    #[test]
2997    fn anchor_lands_at_monitor_top_right_regardless_of_click() {
2998        let monitors = vec![mon(0, 0, 1920, 1080)];
2999        // The click position influences monitor selection only; the
3000        // popover always lands flush with the monitor's top-right.
3001        // Click at the far right of the menubar:
3002        let (x, y) = compute_popover_anchor(1820, 12, 800, &monitors).expect("Some(_)");
3003        assert_eq!((x, y), (1120, 0));
3004        // Click near the left edge of the menubar — same anchor.
3005        let (x, y) = compute_popover_anchor(50, 12, 800, &monitors).expect("Some(_)");
3006        assert_eq!((x, y), (1120, 0));
3007    }
3008
3009    #[test]
3010    fn anchor_picks_secondary_monitor_for_a_click_on_it() {
3011        // Two side-by-side 1920×1080 monitors. A click at x=3820 lives
3012        // in the second monitor; the popover anchors top-right of it.
3013        let monitors = vec![mon(0, 0, 1920, 1080), mon(1920, 0, 1920, 1080)];
3014        let (x, y) = compute_popover_anchor(3820, 12, 800, &monitors).expect("Some(_)");
3015        // Monitor 2 right edge = 1920 + 1920 = 3840; x = 3840 - 800 = 3040.
3016        assert_eq!((x, y), (3040, 0));
3017    }
3018
3019    // ── M-BUBBLE.3 / AUT-276 — position persistence file format ──
3020
3021    #[test]
3022    fn encode_position_uses_canonical_format() {
3023        let s = encode_position(BubblePosition { x: 100, y: 200 });
3024        assert_eq!(s, "v2:100,200\n");
3025    }
3026
3027    #[test]
3028    fn encode_position_handles_negative_coords() {
3029        // Multi-monitor layouts often have negative coords (secondary
3030        // monitor to the left of the primary).
3031        let s = encode_position(BubblePosition { x: -500, y: 0 });
3032        assert_eq!(s, "v2:-500,0\n");
3033    }
3034
3035    #[test]
3036    fn decode_position_round_trips_encoded_value() {
3037        let pos = BubblePosition { x: 1234, y: -56 };
3038        let encoded = encode_position(pos);
3039        assert_eq!(decode_position(&encoded), Some(pos));
3040    }
3041
3042    #[test]
3043    fn decode_position_tolerates_missing_trailing_newline() {
3044        assert_eq!(
3045            decode_position("v2:42,99"),
3046            Some(BubblePosition { x: 42, y: 99 })
3047        );
3048    }
3049
3050    #[test]
3051    fn decode_position_tolerates_whitespace_around_values() {
3052        assert_eq!(
3053            decode_position("v2: 7 , 11 \n"),
3054            Some(BubblePosition { x: 7, y: 11 })
3055        );
3056    }
3057
3058    #[test]
3059    fn decode_position_rejects_missing_comma() {
3060        assert_eq!(decode_position("v2:123 456"), None);
3061    }
3062
3063    #[test]
3064    fn decode_position_rejects_non_integer() {
3065        assert_eq!(decode_position("v2:3.14,2.71"), None);
3066        assert_eq!(decode_position("v2:abc,def"), None);
3067        assert_eq!(decode_position("v2:,100"), None);
3068    }
3069
3070    #[test]
3071    fn decode_position_rejects_empty_input() {
3072        assert_eq!(decode_position(""), None);
3073        assert_eq!(decode_position("   \n"), None);
3074    }
3075
3076    #[test]
3077    fn decode_position_rejects_v1_legacy_format() {
3078        // Pre-design-pass file format was bare `x,y\n`. Bumping the
3079        // prefix to `v2:` lets us migrate users off the old default
3080        // bottom-right corner without writing a per-user one-shot
3081        // migration: stale files just fail to parse and fall through
3082        // to `compute_default_position`.
3083        assert_eq!(decode_position("100,200\n"), None);
3084        assert_eq!(decode_position("-500,0"), None);
3085    }
3086
3087    #[test]
3088    fn bubble_state_update_position_round_trips() {
3089        let state = BubbleState::default();
3090        assert_eq!(state.last_position(), None);
3091        state.set_last_position(BubblePosition { x: 50, y: 60 });
3092        assert_eq!(state.last_position(), Some(BubblePosition { x: 50, y: 60 }));
3093        // update_bubble_position_from_event is the public hook the
3094        // WindowEvent::Moved handler uses.
3095        update_bubble_position_from_event(&state, 99, -7);
3096        assert_eq!(state.last_position(), Some(BubblePosition { x: 99, y: -7 }));
3097    }
3098}