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)]
12#[non_exhaustive]
13pub struct IpcCall {
14 /// Unique call identifier for correlation.
15 pub id: String,
16 /// Name of the Tauri command that was invoked.
17 pub command: String,
18 /// When the call was initiated.
19 pub timestamp: DateTime<Utc>,
20 /// Round-trip duration in milliseconds, if completed.
21 pub duration_ms: Option<u64>,
22 /// Current outcome of the call (pending, ok, or error).
23 pub result: IpcResult,
24 /// Size of the serialized arguments in bytes.
25 pub arg_size_bytes: usize,
26 /// Label of the webview that initiated the call.
27 pub webview_label: String,
28 /// The call never reached the backend: a `route` rule fulfilled or blocked it in the
29 /// webview. `recording replay` skips such calls — replaying a mocked call would turn a
30 /// fake success into a real invocation.
31 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
32 pub mocked: bool,
33}
34
35/// Outcome of an IPC call: pending, success with a JSON value, or error.
36#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
37#[non_exhaustive]
38pub enum IpcResult {
39 /// Call is still in flight, awaiting a response.
40 Pending,
41 /// Call completed successfully with a JSON return value.
42 Ok(serde_json::Value),
43 /// Call failed with an error message.
44 Err(String),
45}
46
47impl IpcCall {
48 /// Creates an IPC call record.
49 ///
50 /// `IpcCall` is `#[non_exhaustive]`, so code outside this crate (bridges,
51 /// mocks, tests) builds it through this constructor rather than a struct
52 /// literal; fields added later get a default here instead of breaking callers.
53 ///
54 /// # Examples
55 ///
56 /// ```
57 /// use victauri_core::{IpcCall, IpcResult};
58 ///
59 /// let call = IpcCall::new(
60 /// "c1",
61 /// "greet",
62 /// chrono::Utc::now(),
63 /// IpcResult::Pending,
64 /// None,
65 /// 0,
66 /// "main",
67 /// );
68 /// assert_eq!(call.command, "greet");
69 /// ```
70 #[must_use]
71 pub fn new(
72 id: impl Into<String>,
73 command: impl Into<String>,
74 timestamp: DateTime<Utc>,
75 result: IpcResult,
76 duration_ms: Option<u64>,
77 arg_size_bytes: usize,
78 webview_label: impl Into<String>,
79 ) -> Self {
80 Self {
81 id: id.into(),
82 command: command.into(),
83 timestamp,
84 duration_ms,
85 result,
86 arg_size_bytes,
87 webview_label: webview_label.into(),
88 mocked: false,
89 }
90 }
91
92 /// Mark the call as answered by a route rule instead of the backend.
93 #[must_use]
94 pub fn with_mocked(mut self, mocked: bool) -> Self {
95 self.mocked = mocked;
96 self
97 }
98}
99
100impl fmt::Display for IpcResult {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 match self {
103 Self::Pending => f.write_str("pending"),
104 Self::Ok(_) => f.write_str("ok"),
105 Self::Err(msg) => write!(f, "error: {msg}"),
106 }
107 }
108}
109
110impl fmt::Display for IpcCall {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 write!(f, "{} [{}] \u{2192} {}", self.command, self.id, self.result)
113 }
114}
115
116impl From<IpcCall> for AppEvent {
117 fn from(call: IpcCall) -> Self {
118 Self::Ipc(call)
119 }
120}
121
122/// The kind of user interaction captured from the DOM.
123#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
124#[non_exhaustive]
125pub enum InteractionKind {
126 /// Mouse click on an element.
127 Click,
128 /// Double-click on an element.
129 DoubleClick,
130 /// Text typed into an input field.
131 Fill,
132 /// Individual key press event.
133 KeyPress,
134 /// Option selected from a dropdown.
135 Select,
136 /// Page navigation (URL change).
137 Navigate,
138 /// Scroll to element or position.
139 Scroll,
140}
141
142impl fmt::Display for InteractionKind {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 Self::Click => f.write_str("click"),
146 Self::DoubleClick => f.write_str("double_click"),
147 Self::Fill => f.write_str("fill"),
148 Self::KeyPress => f.write_str("key_press"),
149 Self::Select => f.write_str("select"),
150 Self::Navigate => f.write_str("navigate"),
151 Self::Scroll => f.write_str("scroll"),
152 }
153 }
154}
155
156/// Application event captured by the introspection layer.
157#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
158#[serde(tag = "type")]
159#[non_exhaustive]
160pub enum AppEvent {
161 /// An IPC call between webview and Rust backend.
162 Ipc(IpcCall),
163 /// A change to application state in the backend.
164 #[non_exhaustive]
165 StateChange {
166 /// State key that changed.
167 key: String,
168 /// When the change occurred.
169 timestamp: DateTime<Utc>,
170 /// Command or action that triggered the change, if known.
171 caused_by: Option<String>,
172 },
173 /// A batch of DOM mutations observed in a webview.
174 #[non_exhaustive]
175 DomMutation {
176 /// Webview where the mutations were observed.
177 webview_label: String,
178 /// When the mutations were observed.
179 timestamp: DateTime<Utc>,
180 /// Number of individual DOM mutations in this batch.
181 mutation_count: u32,
182 },
183 /// A user interaction captured from the DOM during recording.
184 #[non_exhaustive]
185 DomInteraction {
186 /// What kind of interaction occurred.
187 action: InteractionKind,
188 /// Best available selector for the target element (data-testid, id, CSS path).
189 selector: String,
190 /// Value associated with the interaction (typed text, selected option, URL, key name).
191 value: Option<String>,
192 /// When the interaction occurred.
193 timestamp: DateTime<Utc>,
194 /// Label of the webview where the interaction happened.
195 webview_label: String,
196 },
197 /// A native window lifecycle event (e.g. focus, resize, close).
198 #[non_exhaustive]
199 WindowEvent {
200 /// Tauri window label that emitted the event.
201 label: String,
202 /// Event name (e.g. "focus", "resize").
203 event: String,
204 /// When the event occurred.
205 timestamp: DateTime<Utc>,
206 },
207 /// A console log/warn/error message captured from the webview.
208 #[non_exhaustive]
209 Console {
210 /// Severity level: "log", "warn", or "error".
211 level: String,
212 /// The log message text.
213 message: String,
214 /// When the message was captured.
215 timestamp: DateTime<Utc>,
216 },
217}
218
219impl AppEvent {
220 /// Builds an [`AppEvent::StateChange`].
221 #[must_use]
222 pub fn state_change(
223 key: impl Into<String>,
224 timestamp: DateTime<Utc>,
225 caused_by: Option<String>,
226 ) -> Self {
227 Self::StateChange {
228 key: key.into(),
229 timestamp,
230 caused_by,
231 }
232 }
233
234 /// Builds an [`AppEvent::DomMutation`].
235 #[must_use]
236 pub fn dom_mutation(
237 webview_label: impl Into<String>,
238 timestamp: DateTime<Utc>,
239 mutation_count: u32,
240 ) -> Self {
241 Self::DomMutation {
242 webview_label: webview_label.into(),
243 timestamp,
244 mutation_count,
245 }
246 }
247
248 /// Builds an [`AppEvent::DomInteraction`].
249 #[must_use]
250 pub fn dom_interaction(
251 action: InteractionKind,
252 selector: impl Into<String>,
253 value: Option<String>,
254 timestamp: DateTime<Utc>,
255 webview_label: impl Into<String>,
256 ) -> Self {
257 Self::DomInteraction {
258 action,
259 selector: selector.into(),
260 value,
261 timestamp,
262 webview_label: webview_label.into(),
263 }
264 }
265
266 /// Builds an [`AppEvent::WindowEvent`].
267 #[must_use]
268 pub fn window_event(
269 label: impl Into<String>,
270 event: impl Into<String>,
271 timestamp: DateTime<Utc>,
272 ) -> Self {
273 Self::WindowEvent {
274 label: label.into(),
275 event: event.into(),
276 timestamp,
277 }
278 }
279
280 /// Builds an [`AppEvent::Console`].
281 ///
282 /// # Examples
283 ///
284 /// ```
285 /// use victauri_core::AppEvent;
286 ///
287 /// let ev = AppEvent::console("warn", "deprecated", chrono::Utc::now());
288 /// assert!(matches!(ev, AppEvent::Console { .. }));
289 /// ```
290 #[must_use]
291 pub fn console(
292 level: impl Into<String>,
293 message: impl Into<String>,
294 timestamp: DateTime<Utc>,
295 ) -> Self {
296 Self::Console {
297 level: level.into(),
298 message: message.into(),
299 timestamp,
300 }
301 }
302
303 /// Returns the timestamp of this event, regardless of variant.
304 #[must_use]
305 pub fn timestamp(&self) -> DateTime<Utc> {
306 match self {
307 Self::Ipc(call) => call.timestamp,
308 Self::StateChange { timestamp, .. }
309 | Self::DomMutation { timestamp, .. }
310 | Self::DomInteraction { timestamp, .. }
311 | Self::WindowEvent { timestamp, .. }
312 | Self::Console { timestamp, .. } => *timestamp,
313 }
314 }
315
316 /// Returns true if this event was generated by Victauri's own infrastructure
317 /// rather than the application under observation.
318 #[must_use]
319 pub fn is_internal(&self) -> bool {
320 match self {
321 Self::Ipc(call) => call.command.starts_with("plugin:victauri|"),
322 _ => false,
323 }
324 }
325}
326
327/// Thread-safe ring-buffer event log. Automatically evicts the oldest events
328/// when capacity is reached. All operations recover from mutex poisoning.
329#[derive(Debug, Clone)]
330pub struct EventLog {
331 events: Arc<Mutex<VecDeque<AppEvent>>>,
332 max_capacity: usize,
333}
334
335impl EventLog {
336 /// Creates a new event log with the given maximum capacity.
337 ///
338 /// ```
339 /// use victauri_core::EventLog;
340 ///
341 /// let log = EventLog::new(100);
342 /// assert!(log.is_empty());
343 /// assert_eq!(log.capacity(), 100);
344 /// ```
345 #[must_use]
346 pub fn new(max_capacity: usize) -> Self {
347 Self {
348 events: Arc::new(Mutex::new(VecDeque::with_capacity(max_capacity))),
349 max_capacity,
350 }
351 }
352
353 /// Returns the maximum number of events this log can hold.
354 #[must_use]
355 pub fn capacity(&self) -> usize {
356 self.max_capacity
357 }
358
359 /// Appends an event, evicting the oldest if at capacity.
360 ///
361 /// # Examples
362 ///
363 /// ```
364 /// use victauri_core::{EventLog, AppEvent};
365 /// use chrono::Utc;
366 ///
367 /// let log = EventLog::new(100);
368 /// log.push(AppEvent::state_change("theme", Utc::now(), None));
369 /// assert_eq!(log.len(), 1);
370 /// assert_eq!(log.snapshot().len(), 1);
371 /// ```
372 pub fn push(&self, event: AppEvent) {
373 let mut events = crate::acquire_lock(&self.events, "EventLog");
374 if events.len() >= self.max_capacity {
375 events.pop_front();
376 }
377 events.push_back(event);
378 }
379
380 /// Returns a clone of all events currently in the log.
381 #[must_use]
382 pub fn snapshot(&self) -> Vec<AppEvent> {
383 crate::acquire_lock(&self.events, "EventLog")
384 .iter()
385 .cloned()
386 .collect()
387 }
388
389 /// Returns a paginated slice of events starting at `offset`, up to `limit` items.
390 #[must_use]
391 pub fn snapshot_range(&self, offset: usize, limit: usize) -> Vec<AppEvent> {
392 crate::acquire_lock(&self.events, "EventLog")
393 .iter()
394 .skip(offset)
395 .take(limit)
396 .cloned()
397 .collect()
398 }
399
400 /// Returns all events with a timestamp at or after the given time.
401 #[must_use]
402 pub fn since(&self, timestamp: DateTime<Utc>) -> Vec<AppEvent> {
403 crate::acquire_lock(&self.events, "EventLog")
404 .iter()
405 .filter(|e| e.timestamp() >= timestamp)
406 .cloned()
407 .collect()
408 }
409
410 /// Returns all IPC call events, filtering out non-IPC events.
411 ///
412 /// # Examples
413 ///
414 /// ```
415 /// use victauri_core::{EventLog, AppEvent, IpcCall, IpcResult};
416 /// use chrono::Utc;
417 ///
418 /// let log = EventLog::new(100);
419 /// log.push(AppEvent::Ipc(IpcCall::new(
420 /// "c1",
421 /// "greet",
422 /// Utc::now(),
423 /// IpcResult::Ok(serde_json::json!("hi")),
424 /// Some(5),
425 /// 0,
426 /// "main",
427 /// )));
428 /// assert_eq!(log.ipc_calls().len(), 1);
429 /// ```
430 #[must_use]
431 pub fn ipc_calls(&self) -> Vec<IpcCall> {
432 crate::acquire_lock(&self.events, "EventLog")
433 .iter()
434 .filter_map(|e| match e {
435 AppEvent::Ipc(call) => Some(call.clone()),
436 _ => None,
437 })
438 .collect()
439 }
440
441 /// Returns the newest `limit` IPC calls, oldest first.
442 ///
443 /// Unlike [`ipc_calls`](Self::ipc_calls) this clones only the calls it returns (a call
444 /// carries its whole request/response body), so its cost is bounded by `limit`, not by the
445 /// size of the log.
446 #[must_use]
447 pub fn recent_ipc_calls(&self, limit: usize) -> Vec<IpcCall> {
448 let events = crate::acquire_lock(&self.events, "EventLog");
449 let mut calls: Vec<IpcCall> = events
450 .iter()
451 .rev()
452 .filter_map(|e| match e {
453 AppEvent::Ipc(call) => Some(call),
454 _ => None,
455 })
456 .take(limit)
457 .cloned()
458 .collect();
459 drop(events);
460 calls.reverse();
461 calls
462 }
463
464 /// Returns the distinct command names of the IPC calls in the log, without cloning the
465 /// calls themselves.
466 #[must_use]
467 pub fn ipc_command_names(&self) -> std::collections::BTreeSet<String> {
468 let events = crate::acquire_lock(&self.events, "EventLog");
469 let mut names = std::collections::BTreeSet::new();
470 for event in events.iter() {
471 if let AppEvent::Ipc(call) = event
472 && !names.contains(call.command.as_str())
473 {
474 names.insert(call.command.clone());
475 }
476 }
477 names
478 }
479
480 /// Calls `f` on every IPC call in the log, in order, under the log's lock (so `f` must be
481 /// quick and must not touch the log). Nothing is cloned.
482 pub(crate) fn for_each_ipc_call(&self, mut f: impl FnMut(&IpcCall)) {
483 for event in crate::acquire_lock(&self.events, "EventLog").iter() {
484 if let AppEvent::Ipc(call) = event {
485 f(call);
486 }
487 }
488 }
489
490 /// Returns IPC calls with a timestamp at or after the given time.
491 #[must_use]
492 pub fn ipc_calls_since(&self, timestamp: DateTime<Utc>) -> Vec<IpcCall> {
493 crate::acquire_lock(&self.events, "EventLog")
494 .iter()
495 .filter_map(|e| match e {
496 AppEvent::Ipc(call) if call.timestamp >= timestamp => Some(call.clone()),
497 _ => None,
498 })
499 .collect()
500 }
501
502 /// Returns the number of events currently in the log.
503 #[must_use]
504 pub fn len(&self) -> usize {
505 crate::acquire_lock(&self.events, "EventLog").len()
506 }
507
508 /// Returns true if the log contains no events.
509 #[must_use]
510 pub fn is_empty(&self) -> bool {
511 crate::acquire_lock(&self.events, "EventLog").is_empty()
512 }
513
514 /// Removes all events from the log.
515 pub fn clear(&self) {
516 crate::acquire_lock(&self.events, "EventLog").clear();
517 }
518}