Skip to main content

victauri_core/
recording.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::VecDeque;
4use std::sync::{Arc, Mutex};
5
6use crate::event::{AppEvent, IpcCall};
7
8/// A snapshot of application state taken at a specific point during recording.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct StateCheckpoint {
11    /// Unique identifier for this checkpoint.
12    pub id: String,
13    /// Optional human-readable label for the checkpoint.
14    pub label: Option<String>,
15    /// When the checkpoint was created.
16    pub timestamp: DateTime<Utc>,
17    /// Serialized application state at the checkpoint.
18    pub state: serde_json::Value,
19    /// Index into the event stream at the time of this checkpoint.
20    pub event_index: usize,
21}
22
23/// A complete recorded session with events and state checkpoints. Serializable for export/import.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RecordedSession {
26    /// Unique session identifier (UUID).
27    pub id: String,
28    /// When the recording session began.
29    pub started_at: DateTime<Utc>,
30    /// All events captured during the session, in order.
31    pub events: Vec<RecordedEvent>,
32    /// State checkpoints created during the session.
33    pub checkpoints: Vec<StateCheckpoint>,
34}
35
36/// A single event captured during a recording session, with its sequence index.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RecordedEvent {
39    /// Monotonically increasing sequence number within the recording session.
40    pub index: usize,
41    /// When the event occurred.
42    pub timestamp: DateTime<Utc>,
43    /// The captured application event.
44    pub event: AppEvent,
45}
46
47/// Thread-safe session recorder for time-travel debugging. Records events and
48/// state checkpoints during a recording session. Only one session can be active at a time.
49#[derive(Debug, Clone)]
50pub struct EventRecorder {
51    recording: Arc<Mutex<Option<ActiveRecording>>>,
52    max_events: usize,
53}
54
55#[derive(Debug, Clone)]
56struct ActiveRecording {
57    session_id: String,
58    started_at: DateTime<Utc>,
59    events: VecDeque<RecordedEvent>,
60    checkpoints: VecDeque<StateCheckpoint>,
61    event_counter: usize,
62    max_events: usize,
63    max_checkpoints: usize,
64}
65
66impl EventRecorder {
67    /// Creates a new recorder with the given maximum event capacity.
68    ///
69    /// ```
70    /// use victauri_core::EventRecorder;
71    ///
72    /// let recorder = EventRecorder::new(1000);
73    /// assert!(!recorder.is_recording());
74    /// assert_eq!(recorder.event_count(), 0);
75    /// ```
76    pub fn new(max_events: usize) -> Self {
77        Self {
78            recording: Arc::new(Mutex::new(None)),
79            max_events,
80        }
81    }
82
83    /// Starts a new recording session; returns false if one is already active.
84    pub fn start(&self, session_id: String) -> bool {
85        let mut rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
86        if rec.is_some() {
87            return false;
88        }
89        *rec = Some(ActiveRecording {
90            session_id,
91            started_at: Utc::now(),
92            events: VecDeque::new(),
93            checkpoints: VecDeque::new(),
94            event_counter: 0,
95            max_events: self.max_events,
96            max_checkpoints: 1000,
97        });
98        true
99    }
100
101    /// Stops the active recording and returns the completed session, or None if not recording.
102    pub fn stop(&self) -> Option<RecordedSession> {
103        let mut rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
104        rec.take().map(|r| RecordedSession {
105            id: r.session_id,
106            started_at: r.started_at,
107            events: r.events.into_iter().collect(),
108            checkpoints: r.checkpoints.into_iter().collect(),
109        })
110    }
111
112    /// Returns true if a recording session is currently active.
113    pub fn is_recording(&self) -> bool {
114        self.recording
115            .lock()
116            .unwrap_or_else(|e| e.into_inner())
117            .is_some()
118    }
119
120    /// Appends an event to the active recording, evicting the oldest if at capacity.
121    pub fn record_event(&self, event: AppEvent) {
122        let mut rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
123        if let Some(ref mut active) = *rec {
124            let timestamp = extract_timestamp(&event);
125            let index = active.event_counter;
126            active.event_counter += 1;
127
128            if active.events.len() >= active.max_events {
129                active.events.pop_front();
130            }
131
132            active.events.push_back(RecordedEvent {
133                index,
134                timestamp,
135                event,
136            });
137        }
138    }
139
140    /// Creates a named state checkpoint at the current event index; returns false if not recording.
141    pub fn checkpoint(&self, id: String, label: Option<String>, state: serde_json::Value) -> bool {
142        let mut rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
143        if let Some(ref mut active) = *rec {
144            let event_index = active.event_counter;
145            if active.checkpoints.len() >= active.max_checkpoints {
146                active.checkpoints.pop_front();
147            }
148            active.checkpoints.push_back(StateCheckpoint {
149                id,
150                label,
151                timestamp: Utc::now(),
152                state,
153                event_index,
154            });
155            true
156        } else {
157            false
158        }
159    }
160
161    /// Returns the number of events recorded so far, or 0 if not recording.
162    pub fn event_count(&self) -> usize {
163        self.recording
164            .lock()
165            .unwrap_or_else(|e| e.into_inner())
166            .as_ref()
167            .map(|r| r.events.len())
168            .unwrap_or(0)
169    }
170
171    /// Returns the number of checkpoints created so far, or 0 if not recording.
172    pub fn checkpoint_count(&self) -> usize {
173        self.recording
174            .lock()
175            .unwrap_or_else(|e| e.into_inner())
176            .as_ref()
177            .map(|r| r.checkpoints.len())
178            .unwrap_or(0)
179    }
180
181    /// Returns all events with an index >= the given value.
182    pub fn events_since(&self, index: usize) -> Vec<RecordedEvent> {
183        let rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
184        match rec.as_ref() {
185            Some(active) => active
186                .events
187                .iter()
188                .filter(|e| e.index >= index)
189                .cloned()
190                .collect(),
191            None => Vec::new(),
192        }
193    }
194
195    /// Returns events whose timestamps fall within the given inclusive range.
196    pub fn events_between(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> Vec<RecordedEvent> {
197        let rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
198        match rec.as_ref() {
199            Some(active) => active
200                .events
201                .iter()
202                .filter(|e| e.timestamp >= from && e.timestamp <= to)
203                .cloned()
204                .collect(),
205            None => Vec::new(),
206        }
207    }
208
209    /// Returns all checkpoints from the active recording session.
210    pub fn get_checkpoints(&self) -> Vec<StateCheckpoint> {
211        let rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
212        match rec.as_ref() {
213            Some(active) => active.checkpoints.iter().cloned().collect(),
214            None => Vec::new(),
215        }
216    }
217
218    /// Returns events recorded between two named checkpoints, or None if either ID is unknown.
219    pub fn events_between_checkpoints(
220        &self,
221        from_checkpoint_id: &str,
222        to_checkpoint_id: &str,
223    ) -> Option<Vec<RecordedEvent>> {
224        let rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
225        let active = rec.as_ref()?;
226
227        let from_idx = active
228            .checkpoints
229            .iter()
230            .find(|c| c.id == from_checkpoint_id)?
231            .event_index;
232        let to_idx = active
233            .checkpoints
234            .iter()
235            .find(|c| c.id == to_checkpoint_id)?
236            .event_index;
237
238        let (start, end) = if from_idx <= to_idx {
239            (from_idx, to_idx)
240        } else {
241            (to_idx, from_idx)
242        };
243
244        Some(
245            active
246                .events
247                .iter()
248                .filter(|e| e.index >= start && e.index < end)
249                .cloned()
250                .collect(),
251        )
252    }
253
254    /// Extracts IPC calls in order from the recording for replay.
255    pub fn ipc_replay_sequence(&self) -> Vec<IpcCall> {
256        let rec = self.recording.lock().unwrap_or_else(|e| e.into_inner());
257        match rec.as_ref() {
258            Some(active) => active
259                .events
260                .iter()
261                .filter_map(|re| match &re.event {
262                    AppEvent::Ipc(call) => Some(call.clone()),
263                    _ => None,
264                })
265                .collect(),
266            None => Vec::new(),
267        }
268    }
269}
270
271impl Default for EventRecorder {
272    fn default() -> Self {
273        Self::new(50_000)
274    }
275}
276
277fn extract_timestamp(event: &AppEvent) -> DateTime<Utc> {
278    match event {
279        AppEvent::Ipc(call) => call.timestamp,
280        AppEvent::StateChange { timestamp, .. } => *timestamp,
281        AppEvent::DomMutation { timestamp, .. } => *timestamp,
282        AppEvent::WindowEvent { timestamp, .. } => *timestamp,
283    }
284}