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}