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}
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}