Skip to main content

victauri_plugin/
screencast.rs

1//! Screencast state for the `trace` tool — a ring buffer of timestamped PNG
2//! frames captured by a background task at a fixed interval. Pairs with the
3//! `EventRecorder` (events) and `logs` (network/console) to form a trace bundle.
4
5use std::sync::Mutex;
6use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
7
8/// A single captured frame: milliseconds since trace start + base64 PNG.
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct TraceFrame {
11    /// Milliseconds since the trace started.
12    pub t_ms: u64,
13    /// Base64-encoded PNG image data.
14    pub data_b64: String,
15}
16
17/// Shared screencast state. Thread-safe; mutex locks are short-lived and
18/// recover from poisoning.
19#[derive(Debug)]
20pub struct Screencast {
21    active: AtomicBool,
22    interval_ms: AtomicU64,
23    max_frames: AtomicUsize,
24    generation: AtomicU64,
25    frames: Mutex<Vec<TraceFrame>>,
26    label: Mutex<Option<String>>,
27}
28
29impl Default for Screencast {
30    fn default() -> Self {
31        Self {
32            active: AtomicBool::new(false),
33            interval_ms: AtomicU64::new(500),
34            max_frames: AtomicUsize::new(60),
35            generation: AtomicU64::new(0),
36            frames: Mutex::new(Vec::new()),
37            label: Mutex::new(None),
38        }
39    }
40}
41
42impl Screencast {
43    /// Begin a new trace: clears frames, records settings, returns the
44    /// generation token the capture task must check to know it is current.
45    pub fn start(&self, interval_ms: u64, max_frames: usize, label: Option<String>) -> u64 {
46        self.interval_ms
47            .store(interval_ms.max(50), Ordering::Relaxed);
48        self.max_frames
49            .store(max_frames.clamp(1, 600), Ordering::Relaxed);
50        {
51            let mut f = self
52                .frames
53                .lock()
54                .unwrap_or_else(std::sync::PoisonError::into_inner);
55            f.clear();
56        }
57        {
58            let mut l = self
59                .label
60                .lock()
61                .unwrap_or_else(std::sync::PoisonError::into_inner);
62            *l = label;
63        }
64        let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
65        self.active.store(true, Ordering::SeqCst);
66        generation
67    }
68
69    /// Stop the current trace. Returns the captured frame count.
70    pub fn stop(&self) -> usize {
71        self.active.store(false, Ordering::SeqCst);
72        // Invalidate any running task.
73        self.generation.fetch_add(1, Ordering::SeqCst);
74        self.frame_count()
75    }
76
77    /// Whether a trace is currently active.
78    #[must_use]
79    pub fn is_active(&self) -> bool {
80        self.active.load(Ordering::SeqCst)
81    }
82
83    /// The current generation token (a capture task is stale if it differs).
84    #[must_use]
85    pub fn generation(&self) -> u64 {
86        self.generation.load(Ordering::SeqCst)
87    }
88
89    /// Configured capture interval in milliseconds.
90    #[must_use]
91    pub fn interval_ms(&self) -> u64 {
92        self.interval_ms.load(Ordering::Relaxed)
93    }
94
95    /// Target webview label for capture, if set.
96    #[must_use]
97    pub fn label(&self) -> Option<String> {
98        self.label
99            .lock()
100            .unwrap_or_else(std::sync::PoisonError::into_inner)
101            .clone()
102    }
103
104    /// Append a frame, enforcing the `max_frames` ring-buffer cap.
105    pub fn push_frame(&self, t_ms: u64, data_b64: String) {
106        let max = self.max_frames.load(Ordering::Relaxed);
107        let mut f = self
108            .frames
109            .lock()
110            .unwrap_or_else(std::sync::PoisonError::into_inner);
111        f.push(TraceFrame { t_ms, data_b64 });
112        let len = f.len();
113        if len > max {
114            f.drain(0..len - max);
115        }
116    }
117
118    /// Number of frames currently buffered.
119    #[must_use]
120    pub fn frame_count(&self) -> usize {
121        self.frames
122            .lock()
123            .unwrap_or_else(std::sync::PoisonError::into_inner)
124            .len()
125    }
126
127    /// Return up to `limit` of the most recent frames (or all if `limit` is 0).
128    #[must_use]
129    pub fn frames(&self, limit: usize) -> Vec<TraceFrame> {
130        let f = self
131            .frames
132            .lock()
133            .unwrap_or_else(std::sync::PoisonError::into_inner);
134        if limit == 0 || limit >= f.len() {
135            f.clone()
136        } else {
137            f[f.len() - limit..].to_vec()
138        }
139    }
140
141    /// Frame timestamps (ms since start) without the image payloads.
142    #[must_use]
143    pub fn frame_timestamps(&self) -> Vec<u64> {
144        self.frames
145            .lock()
146            .unwrap_or_else(std::sync::PoisonError::into_inner)
147            .iter()
148            .map(|fr| fr.t_ms)
149            .collect()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn ring_buffer_caps_frames() {
159        let sc = Screencast::default();
160        sc.start(100, 3, None);
161        for i in 0..5 {
162            sc.push_frame(i * 100, format!("frame{i}"));
163        }
164        assert_eq!(sc.frame_count(), 3, "should cap at max_frames");
165        let frames = sc.frames(0);
166        // Oldest dropped: keeps frame2, frame3, frame4.
167        assert_eq!(frames[0].data_b64, "frame2");
168        assert_eq!(frames[2].data_b64, "frame4");
169    }
170
171    #[test]
172    fn start_clears_and_bumps_generation() {
173        let sc = Screencast::default();
174        let g1 = sc.start(200, 10, Some("main".into()));
175        sc.push_frame(0, "x".into());
176        assert_eq!(sc.frame_count(), 1);
177        let g2 = sc.start(200, 10, None);
178        assert!(g2 > g1, "generation must increase");
179        assert_eq!(sc.frame_count(), 0, "start clears frames");
180        assert!(sc.is_active());
181    }
182
183    #[test]
184    fn stop_deactivates_and_invalidates() {
185        let sc = Screencast::default();
186        let g = sc.start(200, 10, None);
187        sc.stop();
188        assert!(!sc.is_active());
189        assert!(sc.generation() > g, "stop invalidates the task generation");
190    }
191
192    #[test]
193    fn frames_limit_returns_most_recent() {
194        let sc = Screencast::default();
195        sc.start(100, 100, None);
196        for i in 0..5 {
197            sc.push_frame(i, format!("f{i}"));
198        }
199        let last2 = sc.frames(2);
200        assert_eq!(last2.len(), 2);
201        assert_eq!(last2[0].data_b64, "f3");
202        assert_eq!(last2[1].data_b64, "f4");
203    }
204}