1use crate::cdp::session::CdpSession;
20use crate::error::{Error, Result};
21use serde_json::{json, Value};
22use std::collections::HashMap;
23use std::sync::Arc;
24
25#[derive(Clone)]
29pub struct Accessibility {
30 inner: Arc<AccessibilityInner>,
31}
32
33struct AccessibilityInner {
34 session: CdpSession,
36}
37
38impl Accessibility {
39 pub(crate) fn new(session: CdpSession) -> Self {
40 Self {
41 inner: Arc::new(AccessibilityInner { session }),
42 }
43 }
44
45 pub async fn snapshot(
53 &self,
54 options: Option<AccessibilitySnapshotOptions>,
55 ) -> Result<Option<AccessibilityNode>> {
56 let opts = options.unwrap_or_default();
57 let interesting_only = opts.interesting_only.unwrap_or(true);
58
59 let _ = self.inner.session.send("Accessibility.enable", json!({})).await;
62
63 let nodes = if let Some(root_node_id) = &opts.root {
64 self.partial_tree(root_node_id).await?
65 } else {
66 let resp: Value = self
67 .inner
68 .session
69 .send("Accessibility.getFullAXTree", json!({}))
70 .await?;
71 resp.get("nodes")
72 .and_then(|v| v.as_array())
73 .cloned()
74 .unwrap_or_default()
75 };
76
77 if nodes.is_empty() {
78 return Ok(None);
79 }
80
81 let root_id = match nodes.first().and_then(|n| n.get("nodeId")).and_then(|v| v.as_str()) {
82 Some(id) => id.to_string(),
83 None => return Ok(None),
84 };
85
86 let tree = build_tree(&nodes, &root_id, interesting_only)?;
87 Ok(tree)
88 }
89
90 async fn partial_tree(&self, backend_node_id: &str) -> Result<Vec<Value>> {
93 let backend: i64 = backend_node_id
95 .parse()
96 .map_err(|_| Error::InvalidArgument("root must be a numeric backendNodeId".into()))?;
97 let resp: Value = self
98 .inner
99 .session
100 .send(
101 "Accessibility.getPartialAXTree",
102 json!({ "backendNodeId": backend }),
103 )
104 .await?;
105 Ok(resp
106 .get("nodes")
107 .and_then(|v| v.as_array())
108 .cloned()
109 .unwrap_or_default())
110 }
111}
112
113#[derive(Debug, Clone, Default)]
115#[non_exhaustive]
116pub struct AccessibilitySnapshotOptions {
117 pub interesting_only: Option<bool>,
121 pub root: Option<String>,
124}
125
126impl AccessibilitySnapshotOptions {
127 pub fn interesting_only(mut self, v: bool) -> Self {
128 self.interesting_only = Some(v);
129 self
130 }
131 pub fn root(mut self, v: impl Into<String>) -> Self {
132 self.root = Some(v.into());
133 self
134 }
135}
136
137#[derive(Debug, Clone, Default)]
142#[non_exhaustive]
143pub struct AccessibilityNode {
144 pub role: Option<String>,
146 pub name: Option<String>,
148 pub value: Option<Value>,
150 pub description: Option<String>,
152 pub checked: Option<String>,
154 pub disabled: Option<bool>,
156 pub expanded: Option<String>,
158 pub focused: Option<bool>,
160 pub modal: Option<bool>,
162 pub multiline: Option<bool>,
164 pub readonly: Option<bool>,
166 pub required: Option<bool>,
168 pub selected: Option<bool>,
170 pub hidden: Option<bool>,
172 pub children: Vec<AccessibilityNode>,
174}
175
176impl AccessibilityNode {
177 pub fn role(&self) -> Option<&str> {
178 self.role.as_deref()
179 }
180 pub fn name(&self) -> Option<&str> {
181 self.name.as_deref()
182 }
183}
184
185fn build_tree(
191 nodes: &[Value],
192 root_id: &str,
193 interesting_only: bool,
194) -> Result<Option<AccessibilityNode>> {
195 let mut by_id: HashMap<String, &Value> = HashMap::with_capacity(nodes.len());
197 for n in nodes {
198 if let Some(id) = n.get("nodeId").and_then(|v| v.as_str()) {
199 by_id.insert(id.to_string(), n);
200 }
201 }
202 let root = by_id.get(root_id).ok_or_else(|| {
203 Error::ProtocolError(format!(
204 "accessibility tree root node '{root_id}' not found in response"
205 ))
206 })?;
207 let mut node = convert_node(root, &by_id, interesting_only)?;
208 if interesting_only {
209 node = prune(node);
210 }
211 if node.role.is_none()
212 && node.name.is_none()
213 && node.value.is_none()
214 && node.description.is_none()
215 && node.children.is_empty()
216 {
217 return Ok(None);
218 }
219 Ok(Some(node))
220}
221
222fn convert_node(
225 raw: &Value,
226 by_id: &HashMap<String, &Value>,
227 interesting_only: bool,
228) -> Result<AccessibilityNode> {
229 let role = property_value(raw, "role").or_else(|| {
230 raw.get("role")
231 .and_then(|r| r.get("value"))
232 .and_then(|v| v.as_str())
233 .map(String::from)
234 });
235 let name = property_value(raw, "name").or_else(|| {
239 raw.get("name")
240 .and_then(|n| n.get("value"))
241 .and_then(|v| v.as_str())
242 .map(String::from)
243 });
244 let description = property_value(raw, "description").or_else(|| {
245 raw.get("description")
246 .and_then(|d| d.get("value"))
247 .and_then(|v| v.as_str())
248 .map(String::from)
249 });
250 let value = raw
251 .get("value")
252 .and_then(|v| v.get("value"))
253 .cloned()
254 .filter(|v| !v.is_null());
255
256 let checked = property_value(raw, "checked").filter(|s| s != "false");
257 let expanded = property_value(raw, "expanded").map(|s| match s.as_str() {
258 "true" => "expanded".to_string(),
259 "false" => "collapsed".to_string(),
260 other => other.to_string(),
261 });
262 let disabled = boolean_property(raw, "disabled");
263 let focused = boolean_property(raw, "focused");
264 let modal = boolean_property(raw, "modal");
265 let multiline = boolean_property(raw, "multiline");
266 let readonly = boolean_property(raw, "readonly");
267 let required = boolean_property(raw, "required");
268 let selected = boolean_property(raw, "selected");
269 let hidden = boolean_property(raw, "hidden")
270 .or_else(|| property_value(raw, "visibility").map(|s| s == "hidden"));
271
272 let mut children = Vec::new();
273 if let Some(child_ids) = raw.get("childIds").and_then(|v| v.as_array()) {
274 for cid in child_ids {
275 if let Some(id) = cid.as_str() {
276 if let Some(child_raw) = by_id.get(id) {
277 let mut child = convert_node(child_raw, by_id, interesting_only)?;
278 if interesting_only {
279 child = prune(child);
280 }
281 let empty = child.role.is_none()
284 && child.name.is_none()
285 && child.value.is_none()
286 && child.description.is_none()
287 && child.children.is_empty();
288 if !empty {
289 children.push(child);
290 }
291 }
292 }
293 }
294 }
295
296 Ok(AccessibilityNode {
297 role,
298 name,
299 value,
300 description,
301 checked,
302 disabled,
303 expanded,
304 focused,
305 modal,
306 multiline,
307 readonly,
308 required,
309 selected,
310 hidden,
311 children,
312 })
313}
314
315fn property_value(node: &Value, name: &str) -> Option<String> {
319 let props = node.get("properties").and_then(|v| v.as_array())?;
320 for p in props {
321 let matches = p
322 .get("name")
323 .and_then(|v| v.as_str())
324 .map(|n| n == name)
325 .unwrap_or(false);
326 if matches {
327 return p
328 .get("value")
329 .and_then(|v| v.get("value"))
330 .and_then(|v| v.as_str())
331 .map(String::from);
332 }
333 }
334 None
335}
336
337fn boolean_property(node: &Value, name: &str) -> Option<bool> {
339 let props = node.get("properties").and_then(|v| v.as_array())?;
340 for p in props {
341 let matches = p
342 .get("name")
343 .and_then(|v| v.as_str())
344 .map(|n| n == name)
345 .unwrap_or(false);
346 if matches {
347 return p
348 .get("value")
349 .and_then(|v| v.get("value"))
350 .and_then(|v| v.as_bool());
351 }
352 }
353 None
354}
355
356fn prune(node: AccessibilityNode) -> AccessibilityNode {
359 let has_payload = node.role.is_some()
361 || node.name.is_some()
362 || node.value.is_some()
363 || node.description.is_some()
364 || node.checked.is_some()
365 || node.expanded.is_some()
366 || node.disabled == Some(true)
367 || node.focused == Some(true)
368 || node.selected == Some(true);
369 if has_payload || !node.children.is_empty() {
370 node
371 } else {
372 AccessibilityNode::default()
373 }
374}