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