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}