Skip to main content

victauri_core/
snapshot.rs

1//! DOM snapshot and window state types for webview introspection.
2
3use serde::{Deserialize, Serialize};
4
5/// Current state of a Tauri window including geometry, visibility, and loaded URL.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[non_exhaustive]
8pub struct WindowState {
9    /// Tauri window label (e.g. "main", "notification").
10    pub label: String,
11    /// Window title bar text.
12    pub title: String,
13    /// URL currently loaded in the webview.
14    pub url: String,
15    /// Whether the window is visible on screen.
16    pub visible: bool,
17    /// Whether the window currently has input focus.
18    pub focused: bool,
19    /// Whether the window is maximized.
20    pub maximized: bool,
21    /// Whether the window is minimized.
22    pub minimized: bool,
23    /// Whether the window is in fullscreen mode.
24    pub fullscreen: bool,
25    /// Window position as (x, y) in screen coordinates.
26    pub position: (i32, i32),
27    /// Window dimensions as (width, height) in pixels.
28    pub size: (u32, u32),
29}
30
31/// A point-in-time snapshot of the DOM accessible tree from a specific webview.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[non_exhaustive]
34pub struct DomSnapshot {
35    /// Label of the webview this snapshot was taken from.
36    pub webview_label: String,
37    /// Top-level accessible elements in the DOM tree.
38    pub elements: Vec<DomElement>,
39    /// Maps ref IDs to CSS selectors for element lookup.
40    pub ref_map: std::collections::BTreeMap<String, String>,
41}
42
43/// A single element in the accessible DOM tree with semantic metadata and ref handle.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct DomElement {
47    /// Unique ref handle for this element (e.g. "e3"), used to target interactions.
48    pub ref_id: String,
49    /// HTML tag name (e.g. "div", "button").
50    pub tag: String,
51    /// ARIA role if present (e.g. "button", "navigation").
52    pub role: Option<String>,
53    /// Accessible name derived from aria-label, text content, or other heuristics.
54    pub name: Option<String>,
55    /// Visible text content of the element.
56    pub text: Option<String>,
57    /// Form input value, if applicable.
58    pub value: Option<String>,
59    /// Whether the element is interactive (not disabled).
60    pub enabled: bool,
61    /// Whether the element is visible in the viewport.
62    pub visible: bool,
63    /// Whether the element can receive keyboard focus.
64    pub focusable: bool,
65    /// Pixel-level bounding rectangle, if available.
66    pub bounds: Option<ElementBounds>,
67    /// Nested child elements forming the accessible subtree.
68    pub children: Vec<Self>,
69    /// Raw HTML attributes on the element.
70    pub attributes: std::collections::BTreeMap<String, String>,
71}
72
73/// Pixel-level bounding rectangle of a DOM element relative to the viewport.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[non_exhaustive]
76pub struct ElementBounds {
77    /// Left edge offset from viewport origin.
78    pub x: f64,
79    /// Top edge offset from viewport origin.
80    pub y: f64,
81    /// Element width in CSS pixels.
82    pub width: f64,
83    /// Element height in CSS pixels.
84    pub height: f64,
85}
86
87impl WindowState {
88    /// Creates a window state for `label` with every other field zeroed
89    /// (empty title/URL, not visible/focused/maximized/minimized/fullscreen,
90    /// position `(0, 0)`, size `(0, 0)`). Chain the `with_*` setters to fill it in.
91    ///
92    /// `WindowState` is `#[non_exhaustive]`; `victauri_plugin::bridge::WebviewBridge`
93    /// implementors and mock bridges outside this crate build it through this constructor.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// use victauri_core::WindowState;
99    ///
100    /// let w = WindowState::new("main")
101    ///     .with_title("My App")
102    ///     .with_url("http://localhost/")
103    ///     .with_visible(true)
104    ///     .with_size(800, 600);
105    /// assert_eq!(w.label, "main");
106    /// assert_eq!(w.size, (800, 600));
107    /// assert!(!w.focused);
108    /// ```
109    #[must_use]
110    pub fn new(label: impl Into<String>) -> Self {
111        Self {
112            label: label.into(),
113            title: String::new(),
114            url: String::new(),
115            visible: false,
116            focused: false,
117            maximized: false,
118            minimized: false,
119            fullscreen: false,
120            position: (0, 0),
121            size: (0, 0),
122        }
123    }
124
125    /// Sets the window title.
126    #[must_use]
127    pub fn with_title(mut self, title: impl Into<String>) -> Self {
128        self.title = title.into();
129        self
130    }
131
132    /// Sets the URL loaded in the webview.
133    #[must_use]
134    pub fn with_url(mut self, url: impl Into<String>) -> Self {
135        self.url = url.into();
136        self
137    }
138
139    /// Sets whether the window is visible.
140    #[must_use]
141    pub fn with_visible(mut self, visible: bool) -> Self {
142        self.visible = visible;
143        self
144    }
145
146    /// Sets whether the window has input focus.
147    #[must_use]
148    pub fn with_focused(mut self, focused: bool) -> Self {
149        self.focused = focused;
150        self
151    }
152
153    /// Sets whether the window is maximized.
154    #[must_use]
155    pub fn with_maximized(mut self, maximized: bool) -> Self {
156        self.maximized = maximized;
157        self
158    }
159
160    /// Sets whether the window is minimized.
161    #[must_use]
162    pub fn with_minimized(mut self, minimized: bool) -> Self {
163        self.minimized = minimized;
164        self
165    }
166
167    /// Sets whether the window is fullscreen.
168    #[must_use]
169    pub fn with_fullscreen(mut self, fullscreen: bool) -> Self {
170        self.fullscreen = fullscreen;
171        self
172    }
173
174    /// Sets the window position in screen coordinates.
175    #[must_use]
176    pub fn with_position(mut self, x: i32, y: i32) -> Self {
177        self.position = (x, y);
178        self
179    }
180
181    /// Sets the window size in pixels.
182    #[must_use]
183    pub fn with_size(mut self, width: u32, height: u32) -> Self {
184        self.size = (width, height);
185        self
186    }
187}
188
189impl DomSnapshot {
190    /// Renders the snapshot as indented accessible text (roles, names, and ref handles).
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use victauri_core::DomSnapshot;
196    ///
197    /// // Snapshots are produced by the webview bridge; here one is parsed from JSON.
198    /// let snapshot: DomSnapshot = serde_json::from_value(serde_json::json!({
199    ///     "webview_label": "main",
200    ///     "elements": [{
201    ///         "ref_id": "e1", "tag": "button", "role": "button", "name": "Submit",
202    ///         "text": null, "value": null, "enabled": true, "visible": true,
203    ///         "focusable": true, "bounds": null, "children": [], "attributes": {}
204    ///     }],
205    ///     "ref_map": {}
206    /// }))
207    /// .unwrap();
208    /// let text = snapshot.to_accessible_text(0);
209    /// assert!(text.contains("button"));
210    /// assert!(text.contains("Submit"));
211    /// assert!(text.contains("[ref=e1]"));
212    /// ```
213    #[must_use]
214    pub fn to_accessible_text(&self, indent: usize) -> String {
215        let mut output = String::new();
216        for element in &self.elements {
217            Self::format_element(&mut output, element, indent, 0);
218        }
219        output
220    }
221
222    /// Maximum DOM nesting `format_element` will recurse through. A snapshot can
223    /// come from an arbitrary (browser-extension: hostile) page, so an unbounded
224    /// recursion here is a stack-overflow (denial of service) when rendering the text.
225    const MAX_DOM_DEPTH: usize = 256;
226
227    fn format_element(output: &mut String, element: &DomElement, indent: usize, depth: usize) {
228        if !element.visible {
229            return;
230        }
231
232        if depth >= Self::MAX_DOM_DEPTH {
233            output.push_str(&format!(
234                "{}- <max DOM depth {} exceeded>\n",
235                "  ".repeat(indent),
236                Self::MAX_DOM_DEPTH
237            ));
238            return;
239        }
240
241        let prefix = "  ".repeat(indent);
242
243        let role_str = element.role.as_deref().unwrap_or(&element.tag);
244        let name_str = element
245            .name
246            .as_ref()
247            .map(|n| format!(" \"{n}\""))
248            .unwrap_or_default();
249        let ref_str = if element.focusable || element.tag == "button" || element.tag == "input" {
250            format!(" [ref={}]", element.ref_id)
251        } else {
252            String::new()
253        };
254
255        let line = format!("{prefix}- {role_str}{name_str}{ref_str}\n");
256        output.push_str(&line);
257
258        for child in &element.children {
259            Self::format_element(output, child, indent + 1, depth + 1);
260        }
261    }
262}