Skip to main content

robost_uia/
lib.rs

1//! Windows UI Automation integration.
2//!
3//! Provides direct access to Win32 UI Automation (UIA) for interacting with
4//! controls without image recognition. Windows-only; stubs are provided on
5//! other platforms so the crate compiles cross-platform.
6//!
7//! # Usage
8//!
9//! ```yaml
10//! - uia_get:
11//!     by: { name: "ユーザー名" }
12//!     property: value
13//!     save_as: username_text
14//!
15//! - uia_set:
16//!     by: { name: "ユーザー名" }
17//!     value: "{{ username }}"
18//!
19//! - uia_click:
20//!     by: { id: "btnLogin" }
21//!
22//! - uia_find:
23//!     by: { class: "Edit" }
24//!     save_as: edit_handle
25//! ```
26
27#[derive(Debug, thiserror::Error)]
28pub enum UiaError {
29    #[error("UIA element not found: {0}")]
30    NotFound(String),
31    #[error("UIA COM error: {0}")]
32    Com(String),
33    #[error("UIA not supported on this platform")]
34    Unsupported,
35    #[error("{0}")]
36    Other(String),
37}
38
39pub type Result<T> = std::result::Result<T, UiaError>;
40
41/// How to locate a UI Automation element.
42#[derive(Debug, Clone)]
43pub enum UiaSelector {
44    /// Match by the element's Name property (accessibility label).
45    Name(String),
46    /// Match by the element's AutomationId property.
47    AutomationId(String),
48    /// Match by the element's ClassName property.
49    ClassName(String),
50}
51
52impl UiaSelector {
53    pub fn from_name(s: impl Into<String>) -> Self {
54        Self::Name(s.into())
55    }
56    pub fn from_id(s: impl Into<String>) -> Self {
57        Self::AutomationId(s.into())
58    }
59    pub fn from_class(s: impl Into<String>) -> Self {
60        Self::ClassName(s.into())
61    }
62}
63
64/// A located UI Automation element.
65pub struct UiaElement {
66    #[cfg(target_os = "windows")]
67    inner: windows_impl::Element,
68    #[cfg(not(target_os = "windows"))]
69    _phantom: (),
70}
71
72/// The UI Automation root finder.
73pub struct UiaFinder {
74    #[cfg(target_os = "windows")]
75    inner: windows_impl::Finder,
76    #[cfg(not(target_os = "windows"))]
77    _phantom: (),
78}
79
80impl UiaFinder {
81    pub fn new() -> Result<Self> {
82        #[cfg(target_os = "windows")]
83        {
84            Ok(Self {
85                inner: windows_impl::Finder::new()?,
86            })
87        }
88        #[cfg(not(target_os = "windows"))]
89        Err(UiaError::Unsupported)
90    }
91
92    /// Find the first element matching `selector` in the entire desktop tree.
93    pub fn find(&self, selector: &UiaSelector) -> Result<UiaElement> {
94        #[cfg(target_os = "windows")]
95        {
96            let el = self.inner.find(selector)?;
97            Ok(UiaElement { inner: el })
98        }
99        #[cfg(not(target_os = "windows"))]
100        {
101            let _ = selector;
102            Err(UiaError::Unsupported)
103        }
104    }
105}
106
107impl UiaElement {
108    /// Read the Name property.
109    pub fn get_name(&self) -> Result<String> {
110        #[cfg(target_os = "windows")]
111        return self.inner.get_name();
112        #[cfg(not(target_os = "windows"))]
113        Err(UiaError::Unsupported)
114    }
115
116    /// Read the Value property (for edit controls, etc.).
117    pub fn get_value(&self) -> Result<String> {
118        #[cfg(target_os = "windows")]
119        return self.inner.get_value();
120        #[cfg(not(target_os = "windows"))]
121        Err(UiaError::Unsupported)
122    }
123
124    /// Set the Value property.
125    pub fn set_value(&self, value: &str) -> Result<()> {
126        #[cfg(target_os = "windows")]
127        return self.inner.set_value(value);
128        #[cfg(not(target_os = "windows"))]
129        {
130            let _ = value;
131            Err(UiaError::Unsupported)
132        }
133    }
134
135    /// Invoke the element's default action (equivalent to clicking a button).
136    pub fn invoke(&self) -> Result<()> {
137        #[cfg(target_os = "windows")]
138        return self.inner.invoke();
139        #[cfg(not(target_os = "windows"))]
140        Err(UiaError::Unsupported)
141    }
142
143    /// Get the bounding rectangle as (x, y, width, height).
144    pub fn bounding_rect(&self) -> Result<(i32, i32, i32, i32)> {
145        #[cfg(target_os = "windows")]
146        return self.inner.bounding_rect();
147        #[cfg(not(target_os = "windows"))]
148        Err(UiaError::Unsupported)
149    }
150
151    /// Enumerate immediate children.
152    pub fn children(&self) -> Result<Vec<UiaElement>> {
153        #[cfg(target_os = "windows")]
154        {
155            let children = self.inner.children()?;
156            Ok(children
157                .into_iter()
158                .map(|el| UiaElement { inner: el })
159                .collect())
160        }
161        #[cfg(not(target_os = "windows"))]
162        Err(UiaError::Unsupported)
163    }
164
165    /// Return whether the element is currently enabled.
166    pub fn is_enabled(&self) -> Result<bool> {
167        #[cfg(target_os = "windows")]
168        return self.inner.is_enabled();
169        #[cfg(not(target_os = "windows"))]
170        Err(UiaError::Unsupported)
171    }
172
173    /// Return whether the element is off-screen (not visible).
174    pub fn is_offscreen(&self) -> Result<bool> {
175        #[cfg(target_os = "windows")]
176        return self.inner.is_offscreen();
177        #[cfg(not(target_os = "windows"))]
178        Err(UiaError::Unsupported)
179    }
180
181    /// Read the ClassName property.
182    pub fn get_class_name(&self) -> Result<String> {
183        #[cfg(target_os = "windows")]
184        return self.inner.get_class_name();
185        #[cfg(not(target_os = "windows"))]
186        Err(UiaError::Unsupported)
187    }
188
189    /// Select a named item inside a ComboBox or ListBox.
190    ///
191    /// For ComboBoxes the element is expanded first, then the child whose Name
192    /// matches `item_name` is selected via `IUIAutomationSelectionItemPattern`.
193    pub fn select_item(&self, item_name: &str) -> Result<()> {
194        #[cfg(target_os = "windows")]
195        return self.inner.select_item(item_name);
196        #[cfg(not(target_os = "windows"))]
197        {
198            let _ = item_name;
199            Err(UiaError::Unsupported)
200        }
201    }
202
203    /// Set the checked state of a checkbox via `IUIAutomationTogglePattern`.
204    pub fn set_checked(&self, checked: bool) -> Result<()> {
205        #[cfg(target_os = "windows")]
206        return self.inner.set_checked(checked);
207        #[cfg(not(target_os = "windows"))]
208        {
209            let _ = checked;
210            Err(UiaError::Unsupported)
211        }
212    }
213}
214
215// ── Windows implementation ─────────────────────────────────────────────────
216
217#[cfg(target_os = "windows")]
218mod windows_impl {
219    use super::{UiaError, UiaSelector};
220    use windows::{
221        core::{Interface, BSTR},
222        Win32::{
223            System::{
224                Com::{
225                    CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED,
226                },
227                Variant::VARIANT,
228            },
229            UI::Accessibility::{
230                CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationValuePattern,
231                TreeScope_Descendants, UIA_AutomationIdPropertyId, UIA_ClassNamePropertyId,
232                UIA_NamePropertyId, UIA_ValuePatternId,
233            },
234        },
235    };
236
237    pub struct Finder {
238        automation: IUIAutomation,
239    }
240
241    pub struct Element {
242        pub(crate) el: IUIAutomationElement,
243        automation: IUIAutomation,
244    }
245
246    impl Finder {
247        pub fn new() -> super::Result<Self> {
248            unsafe {
249                CoInitializeEx(None, COINIT_MULTITHREADED)
250                    .ok()
251                    .map_err(|e| UiaError::Com(e.to_string()))?;
252                let automation: IUIAutomation =
253                    CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER)
254                        .map_err(|e| UiaError::Com(e.to_string()))?;
255                Ok(Self { automation })
256            }
257        }
258
259        pub fn find(&self, selector: &UiaSelector) -> super::Result<Element> {
260            unsafe {
261                let root = self
262                    .automation
263                    .GetRootElement()
264                    .map_err(|e| UiaError::Com(e.to_string()))?;
265
266                let (prop_id, value) = match selector {
267                    UiaSelector::Name(s) => (UIA_NamePropertyId, s.clone()),
268                    UiaSelector::AutomationId(s) => (UIA_AutomationIdPropertyId, s.clone()),
269                    UiaSelector::ClassName(s) => (UIA_ClassNamePropertyId, s.clone()),
270                };
271
272                let variant = VARIANT::from(BSTR::from(value.as_str()));
273                let condition = self
274                    .automation
275                    .CreatePropertyCondition(prop_id, &variant)
276                    .map_err(|e| UiaError::Com(e.to_string()))?;
277
278                let el = root
279                    .FindFirst(TreeScope_Descendants, &condition)
280                    .map_err(|e| UiaError::Com(e.to_string()))?;
281
282                Ok(Element {
283                    el,
284                    automation: self.automation.clone(),
285                })
286            }
287        }
288    }
289
290    impl Element {
291        pub fn get_name(&self) -> super::Result<String> {
292            unsafe {
293                let bstr = self
294                    .el
295                    .CurrentName()
296                    .map_err(|e| UiaError::Com(e.to_string()))?;
297                Ok(bstr.to_string())
298            }
299        }
300
301        pub fn get_value(&self) -> super::Result<String> {
302            unsafe {
303                let pattern: IUIAutomationValuePattern = self
304                    .el
305                    .GetCurrentPattern(UIA_ValuePatternId)
306                    .map_err(|e| UiaError::Com(e.to_string()))?
307                    .cast()
308                    .map_err(|e| UiaError::Com(e.to_string()))?;
309                let bstr = pattern
310                    .CurrentValue()
311                    .map_err(|e| UiaError::Com(e.to_string()))?;
312                Ok(bstr.to_string())
313            }
314        }
315
316        pub fn set_value(&self, value: &str) -> super::Result<()> {
317            unsafe {
318                let pattern: IUIAutomationValuePattern = self
319                    .el
320                    .GetCurrentPattern(UIA_ValuePatternId)
321                    .map_err(|e| UiaError::Com(e.to_string()))?
322                    .cast()
323                    .map_err(|e| UiaError::Com(e.to_string()))?;
324                pattern
325                    .SetValue(&BSTR::from(value))
326                    .map_err(|e| UiaError::Com(e.to_string()))
327            }
328        }
329
330        pub fn invoke(&self) -> super::Result<()> {
331            use windows::Win32::UI::Accessibility::{
332                IUIAutomationInvokePattern, UIA_InvokePatternId,
333            };
334            unsafe {
335                let pattern: IUIAutomationInvokePattern = self
336                    .el
337                    .GetCurrentPattern(UIA_InvokePatternId)
338                    .map_err(|e| UiaError::Com(e.to_string()))?
339                    .cast()
340                    .map_err(|e| UiaError::Com(e.to_string()))?;
341                pattern.Invoke().map_err(|e| UiaError::Com(e.to_string()))
342            }
343        }
344
345        pub fn bounding_rect(&self) -> super::Result<(i32, i32, i32, i32)> {
346            unsafe {
347                let rect = self
348                    .el
349                    .CurrentBoundingRectangle()
350                    .map_err(|e| UiaError::Com(e.to_string()))?;
351                Ok((
352                    rect.left,
353                    rect.top,
354                    rect.right - rect.left,
355                    rect.bottom - rect.top,
356                ))
357            }
358        }
359
360        pub fn children(&self) -> super::Result<Vec<Element>> {
361            use windows::Win32::UI::Accessibility::TreeScope_Children;
362            unsafe {
363                let true_cond = self
364                    .automation
365                    .CreateTrueCondition()
366                    .map_err(|e| UiaError::Com(e.to_string()))?;
367                let el_array = self
368                    .el
369                    .FindAll(TreeScope_Children, &true_cond)
370                    .map_err(|e| UiaError::Com(e.to_string()))?;
371                let count = el_array
372                    .Length()
373                    .map_err(|e| UiaError::Com(e.to_string()))?;
374                let mut result = Vec::with_capacity(count as usize);
375                for i in 0..count {
376                    let child = el_array
377                        .GetElement(i)
378                        .map_err(|e| UiaError::Com(e.to_string()))?;
379                    result.push(Element {
380                        el: child,
381                        automation: self.automation.clone(),
382                    });
383                }
384                Ok(result)
385            }
386        }
387
388        pub fn is_enabled(&self) -> super::Result<bool> {
389            unsafe {
390                let b = self
391                    .el
392                    .CurrentIsEnabled()
393                    .map_err(|e| UiaError::Com(e.to_string()))?;
394                Ok(b.as_bool())
395            }
396        }
397
398        pub fn is_offscreen(&self) -> super::Result<bool> {
399            unsafe {
400                let b = self
401                    .el
402                    .CurrentIsOffscreen()
403                    .map_err(|e| UiaError::Com(e.to_string()))?;
404                Ok(b.as_bool())
405            }
406        }
407
408        pub fn get_class_name(&self) -> super::Result<String> {
409            unsafe {
410                let bstr = self
411                    .el
412                    .CurrentClassName()
413                    .map_err(|e| UiaError::Com(e.to_string()))?;
414                Ok(bstr.to_string())
415            }
416        }
417
418        pub fn select_item(&self, item_name: &str) -> super::Result<()> {
419            use windows::Win32::UI::Accessibility::{
420                IUIAutomationExpandCollapsePattern, IUIAutomationSelectionItemPattern,
421                UIA_ExpandCollapsePatternId, UIA_SelectionItemPatternId,
422            };
423            unsafe {
424                // Try to expand (ComboBox) — ignore error if not applicable.
425                if let Ok(p) = self.el.GetCurrentPattern(UIA_ExpandCollapsePatternId) {
426                    if let Ok(ecp) = p.cast::<IUIAutomationExpandCollapsePattern>() {
427                        let _ = ecp.Expand();
428                    }
429                }
430                let true_cond = self
431                    .automation
432                    .CreateTrueCondition()
433                    .map_err(|e| UiaError::Com(e.to_string()))?;
434                let el_array = self
435                    .el
436                    .FindAll(TreeScope_Descendants, &true_cond)
437                    .map_err(|e| UiaError::Com(e.to_string()))?;
438                let count = el_array
439                    .Length()
440                    .map_err(|e| UiaError::Com(e.to_string()))?;
441                for i in 0..count {
442                    let child = el_array
443                        .GetElement(i)
444                        .map_err(|e| UiaError::Com(e.to_string()))?;
445                    let name = child
446                        .CurrentName()
447                        .map_err(|e| UiaError::Com(e.to_string()))?;
448                    if name == item_name {
449                        if let Ok(p) = child.GetCurrentPattern(UIA_SelectionItemPatternId) {
450                            let sip = p
451                                .cast::<IUIAutomationSelectionItemPattern>()
452                                .map_err(|e| UiaError::Com(e.to_string()))?;
453                            sip.Select().map_err(|e| UiaError::Com(e.to_string()))?;
454                            return Ok(());
455                        }
456                    }
457                }
458                Err(UiaError::NotFound(format!("item '{item_name}'")))
459            }
460        }
461
462        pub fn set_checked(&self, checked: bool) -> super::Result<()> {
463            use windows::Win32::UI::Accessibility::{
464                IUIAutomationTogglePattern, ToggleState_On, UIA_TogglePatternId,
465            };
466            unsafe {
467                let p = self
468                    .el
469                    .GetCurrentPattern(UIA_TogglePatternId)
470                    .map_err(|e| UiaError::Com(e.to_string()))?;
471                let tp = p
472                    .cast::<IUIAutomationTogglePattern>()
473                    .map_err(|e| UiaError::Com(e.to_string()))?;
474                let state = tp
475                    .CurrentToggleState()
476                    .map_err(|e| UiaError::Com(e.to_string()))?;
477                let is_on = state == ToggleState_On;
478                if is_on != checked {
479                    tp.Toggle().map_err(|e| UiaError::Com(e.to_string()))?;
480                }
481                Ok(())
482            }
483        }
484    }
485}