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 /// A console log/warn/error message captured from the webview.
144 Console {
145 /// Severity level: "log", "warn", or "error".
146 level: String,
147 /// The log message text.
148 message: String,
149 /// When the message was captured.
150 timestamp: DateTime<Utc>,
151 },
152}
153
154impl AppEvent {
155 /// Returns the timestamp of this event, regardless of variant.
156 #[must_use]
157 pub fn timestamp(&self) -> DateTime<Utc> {
158 match self {
159 Self::Ipc(call) => call.timestamp,
160 Self::StateChange { timestamp, .. }
161 | Self::DomMutation { timestamp, .. }
162 | Self::DomInteraction { timestamp, .. }
163 | Self::WindowEvent { timestamp, .. }
164 | Self::Console { timestamp, .. } => *timestamp,
165 }
166 }
167
168 /// Returns true if this event was generated by Victauri's own infrastructure
169 /// rather than the application under observation.
170 #[must_use]
171 pub fn is_internal(&self) -> bool {
172 match self {
173 Self::Ipc(call) => call.command.starts_with("plugin:victauri|"),
174 _ => false,
175 }
176 }
177}
178
179/// Thread-safe ring-buffer event log. Automatically evicts the oldest events
180/// when capacity is reached. All operations recover from mutex poisoning.
181#[derive(Debug, Clone)]
182pub struct EventLog {
183 events: Arc<Mutex<VecDeque<AppEvent>>>,
184 max_capacity: usize,
185}
186
187impl EventLog {
188 /// Creates a new event log with the given maximum capacity.
189 ///
190 /// ```
191 /// use victauri_core::EventLog;
192 ///
193 /// let log = EventLog::new(100);
194 /// assert!(log.is_empty());
195 /// assert_eq!(log.capacity(), 100);
196 /// ```
197 #[must_use]
198 pub fn new(max_capacity: usize) -> Self {
199 Self {
200 events: Arc::new(Mutex::new(VecDeque::with_capacity(max_capacity))),
201 max_capacity,
202 }
203 }
204
205 /// Returns the maximum number of events this log can hold.
206 #[must_use]
207 pub fn capacity(&self) -> usize {
208 self.max_capacity
209 }
210
211 /// Appends an event, evicting the oldest if at capacity.
212 ///
213 /// # Examples
214 ///
215 /// ```
216 /// use victauri_core::{EventLog, AppEvent};
217 /// use chrono::Utc;
218 ///
219 /// let log = EventLog::new(100);
220 /// log.push(AppEvent::StateChange {
221 /// key: "theme".to_string(),
222 /// timestamp: Utc::now(),
223 /// caused_by: None,
224 /// });
225 /// assert_eq!(log.len(), 1);
226 /// assert_eq!(log.snapshot().len(), 1);
227 /// ```
228 pub fn push(&self, event: AppEvent) {
229 let mut events = crate::acquire_lock(&self.events, "EventLog");
230 if events.len() >= self.max_capacity {
231 events.pop_front();
232 }
233 events.push_back(event);
234 }
235
236 /// Returns a clone of all events currently in the log.
237 #[must_use]
238 pub fn snapshot(&self) -> Vec<AppEvent> {
239 crate::acquire_lock(&self.events, "EventLog")
240 .iter()
241 .cloned()
242 .collect()
243 }
244
245 /// Returns a paginated slice of events starting at `offset`, up to `limit` items.
246 #[must_use]
247 pub fn snapshot_range(&self, offset: usize, limit: usize) -> Vec<AppEvent> {
248 crate::acquire_lock(&self.events, "EventLog")
249 .iter()
250 .skip(offset)
251 .take(limit)
252 .cloned()
253 .collect()
254 }
255
256 /// Returns all events with a timestamp at or after the given time.
257 #[must_use]
258 pub fn since(&self, timestamp: DateTime<Utc>) -> Vec<AppEvent> {
259 crate::acquire_lock(&self.events, "EventLog")
260 .iter()
261 .filter(|e| e.timestamp() >= timestamp)
262 .cloned()
263 .collect()
264 }
265
266 /// Returns all IPC call events, filtering out non-IPC events.
267 ///
268 /// # Examples
269 ///
270 /// ```
271 /// use victauri_core::{EventLog, AppEvent, IpcCall, IpcResult};
272 /// use chrono::Utc;
273 ///
274 /// let log = EventLog::new(100);
275 /// log.push(AppEvent::Ipc(IpcCall {
276 /// id: "c1".to_string(),
277 /// command: "greet".to_string(),
278 /// timestamp: Utc::now(),
279 /// duration_ms: Some(5),
280 /// result: IpcResult::Ok(serde_json::json!("hi")),
281 /// arg_size_bytes: 0,
282 /// webview_label: "main".to_string(),
283 /// }));
284 /// assert_eq!(log.ipc_calls().len(), 1);
285 /// ```
286 #[must_use]
287 pub fn ipc_calls(&self) -> Vec<IpcCall> {
288 crate::acquire_lock(&self.events, "EventLog")
289 .iter()
290 .filter_map(|e| match e {
291 AppEvent::Ipc(call) => Some(call.clone()),
292 _ => None,
293 })
294 .collect()
295 }
296
297 /// Returns IPC calls with a timestamp at or after the given time.
298 #[must_use]
299 pub fn ipc_calls_since(&self, timestamp: DateTime<Utc>) -> Vec<IpcCall> {
300 crate::acquire_lock(&self.events, "EventLog")
301 .iter()
302 .filter_map(|e| match e {
303 AppEvent::Ipc(call) if call.timestamp >= timestamp => Some(call.clone()),
304 _ => None,
305 })
306 .collect()
307 }
308
309 /// Returns the number of events currently in the log.
310 #[must_use]
311 pub fn len(&self) -> usize {
312 crate::acquire_lock(&self.events, "EventLog").len()
313 }
314
315 /// Returns true if the log contains no events.
316 #[must_use]
317 pub fn is_empty(&self) -> bool {
318 crate::acquire_lock(&self.events, "EventLog").is_empty()
319 }
320
321 /// Removes all events from the log.
322 pub fn clear(&self) {
323 crate::acquire_lock(&self.events, "EventLog").clear();
324 }
325}