Skip to main content

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}