victauri_core/event.rs
1//! Application event types and a thread-safe ring-buffer event log.
2
3use std::collections::VecDeque;
4use std::fmt;
5use std::sync::{Arc, Mutex};
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10/// A single Tauri IPC call with timing, result, and source webview.
11#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
12pub struct IpcCall {
13 /// Unique call identifier for correlation.
14 pub id: String,
15 /// Name of the Tauri command that was invoked.
16 pub command: String,
17 /// When the call was initiated.
18 pub timestamp: DateTime<Utc>,
19 /// Round-trip duration in milliseconds, if completed.
20 pub duration_ms: Option<u64>,
21 /// Current outcome of the call (pending, ok, or error).
22 pub result: IpcResult,
23 /// Size of the serialized arguments in bytes.
24 pub arg_size_bytes: usize,
25 /// Label of the webview that initiated the call.
26 pub webview_label: String,
27}
28
29/// Outcome of an IPC call: pending, success with a JSON value, or error.
30#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
31#[non_exhaustive]
32pub enum IpcResult {
33 /// Call is still in flight, awaiting a response.
34 Pending,
35 /// Call completed successfully with a JSON return value.
36 Ok(serde_json::Value),
37 /// Call failed with an error message.
38 Err(String),
39}
40
41impl fmt::Display for IpcResult {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 match self {
44 Self::Pending => f.write_str("pending"),
45 Self::Ok(_) => f.write_str("ok"),
46 Self::Err(msg) => write!(f, "error: {msg}"),
47 }
48 }
49}
50
51impl fmt::Display for IpcCall {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 write!(f, "{} [{}] \u{2192} {}", self.command, self.id, self.result)
54 }
55}
56
57impl From<IpcCall> for AppEvent {
58 fn from(call: IpcCall) -> Self {
59 Self::Ipc(call)
60 }
61}
62
63/// The kind of user interaction captured from the DOM.
64#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
65pub enum InteractionKind {
66 /// Mouse click on an element.
67 Click,
68 /// Double-click on an element.
69 DoubleClick,
70 /// Text typed into an input field.
71 Fill,
72 /// Individual key press event.
73 KeyPress,
74 /// Option selected from a dropdown.
75 Select,
76 /// Page navigation (URL change).
77 Navigate,
78 /// Scroll to element or position.
79 Scroll,
80}
81
82impl fmt::Display for InteractionKind {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::Click => f.write_str("click"),
86 Self::DoubleClick => f.write_str("double_click"),
87 Self::Fill => f.write_str("fill"),
88 Self::KeyPress => f.write_str("key_press"),
89 Self::Select => f.write_str("select"),
90 Self::Navigate => f.write_str("navigate"),
91 Self::Scroll => f.write_str("scroll"),
92 }
93 }
94}
95
96/// Application event captured by the introspection layer.
97#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
98#[serde(tag = "type")]
99#[non_exhaustive]
100pub enum AppEvent {
101 /// An IPC call between webview and Rust backend.
102 Ipc(IpcCall),
103 /// A change to application state in the backend.
104 StateChange {
105 /// State key that changed.
106 key: String,
107 /// When the change occurred.
108 timestamp: DateTime<Utc>,
109 /// Command or action that triggered the change, if known.
110 caused_by: Option<String>,
111 },
112 /// A batch of DOM mutations observed in a webview.
113 DomMutation {
114 /// Webview where the mutations were observed.
115 webview_label: String,
116 /// When the mutations were observed.
117 timestamp: DateTime<Utc>,
118 /// Number of individual DOM mutations in this batch.
119 mutation_count: u32,
120 },
121 /// A user interaction captured from the DOM during recording.
122 DomInteraction {
123 /// What kind of interaction occurred.
124 action: InteractionKind,
125 /// Best available selector for the target element (data-testid, id, CSS path).
126 selector: String,
127 /// Value associated with the interaction (typed text, selected option, URL, key name).
128 value: Option<String>,
129 /// When the interaction occurred.
130 timestamp: DateTime<Utc>,
131 /// Label of the webview where the interaction happened.
132 webview_label: String,
133 },
134 /// A native window lifecycle event (e.g. focus, resize, close).
135 WindowEvent {
136 /// Tauri window label that emitted the event.
137 label: String,
138 /// Event name (e.g. "focus", "resize").
139 event: String,
140 /// When the event occurred.
141 timestamp: DateTime<Utc>,
142 },
143}
144
145impl AppEvent {
146 /// Returns the timestamp of this event, regardless of variant.
147 #[must_use]
148 pub fn timestamp(&self) -> DateTime<Utc> {
149 match self {
150 Self::Ipc(call) => call.timestamp,
151 Self::StateChange { timestamp, .. }
152 | Self::DomMutation { timestamp, .. }
153 | Self::DomInteraction { timestamp, .. }
154 | Self::WindowEvent { timestamp, .. } => *timestamp,
155 }
156 }
157}
158
159/// Thread-safe ring-buffer event log. Automatically evicts the oldest events
160/// when capacity is reached. All operations recover from mutex poisoning.
161#[derive(Debug, Clone)]
162pub struct EventLog {
163 events: Arc<Mutex<VecDeque<AppEvent>>>,
164 max_capacity: usize,
165}
166
167impl EventLog {
168 /// Creates a new event log with the given maximum capacity.
169 ///
170 /// ```
171 /// use victauri_core::EventLog;
172 ///
173 /// let log = EventLog::new(100);
174 /// assert!(log.is_empty());
175 /// assert_eq!(log.capacity(), 100);
176 /// ```
177 #[must_use]
178 pub fn new(max_capacity: usize) -> Self {
179 Self {
180 events: Arc::new(Mutex::new(VecDeque::with_capacity(max_capacity))),
181 max_capacity,
182 }
183 }
184
185 /// Returns the maximum number of events this log can hold.
186 #[must_use]
187 pub fn capacity(&self) -> usize {
188 self.max_capacity
189 }
190
191 /// Appends an event, evicting the oldest if at capacity.
192 ///
193 /// # Examples
194 ///
195 /// ```
196 /// use victauri_core::{EventLog, AppEvent};
197 /// use chrono::Utc;
198 ///
199 /// let log = EventLog::new(100);
200 /// log.push(AppEvent::StateChange {
201 /// key: "theme".to_string(),
202 /// timestamp: Utc::now(),
203 /// caused_by: None,
204 /// });
205 /// assert_eq!(log.len(), 1);
206 /// assert_eq!(log.snapshot().len(), 1);
207 /// ```
208 pub fn push(&self, event: AppEvent) {
209 let mut events = self
210 .events
211 .lock()
212 .unwrap_or_else(std::sync::PoisonError::into_inner);
213 if events.len() >= self.max_capacity {
214 events.pop_front();
215 }
216 events.push_back(event);
217 }
218
219 /// Returns a clone of all events currently in the log.
220 #[must_use]
221 pub fn snapshot(&self) -> Vec<AppEvent> {
222 self.events
223 .lock()
224 .unwrap_or_else(std::sync::PoisonError::into_inner)
225 .iter()
226 .cloned()
227 .collect()
228 }
229
230 /// Returns a paginated slice of events starting at `offset`, up to `limit` items.
231 #[must_use]
232 pub fn snapshot_range(&self, offset: usize, limit: usize) -> Vec<AppEvent> {
233 self.events
234 .lock()
235 .unwrap_or_else(std::sync::PoisonError::into_inner)
236 .iter()
237 .skip(offset)
238 .take(limit)
239 .cloned()
240 .collect()
241 }
242
243 /// Returns all events with a timestamp at or after the given time.
244 #[must_use]
245 pub fn since(&self, timestamp: DateTime<Utc>) -> Vec<AppEvent> {
246 self.events
247 .lock()
248 .unwrap_or_else(std::sync::PoisonError::into_inner)
249 .iter()
250 .filter(|e| e.timestamp() >= timestamp)
251 .cloned()
252 .collect()
253 }
254
255 /// Returns all IPC call events, filtering out non-IPC events.
256 ///
257 /// # Examples
258 ///
259 /// ```
260 /// use victauri_core::{EventLog, AppEvent, IpcCall, IpcResult};
261 /// use chrono::Utc;
262 ///
263 /// let log = EventLog::new(100);
264 /// log.push(AppEvent::Ipc(IpcCall {
265 /// id: "c1".to_string(),
266 /// command: "greet".to_string(),
267 /// timestamp: Utc::now(),
268 /// duration_ms: Some(5),
269 /// result: IpcResult::Ok(serde_json::json!("hi")),
270 /// arg_size_bytes: 0,
271 /// webview_label: "main".to_string(),
272 /// }));
273 /// assert_eq!(log.ipc_calls().len(), 1);
274 /// ```
275 #[must_use]
276 pub fn ipc_calls(&self) -> Vec<IpcCall> {
277 self.events
278 .lock()
279 .unwrap_or_else(std::sync::PoisonError::into_inner)
280 .iter()
281 .filter_map(|e| match e {
282 AppEvent::Ipc(call) => Some(call.clone()),
283 _ => None,
284 })
285 .collect()
286 }
287
288 /// Returns IPC calls with a timestamp at or after the given time.
289 #[must_use]
290 pub fn ipc_calls_since(&self, timestamp: DateTime<Utc>) -> Vec<IpcCall> {
291 self.events
292 .lock()
293 .unwrap_or_else(std::sync::PoisonError::into_inner)
294 .iter()
295 .filter_map(|e| match e {
296 AppEvent::Ipc(call) if call.timestamp >= timestamp => Some(call.clone()),
297 _ => None,
298 })
299 .collect()
300 }
301
302 /// Returns the number of events currently in the log.
303 #[must_use]
304 pub fn len(&self) -> usize {
305 self.events
306 .lock()
307 .unwrap_or_else(std::sync::PoisonError::into_inner)
308 .len()
309 }
310
311 /// Returns true if the log contains no events.
312 #[must_use]
313 pub fn is_empty(&self) -> bool {
314 self.events
315 .lock()
316 .unwrap_or_else(std::sync::PoisonError::into_inner)
317 .is_empty()
318 }
319
320 /// Removes all events from the log.
321 pub fn clear(&self) {
322 self.events
323 .lock()
324 .unwrap_or_else(std::sync::PoisonError::into_inner)
325 .clear();
326 }
327}