Skip to main content

automata_windows/
desktop.rs

1use super::{UIElement, UiError};
2
3/// Entry point for UI automation: enumerate windows and launch applications.
4pub struct Desktop {
5    #[cfg(target_os = "windows")]
6    browser: crate::browser::WindowsBrowser,
7}
8
9impl Desktop {
10    pub fn new() -> Self {
11        Desktop {
12            #[cfg(target_os = "windows")]
13            browser: crate::browser::WindowsBrowser::new(automata_browser::DEFAULT_PORT),
14        }
15    }
16}
17
18impl Default for Desktop {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24// ── Non-Windows stub ──────────────────────────────────────────────────────────
25
26#[cfg(not(target_os = "windows"))]
27impl Desktop {
28    pub fn application_windows(&self) -> Result<Vec<UIElement>, UiError> {
29        Err(UiError::Platform("Windows only".into()))
30    }
31
32    pub fn open_application(&self, _exe: &str) -> Result<u32, UiError> {
33        Err(UiError::Platform("Windows only".into()))
34    }
35}
36
37#[cfg(not(target_os = "windows"))]
38impl ui_automata::Desktop for Desktop {
39    type Elem = UIElement;
40    type Browser = crate::element::NoBrowser;
41
42    fn browser(&self) -> &crate::element::NoBrowser {
43        unimplemented!("Browser not supported on this platform")
44    }
45
46    fn application_windows(&self) -> Result<Vec<UIElement>, ui_automata::AutomataError> {
47        Err(ui_automata::AutomataError::Platform("Windows only".into()))
48    }
49
50    fn open_application(&self, _exe: &str) -> Result<u32, ui_automata::AutomataError> {
51        Err(ui_automata::AutomataError::Platform("Windows only".into()))
52    }
53
54    fn foreground_window(&self) -> Option<UIElement> {
55        None
56    }
57
58    fn foreground_hwnd(&self) -> Option<u64> {
59        None
60    }
61}
62
63// ── Windows implementation ────────────────────────────────────────────────────
64
65#[cfg(target_os = "windows")]
66impl Desktop {
67    /// Return all top-level Window/Pane elements visible to UIA.
68    pub fn application_windows(&self) -> Result<Vec<UIElement>, UiError> {
69        use crate::util::window_pane_condition;
70        use uiautomation::UIAutomation;
71        use uiautomation::types::TreeScope;
72
73        let auto = UIAutomation::new_direct().map_err(|e| UiError::Platform(e.to_string()))?;
74        let root = auto
75            .get_root_element()
76            .map_err(|e| UiError::Platform(e.to_string()))?;
77        let cond = window_pane_condition(&auto).map_err(|e| UiError::Platform(e.to_string()))?;
78        root.find_all(TreeScope::Children, &cond)
79            .map_err(|e| UiError::Platform(e.to_string()))
80            .map(|v| v.into_iter().map(UIElement::new).collect())
81    }
82
83    /// Launch an application and return its process ID.
84    ///
85    /// Launch strategies tried in order:
86    ///
87    /// 1. **Start Menu AppID** (`{GUID}\path\to\App.exe`):
88    ///    `IApplicationActivationManager::ActivateApplication`, then `shell:AppsFolder\{id}`.
89    ///
90    /// 2. **Bare exe name** (no path separators, ends with `.exe`):
91    ///    a. `App Paths` registry lookup → `CreateProcessW` with resolved path.
92    ///    b. Start Menu enumeration — resolve `.lnk` shortcut to full path → `CreateProcessW`.
93    ///
94    /// 3. **Full path or URI**:
95    ///    `CreateProcessW` directly.
96    pub fn open_application(&self, app_id: &str) -> Result<u32, UiError> {
97        // ── Strategy 1a: GUID-style Start Menu AppID ({GUID}\...) ─────────────
98        if app_id.starts_with('{') && app_id.contains('\\') {
99            if let Ok(pid) = self.activate_via_com(app_id) {
100                return Ok(pid);
101            }
102            let shell_path = format!("shell:AppsFolder\\{app_id}");
103            return self.shell_open(&shell_path);
104        }
105
106        // ── Strategy 1b: UWP Package!App format ───────────────────────────────
107        // e.g. "Microsoft.WindowsStore_8wekyb3d8bbwe!App"
108        if app_id.contains('!') && !app_id.contains(['/', '\\']) {
109            if let Ok(pid) = self.activate_via_com(app_id) {
110                return Ok(pid);
111            }
112            // Fallback: try shell:AppsFolder\ prefix
113            let shell_path = format!("shell:AppsFolder\\{app_id}");
114            return self.shell_open(&shell_path);
115        }
116
117        // ── Strategy 2: bare exe name ─────────────────────────────────────────
118        let is_bare = !app_id.contains(['/', '\\']) && app_id.to_lowercase().ends_with(".exe");
119        if is_bare {
120            // 2a. App Paths registry
121            if let Some(full_path) = self.resolve_app_path(app_id) {
122                return self.shell_execute(&full_path);
123            }
124            // 2b. Start Menu: resolve .lnk to full path, then launch directly
125            if let Some(full_path) = self.find_start_menu_app(app_id) {
126                return self.shell_execute(&full_path);
127            }
128        }
129
130        // ── Strategy 3: URI scheme ────────────────────────────────────────────
131        // Explicit URI: colon present with 2+ char scheme prefix, no path separators.
132        // Single-char prefix = drive letter (C:\...).
133        let is_explicit_uri = app_id
134            .find(':')
135            .map(|i| i >= 2 && !app_id[..i].contains(['/', '\\']))
136            .unwrap_or(false);
137        if is_explicit_uri {
138            return self.shell_open(app_id);
139        }
140
141        // Bare scheme name (e.g. "ms-windows-store", "ms-settings"): no colon,
142        // no path separators, no dots, no .exe — treat as URI by appending ':'.
143        let is_bare_scheme = !app_id.contains(['.', '/', '\\', ':'])
144            && app_id
145                .chars()
146                .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
147        if is_bare_scheme {
148            let uri = format!("{app_id}:");
149            return self.shell_open(&uri);
150        }
151
152        // ── Strategy 4: full executable path ─────────────────────────────────
153        self.shell_execute(app_id)
154    }
155
156    /// Search Start Menu entries (both system and user) for one whose AppID
157    /// file stem matches `exe_name` (case-insensitive). Returns the AppID.
158    fn find_start_menu_app(&self, exe_name: &str) -> Option<String> {
159        let needle = exe_name.to_lowercase();
160        for root in self.start_menu_roots() {
161            if let Some(id) = Self::search_start_menu_dir(&root, &needle) {
162                return Some(id);
163            }
164        }
165        None
166    }
167
168    /// Enumerate `.lnk` shortcuts under `dir` recursively, resolving each to
169    /// its target path. Returns the AppID of the first entry whose filename
170    /// (lowercased) matches `needle`.
171    fn search_start_menu_dir(dir: &std::path::Path, needle: &str) -> Option<String> {
172        let entries = std::fs::read_dir(dir).ok()?;
173        for entry in entries.flatten() {
174            let path = entry.path();
175            if path.is_dir() {
176                if let Some(id) = Self::search_start_menu_dir(&path, needle) {
177                    return Some(id);
178                }
179            } else if path.extension().and_then(|e| e.to_str()) == Some("lnk") {
180                if let Some(target) = Self::resolve_lnk(&path) {
181                    let stem = std::path::Path::new(&target)
182                        .file_name()
183                        .and_then(|n| n.to_str())
184                        .unwrap_or("")
185                        .to_lowercase();
186                    if stem == needle {
187                        return Some(target);
188                    }
189                }
190            }
191        }
192        None
193    }
194
195    /// Resolve a `.lnk` shell shortcut to its target path using `IShellLink`.
196    fn resolve_lnk(lnk: &std::path::Path) -> Option<String> {
197        use windows::Win32::Foundation::HWND;
198        use windows::Win32::System::Com::{
199            CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx,
200            IPersistFile,
201        };
202        use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
203        use windows::core::{HRESULT, Interface, PCWSTR};
204
205        unsafe {
206            let hr = CoInitializeEx(None, COINIT_MULTITHREADED);
207            if hr.is_err() && hr != HRESULT(0x80010106u32 as i32) {
208                return None;
209            }
210            let shell_link: IShellLinkW =
211                CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).ok()?;
212            let persist: IPersistFile = shell_link.cast().ok()?;
213            let path_wide: Vec<u16> = lnk
214                .to_string_lossy()
215                .encode_utf16()
216                .chain(std::iter::once(0))
217                .collect();
218            use windows::Win32::System::Com::STGM;
219            persist.Load(PCWSTR(path_wide.as_ptr()), STGM(0)).ok()?;
220            shell_link.Resolve(HWND(std::ptr::null_mut()), 0).ok()?;
221            let mut buf = vec![0u16; 260];
222            shell_link.GetPath(&mut buf, std::ptr::null_mut(), 0).ok()?;
223            let nul = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
224            Some(String::from_utf16_lossy(&buf[..nul]))
225        }
226    }
227
228    /// Return Start Menu program folders to search (system then user).
229    fn start_menu_roots(&self) -> Vec<std::path::PathBuf> {
230        use windows::Win32::UI::Shell::{
231            FOLDERID_CommonPrograms, FOLDERID_Programs, KF_FLAG_DEFAULT, SHGetKnownFolderPath,
232        };
233        let mut roots = Vec::new();
234        for folder_id in [&FOLDERID_CommonPrograms, &FOLDERID_Programs] {
235            unsafe {
236                if let Ok(p) = SHGetKnownFolderPath(folder_id, KF_FLAG_DEFAULT, None) {
237                    let wide = p.as_wide();
238                    let path = String::from_utf16_lossy(wide);
239                    roots.push(std::path::PathBuf::from(path));
240                }
241            }
242        }
243        roots
244    }
245
246    /// Look up a bare exe name in `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths`.
247    /// Returns the full path if registered, or `None`.
248    fn resolve_app_path(&self, exe: &str) -> Option<String> {
249        use windows::Win32::System::Registry::{HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ, RegGetValueW};
250
251        let subkey: Vec<u16> =
252            format!("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{exe}\0")
253                .encode_utf16()
254                .collect();
255
256        let mut buf = vec![0u16; 512];
257        let mut len = (buf.len() * 2) as u32;
258
259        let ok = unsafe {
260            RegGetValueW(
261                HKEY_LOCAL_MACHINE,
262                windows::core::PCWSTR(subkey.as_ptr()),
263                None,
264                RRF_RT_REG_SZ,
265                None,
266                Some(buf.as_mut_ptr() as *mut _),
267                Some(&mut len),
268            )
269        };
270
271        if ok.is_ok() {
272            let nul = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
273            Some(String::from_utf16_lossy(&buf[..nul]))
274        } else {
275            None
276        }
277    }
278
279    /// Launch a Start Menu app via COM `IApplicationActivationManager`.
280    fn activate_via_com(&self, app_id: &str) -> Result<u32, UiError> {
281        use windows::Win32::System::Com::{
282            CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx,
283        };
284        use windows::Win32::UI::Shell::{
285            ACTIVATEOPTIONS, ApplicationActivationManager, IApplicationActivationManager,
286        };
287        use windows::core::{HRESULT, HSTRING};
288
289        unsafe {
290            let hr = CoInitializeEx(None, COINIT_MULTITHREADED);
291            // 0x80010106 = RPC_E_CHANGED_MODE: COM already initialized on this thread
292            if hr.is_err() && hr != HRESULT(0x80010106u32 as i32) {
293                return Err(UiError::Platform(format!("CoInitializeEx failed: {hr}")));
294            }
295
296            let manager: IApplicationActivationManager =
297                CoCreateInstance(&ApplicationActivationManager, None, CLSCTX_ALL)
298                    .map_err(|e| UiError::Platform(format!("CoCreateInstance failed: {e}")))?;
299
300            let pid = manager
301                .ActivateApplication(
302                    &HSTRING::from(app_id),
303                    &HSTRING::from(""),
304                    ACTIVATEOPTIONS(0),
305                )
306                .map_err(|e| UiError::Platform(format!("ActivateApplication failed: {e}")))?;
307
308            Ok(pid)
309        }
310    }
311
312    /// Open a URI, shell path, or executable via `ShellExecuteW` with verb "open".
313    /// Use this for URI schemes (`ms-windows-store:`, `ms-settings:`, `shell:AppsFolder\...`)
314    /// and anything that needs the shell's association table. Returns 0 as PID (not available).
315    fn shell_open(&self, target: &str) -> Result<u32, UiError> {
316        use windows::Win32::UI::Shell::ShellExecuteW;
317        use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
318        use windows::core::PCWSTR;
319
320        let verb: Vec<u16> = "open\0".encode_utf16().collect();
321        let file: Vec<u16> = target.encode_utf16().chain(std::iter::once(0)).collect();
322
323        let result = unsafe {
324            ShellExecuteW(
325                None,
326                PCWSTR(verb.as_ptr()),
327                PCWSTR(file.as_ptr()),
328                PCWSTR::null(),
329                PCWSTR::null(),
330                SW_SHOWNORMAL,
331            )
332        };
333
334        // ShellExecuteW returns an HINSTANCE; values > 32 indicate success.
335        let code = result.0 as usize;
336        if code <= 32 {
337            return Err(UiError::Platform(format!(
338                "ShellExecuteW failed for '{target}': error code {code}"
339            )));
340        }
341        Ok(0) // PID not available via ShellExecuteW
342    }
343
344    /// Launch an executable via `CreateProcessW` (searches PATH, no UAC prompt for normal apps).
345    fn shell_execute(&self, target: &str) -> Result<u32, UiError> {
346        use windows::Win32::Foundation::CloseHandle;
347        use windows::Win32::System::Threading::CREATE_NO_WINDOW;
348        use windows::Win32::System::Threading::{
349            CreateProcessW, PROCESS_INFORMATION, STARTUPINFOW,
350        };
351
352        let mut cmdline: Vec<u16> = target.encode_utf16().chain(std::iter::once(0)).collect();
353        let startup_info = STARTUPINFOW {
354            cb: std::mem::size_of::<STARTUPINFOW>() as u32,
355            ..Default::default()
356        };
357        let mut process_info = PROCESS_INFORMATION::default();
358
359        unsafe {
360            CreateProcessW(
361                None,
362                Some(windows::core::PWSTR::from_raw(cmdline.as_mut_ptr())),
363                None,
364                None,
365                false,
366                CREATE_NO_WINDOW,
367                None,
368                None,
369                &startup_info,
370                &mut process_info,
371            )
372            .map_err(|e| UiError::Platform(format!("CreateProcessW failed for '{target}': {e}")))?;
373
374            let pid = process_info.dwProcessId;
375            let _ = CloseHandle(process_info.hProcess);
376            let _ = CloseHandle(process_info.hThread);
377            Ok(pid)
378        }
379    }
380}
381
382#[cfg(target_os = "windows")]
383impl ui_automata::Desktop for Desktop {
384    type Elem = UIElement;
385    type Browser = crate::browser::WindowsBrowser;
386
387    fn browser(&self) -> &crate::browser::WindowsBrowser {
388        &self.browser
389    }
390
391    fn application_windows(&self) -> Result<Vec<UIElement>, ui_automata::AutomataError> {
392        self.application_windows().map_err(Into::into)
393    }
394
395    fn open_application(&self, exe: &str) -> Result<u32, ui_automata::AutomataError> {
396        self.open_application(exe).map_err(Into::into)
397    }
398
399    fn foreground_window(&self) -> Option<UIElement> {
400        use uiautomation::UIAutomation;
401        use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
402
403        let hwnd = unsafe { GetForegroundWindow() };
404        if hwnd.is_invalid() {
405            return None;
406        }
407        let auto = UIAutomation::new_direct().ok()?;
408        auto.element_from_handle(hwnd.into())
409            .ok()
410            .map(UIElement::new)
411    }
412
413    fn foreground_hwnd(&self) -> Option<u64> {
414        use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
415        let hwnd = unsafe { GetForegroundWindow() };
416        if hwnd.is_invalid() {
417            None
418        } else {
419            Some(hwnd.0 as u64)
420        }
421    }
422
423    fn hwnd_z_order(&self) -> Vec<u64> {
424        use windows::Win32::UI::WindowsAndMessaging::{
425            GW_HWNDNEXT, GetTopWindow, GetWindow, IsWindowVisible,
426        };
427
428        let mut result = Vec::new();
429        unsafe {
430            // None = desktop root; returns the topmost top-level window (Z-order head).
431            let mut hwnd = GetTopWindow(None);
432            while let Ok(h) = hwnd {
433                if !h.is_invalid() {
434                    if IsWindowVisible(h).as_bool() {
435                        result.push(h.0 as usize as u64);
436                    }
437                    hwnd = GetWindow(h, GW_HWNDNEXT);
438                } else {
439                    break;
440                }
441            }
442        }
443        result
444    }
445
446    fn tooltip_windows(&self) -> Vec<UIElement> {
447        use uiautomation::UIAutomation;
448        use uiautomation::controls::ControlType;
449        use uiautomation::types::{TreeScope, UIProperty};
450        use uiautomation::variants::Variant;
451
452        let Ok(auto) = UIAutomation::new_direct() else {
453            return vec![];
454        };
455        let Ok(root) = auto.get_root_element() else {
456            return vec![];
457        };
458        let Ok(cond) = auto.create_property_condition(
459            UIProperty::ControlType,
460            Variant::from(ControlType::ToolTip as i32),
461            None,
462        ) else {
463            return vec![];
464        };
465        root.find_all(TreeScope::Children, &cond)
466            .unwrap_or_default()
467            .into_iter()
468            .map(UIElement::new)
469            .collect()
470    }
471}