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}