Skip to main content

screen_app/preview/
mod.rs

1//! Camera preview lifecycle (M-CAM.2 / AUT-256) + worker (M-CAM.3 / AUT-257).
2//!
3//! Owns the [`PreviewLifecycle`] state machine that Tauri-managed
4//! state holds + the [`pipeline::CameraPipeline`] worker that runs
5//! the gst capture subprocess on a dedicated thread. M-CAM.2 landed
6//! the state machine; M-CAM.3 layers the gst worker on top, with
7//! the wisp render + Tauri `Channel<T>` frame emission shipping in
8//! follow-up commits (see `pipeline.rs`'s "What this commit ships"
9//! callout for the explicit boundary).
10//!
11//! The state machine is pure Rust (no `tauri::*` types, no async, no
12//! I/O) so the four-state transition contract works on every OS
13//! including Windows, where Tauri 2's `mock_builder` won't even link
14//! at test-time (per CLAUDE.md).
15
16pub mod diagnostics;
17pub mod pipeline;
18
19pub use diagnostics::{DiagnosticsSnapshot, PreviewDiagnostics};
20pub use pipeline::{CameraPipeline, CameraPipelineHandle};
21
22use std::sync::Mutex;
23
24use serde::{Deserialize, Serialize};
25
26/// Lifecycle state of the camera preview pipeline.
27///
28/// `Idle` is the resting state. `Starting`/`Stopping` are transient
29/// states that exist so a re-entrant `start_preview` call can detect
30/// "already booting, drop the new one" instead of double-spawning gst
31/// child processes. `Running` is the steady state.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub enum PreviewLifecycle {
34    /// No pipeline running.
35    #[default]
36    Idle,
37    /// `start_preview` invoked but the pipeline isn't producing
38    /// frames yet (gst spawn + first-frame latency).
39    Starting,
40    /// Pipeline is producing frames.
41    Running,
42    /// `stop_preview` invoked but the gst child is still being
43    /// torn down. Transient — the click handler should drop into
44    /// `Idle` once the child has been reaped.
45    Stopping,
46}
47
48/// Error variants the IPC command surface can return to Leptos.
49///
50/// `PermissionPending` is the macOS first-run case where the OS
51/// shows a prompt and the gst pipeline blocks until the user clicks.
52/// `PermissionDenied` is the post-prompt rejection. The Leptos
53/// `RecorderPreviewState` (M-CAM.3) maps each variant to the right
54/// loading-state copy.
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
56pub enum CameraError {
57    /// macOS permission prompt is showing; the pipeline hasn't
58    /// produced a frame yet but isn't denied either.
59    #[error("camera permission prompt is pending user response")]
60    PermissionPending,
61    /// User has explicitly denied camera access in System Settings.
62    #[error("camera access denied; user must grant in System Settings")]
63    PermissionDenied,
64    /// The selected camera is in use by another app (or, on macOS,
65    /// the missing-Info.plist failure mode that masquerades as this).
66    #[error("camera device is busy or otherwise unavailable")]
67    DeviceBusy,
68    /// gst pipeline spawn / runtime failure.
69    #[error("gst pipeline failed: {0}")]
70    GstFailed(String),
71}
72
73/// Tauri-managed wrapper around [`PreviewLifecycle`]. Held in
74/// `tauri::State` so the IPC command handlers + the future frame
75/// emitter share one source of truth.
76#[derive(Default)]
77pub struct PreviewState(pub Mutex<PreviewLifecycle>);
78
79impl PreviewLifecycle {
80    /// Attempt to advance to `Starting`. Returns the previous state
81    /// (so callers can distinguish "started fresh" from "already
82    /// running, refused").
83    #[must_use]
84    pub fn try_start(self) -> Self {
85        match self {
86            Self::Idle => Self::Starting,
87            // Re-entrant start while in Starting/Running/Stopping is
88            // a no-op at the state level — the caller is expected
89            // to first stop the existing session.
90            other => other,
91        }
92    }
93
94    /// Mark the pipeline as running (first frame received).
95    #[must_use]
96    pub fn mark_running(self) -> Self {
97        match self {
98            Self::Starting => Self::Running,
99            other => other,
100        }
101    }
102
103    /// Begin teardown — moves Running → Stopping, or stays Idle if
104    /// no pipeline is up.
105    #[must_use]
106    pub fn try_stop(self) -> Self {
107        match self {
108            Self::Running | Self::Starting => Self::Stopping,
109            other => other,
110        }
111    }
112
113    /// Complete teardown — moves Stopping → Idle.
114    #[must_use]
115    pub fn finish_stop(self) -> Self {
116        match self {
117            Self::Stopping => Self::Idle,
118            other => other,
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn default_is_idle() {
129        assert_eq!(PreviewLifecycle::default(), PreviewLifecycle::Idle);
130    }
131
132    #[test]
133    fn idle_can_start() {
134        assert_eq!(
135            PreviewLifecycle::Idle.try_start(),
136            PreviewLifecycle::Starting
137        );
138    }
139
140    #[test]
141    fn starting_cannot_start_again() {
142        assert_eq!(
143            PreviewLifecycle::Starting.try_start(),
144            PreviewLifecycle::Starting
145        );
146    }
147
148    #[test]
149    fn running_cannot_start_again() {
150        assert_eq!(
151            PreviewLifecycle::Running.try_start(),
152            PreviewLifecycle::Running
153        );
154    }
155
156    #[test]
157    fn starting_can_mark_running() {
158        assert_eq!(
159            PreviewLifecycle::Starting.mark_running(),
160            PreviewLifecycle::Running
161        );
162    }
163
164    #[test]
165    fn running_can_stop() {
166        assert_eq!(
167            PreviewLifecycle::Running.try_stop(),
168            PreviewLifecycle::Stopping
169        );
170    }
171
172    #[test]
173    fn stopping_finishes_to_idle() {
174        assert_eq!(
175            PreviewLifecycle::Stopping.finish_stop(),
176            PreviewLifecycle::Idle
177        );
178    }
179
180    #[test]
181    fn full_round_trip() {
182        let mut s = PreviewLifecycle::default();
183        s = s.try_start();
184        assert_eq!(s, PreviewLifecycle::Starting);
185        s = s.mark_running();
186        assert_eq!(s, PreviewLifecycle::Running);
187        s = s.try_stop();
188        assert_eq!(s, PreviewLifecycle::Stopping);
189        s = s.finish_stop();
190        assert_eq!(s, PreviewLifecycle::Idle);
191    }
192
193    #[test]
194    fn camera_error_round_trips_serde() {
195        let cases = [
196            CameraError::PermissionPending,
197            CameraError::PermissionDenied,
198            CameraError::DeviceBusy,
199            CameraError::GstFailed("spawn failed".into()),
200        ];
201        for err in cases {
202            let json = serde_json::to_string(&err).unwrap();
203            let back: CameraError = serde_json::from_str(&json).unwrap();
204            assert_eq!(back, err);
205        }
206    }
207}