use actl_core::{CtlError, ErrorCode, UiNode};
use uiautomation::types::ControlType;
use uiautomation::{UIAutomation, UIElement, UITreeWalker};
use crate::{internal, timing};
pub const MAX_ELEMENTS: usize = 5000;
pub const MAX_DEPTH: u32 = 40;
pub struct WindowInfo {
pub title: String,
pub class: String,
pub pid: u32,
}
pub struct CaptureResult {
pub window: WindowInfo,
pub window_runtime_id: Vec<i32>,
pub nodes: Vec<UiNode>,
pub truncated: bool,
pub wake_ms: u32,
}
pub fn capture(app: Option<&str>) -> Result<CaptureResult, CtlError> {
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let target = {
let _t = actl_core::trace::scope("capture.window");
match app {
Some(pattern) => find_window(&auto, &walker, pattern)?,
None => top_level_of_focused(&auto, &walker)?,
}
};
let window = WindowInfo {
title: target.get_name().unwrap_or_default(),
class: target.get_classname().unwrap_or_default(),
pid: target.get_process_id().unwrap_or_default(),
};
let wake_ms = {
let _t = actl_core::trace::scope("capture.wake");
ensure_window_responsive(&target)
};
let window_runtime_id = target.get_runtime_id().unwrap_or_default();
let mut nodes = Vec::new();
{
let _t = actl_core::trace::scope("capture.walk");
walk(&walker, &target, 0, None, &mut nodes);
}
let truncated = nodes.len() >= MAX_ELEMENTS;
Ok(CaptureResult {
window,
window_runtime_id,
nodes,
truncated,
wake_ms,
})
}
const SNAPSHOT_KEEP: usize = 20;
fn snapshot_dir() -> Option<std::path::PathBuf> {
let base = std::env::var("LOCALAPPDATA").ok()?;
let dir = std::path::Path::new(&base).join("actl").join("snapshots");
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
pub fn persist_snapshot(id: &str, window_title: &str, window_runtime_id: &[i32]) {
let Some(dir) = snapshot_dir() else { return };
let record = serde_json::json!({
"snapshot_id": id,
"window_title": window_title,
"window_runtime_id": window_runtime_id,
});
let _ = std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_string(&record).unwrap_or_default(),
);
if let Ok(entries) = std::fs::read_dir(&dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let m = e.metadata().ok()?;
let t = m.modified().ok()?;
Some((t, e.path()))
})
.collect();
files.sort();
let excess = files.len().saturating_sub(SNAPSHOT_KEEP);
for (_, path) in files.into_iter().take(excess) {
let _ = std::fs::remove_file(path);
}
}
}
fn load_snapshot(id: &str) -> Option<(String, Vec<i32>)> {
let dir = snapshot_dir()?;
let text = std::fs::read_to_string(dir.join(format!("{id}.json"))).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
let title = v["window_title"].as_str()?.to_string();
let rid = v["window_runtime_id"]
.as_array()?
.iter()
.filter_map(|x| x.as_i64().map(|n| n as i32))
.collect();
Some((title, rid))
}
pub fn check_snapshot_freshness(snapshot_id: &str, current: &UIElement) -> Result<(), CtlError> {
let Some((_, recorded)) = load_snapshot(snapshot_id) else {
return Err(CtlError::new(
ErrorCode::StaleRef,
format!("snapshot {snapshot_id:?} not found on disk (expired or different machine)"),
));
};
let now = current.get_runtime_id().unwrap_or_default();
if !recorded.is_empty() && now != recorded {
return Err(CtlError::new(
ErrorCode::StaleRef,
format!(
"window instance changed since snapshot {snapshot_id:?} (same-title window reopened); re-snapshot before replaying refs"
),
));
}
Ok(())
}
pub(crate) fn ensure_window_responsive(elem: &UIElement) -> u32 {
use windows::Win32::UI::WindowsAndMessaging::{
SMTO_ABORTIFHUNG, SW_SHOW, SendMessageTimeoutW, ShowWindowAsync, WM_NULL,
};
let Some(hwnd) = native_hwnd(elem) else {
return 0;
};
let pumps = |timeout_ms: u32| unsafe {
SendMessageTimeoutW(
hwnd,
WM_NULL,
windows::Win32::Foundation::WPARAM(0),
windows::Win32::Foundation::LPARAM(0),
SMTO_ABORTIFHUNG,
timeout_ms,
None,
)
.0 != 0
};
if pumps(timing().probe_ms) {
return 0;
}
let started = std::time::Instant::now();
unsafe {
let _ = ShowWindowAsync(hwnd, SW_SHOW);
}
while started.elapsed() < std::time::Duration::from_millis(timing().wake_bound_ms) {
std::thread::sleep(std::time::Duration::from_millis(timing().poll_ms));
if pumps(60) {
break;
}
}
started.elapsed().as_millis() as u32
}
pub(crate) fn window_title_of(hwnd: windows::Win32::Foundation::HWND) -> Option<String> {
use windows::Win32::UI::WindowsAndMessaging::GetWindowTextW;
let mut buf = [0u16; 256];
let len = unsafe { GetWindowTextW(hwnd, &mut buf) };
if len <= 0 {
return None;
}
Some(String::from_utf16_lossy(&buf[..len as usize]))
}
pub(crate) fn native_hwnd(elem: &UIElement) -> Option<windows::Win32::Foundation::HWND> {
let handle = elem.get_native_window_handle().ok()?;
if handle.is_invalid() {
return None;
}
let raw: windows::Win32::Foundation::HANDLE = unsafe { std::mem::transmute(handle) };
Some(windows::Win32::Foundation::HWND(raw.0))
}
pub(crate) fn top_windows(auto: &UIAutomation, walker: &UITreeWalker) -> Vec<UIElement> {
let mut out = Vec::new();
for w in root_children(auto, walker) {
out.push(w.clone());
out.extend(owned_dialogs_of(walker, &w));
}
out
}
fn root_children(auto: &UIAutomation, walker: &UITreeWalker) -> Vec<UIElement> {
let mut out = Vec::new();
let Ok(root) = auto.get_root_element() else {
return out;
};
let mut child = walker.get_first_child(&root).ok();
while let Some(w) = child {
out.push(w.clone());
child = walker.get_next_sibling(&w).ok();
}
out
}
fn owned_dialogs_of(walker: &UITreeWalker, host: &UIElement) -> Vec<UIElement> {
let mut out = Vec::new();
let mut sub = walker.get_first_child(host).ok();
while let Some(d) = sub {
if d.get_control_type()
.map(|t| t == ControlType::Window)
.unwrap_or(false)
{
out.push(d.clone());
}
sub = walker.get_next_sibling(&d).ok();
}
out
}
fn scan_top_window(auto: &UIAutomation, walker: &UITreeWalker, pattern: &str) -> Option<UIElement> {
top_windows(auto, walker)
.into_iter()
.find(|w| name_matches(w, pattern))
}
fn name_matches(elem: &UIElement, pattern: &str) -> bool {
elem.get_name()
.map(|n| n.contains(pattern))
.unwrap_or(false)
}
const WINUI_AUX_CLASSES: [&str; 2] = ["ApplicationFrameWindow", "ApplicationFrameTitleBarWindow"];
pub(crate) fn find_window(
auto: &UIAutomation,
walker: &UITreeWalker,
pattern: &str,
) -> Result<UIElement, CtlError> {
let mut matches: Vec<UIElement> = root_children(auto, walker)
.into_iter()
.filter(|w| name_matches(w, pattern))
.collect();
if matches.is_empty() {
for host in root_children(auto, walker) {
matches.extend(
owned_dialogs_of(walker, &host)
.into_iter()
.filter(|w| name_matches(w, pattern)),
);
}
}
let has_primary = matches.iter().any(|w| {
let cls = w.get_classname().unwrap_or_default();
!WINUI_AUX_CLASSES.contains(&cls.as_str())
});
let pool: Vec<UIElement> = if has_primary {
matches
.into_iter()
.filter(|w| {
let cls = w.get_classname().unwrap_or_default();
!WINUI_AUX_CLASSES.contains(&cls.as_str())
})
.collect()
} else {
matches
};
match pool.len() {
0 => Err(CtlError::new(
ErrorCode::NotFound,
format!("no top-level window title contains {pattern:?}"),
)),
1 => Ok(pool.into_iter().next().expect("len == 1")),
n => {
let titles: Vec<String> = pool
.iter()
.filter_map(|w| w.get_name().ok())
.take(5)
.collect();
Err(CtlError::new(
ErrorCode::Ambiguous,
format!(
"pattern {pattern:?} matches {n} windows: {titles:?}; tighten the selector"
),
))
}
}
}
pub(crate) fn top_level_of_focused(
auto: &UIAutomation,
walker: &UITreeWalker,
) -> Result<UIElement, CtlError> {
let mut cur = auto.get_focused_element().map_err(internal)?;
loop {
match walker.get_parent(&cur) {
Ok(p) => cur = p,
Err(_) => return Ok(cur), }
}
}
fn walk(
walker: &UITreeWalker,
elem: &UIElement,
depth: u32,
parent: Option<usize>,
nodes: &mut Vec<UiNode>,
) {
if nodes.len() >= MAX_ELEMENTS || depth > MAX_DEPTH {
return;
}
nodes.push(to_node(elem, depth, parent));
let idx = nodes.len() - 1;
let mut child = walker.get_first_child(elem).ok();
while let Some(c) = child {
walk(walker, &c, depth + 1, Some(idx), nodes);
child = walker.get_next_sibling(&c).ok();
}
}
fn to_node(elem: &UIElement, depth: u32, parent: Option<usize>) -> UiNode {
UiNode {
depth,
role: format!(
"{:?}",
elem.get_control_type().unwrap_or(ControlType::Custom)
),
name: elem.get_name().ok().filter(|n| !n.is_empty()),
automation_id: elem.get_automation_id().ok().filter(|a| !a.is_empty()),
parent,
}
}
pub fn wait_window(substr: &str, timeout_ms: u64) -> Result<String, CtlError> {
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
if let Some(title) = scan_top_windows(&auto, &walker, substr) {
return Ok(title);
}
if std::time::Instant::now() >= deadline {
return Err(CtlError::new(
ErrorCode::AssertionFailed,
format!(
"expected window containing {substr:?} did not appear within {timeout_ms}ms"
),
));
}
std::thread::sleep(std::time::Duration::from_millis(timing().poll_ms));
}
}
pub fn window_identities() -> Result<Vec<Vec<i32>>, CtlError> {
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
Ok(top_windows(&auto, &walker)
.into_iter()
.filter_map(|w| w.get_runtime_id().ok())
.collect())
}
pub fn wait_new_window(
substr: &str,
timeout_ms: u64,
baseline: &[Vec<i32>],
) -> Result<String, CtlError> {
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
if let Some(title) = top_windows(&auto, &walker)
.into_iter()
.filter(|w| name_matches(w, substr))
.find(|w| {
w.get_runtime_id()
.map(|id| !baseline.contains(&id))
.unwrap_or(false) })
.and_then(|w| w.get_name().ok())
{
return Ok(title);
}
if std::time::Instant::now() >= deadline {
return Err(CtlError::new(
ErrorCode::AssertionFailed,
format!(
"no NEW window containing {substr:?} appeared within {timeout_ms}ms \
(a pre-existing window with that title does not satisfy --expect)"
),
));
}
std::thread::sleep(std::time::Duration::from_millis(timing().poll_ms));
}
}
fn scan_top_windows(auto: &UIAutomation, walker: &UITreeWalker, substr: &str) -> Option<String> {
scan_top_window(auto, walker, substr).and_then(|w| w.get_name().ok())
}
pub fn focus_window(pattern: &str) -> Result<String, CtlError> {
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let win = find_window(&auto, &walker, pattern)?;
win.set_focus().map_err(|e| {
CtlError::new(
ErrorCode::NotActionable,
format!("cannot focus window {pattern:?}: {e:?}"),
)
})?;
Ok(win.get_name().unwrap_or_default())
}
#[derive(Debug, Clone)]
pub struct WindowEntry {
pub title: String,
pub class: String,
pub pid: u32,
pub foreground: bool,
pub topmost: bool,
pub minimized: bool,
pub visible: bool,
pub z_order: Option<u32>,
pub rect: Option<[i32; 4]>,
pub owner_title: Option<String>,
}
pub fn list_windows() -> Result<Vec<WindowEntry>, CtlError> {
use std::collections::HashMap;
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::{
GA_ROOT, GW_HWNDNEXT, GW_OWNER, GWL_EXSTYLE, GetAncestor, GetForegroundWindow,
GetTopWindow, GetWindow, GetWindowLongW, GetWindowRect, IsIconic, IsWindowVisible,
WS_EX_TOPMOST,
};
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let mut z_rank: HashMap<usize, u32> = HashMap::new();
unsafe {
let null = HWND::default();
let mut h = GetTopWindow(None).unwrap_or(null);
let mut i = 0u32;
while !h.0.is_null() {
z_rank.insert(h.0 as usize, i);
i += 1;
h = GetWindow(h, GW_HWNDNEXT).unwrap_or(null);
}
}
let (fg_root, fg_title) = unsafe {
let fg = GetForegroundWindow();
let root = GetAncestor(fg, GA_ROOT);
(
if root.0.is_null() { fg } else { root },
window_title_of(fg).unwrap_or_default(),
)
};
let rank_of_root = |hwnd: HWND| -> Option<u32> {
let root = unsafe { GetAncestor(hwnd, GA_ROOT) };
let key = if root.0.is_null() { hwnd } else { root }.0 as usize;
z_rank.get(&key).copied()
};
Ok(top_windows(&auto, &walker)
.into_iter()
.filter_map(|w| {
let title = w.get_name().ok()?;
if title.is_empty() {
return None;
}
let hwnd = native_hwnd(&w);
let (foreground, topmost, minimized, visible, z_order, rect, owner_title) = match hwnd {
Some(h) => unsafe {
let root = GetAncestor(h, GA_ROOT);
let root = if root.0.is_null() { h } else { root };
let is_fg = root.0 == fg_root.0
|| window_title_of(h)
.zip(Some(fg_title.clone()))
.is_some_and(|(t, f)| !f.is_empty() && t == f);
let ex_style = GetWindowLongW(h, GWL_EXSTYLE);
let mut r = RECT::default();
let has_rect = GetWindowRect(h, &mut r).is_ok();
let owner = GetWindow(h, GW_OWNER).unwrap_or_default();
(
is_fg,
(ex_style as u32) & WS_EX_TOPMOST.0 != 0,
IsIconic(h).as_bool(),
IsWindowVisible(h).as_bool(),
rank_of_root(h),
has_rect.then_some([r.left, r.top, r.right, r.bottom]),
(!owner.0.is_null())
.then(|| window_title_of(owner).unwrap_or_default())
.filter(|t| !t.is_empty()),
)
},
None => (false, false, false, true, None, None, None),
};
Some(WindowEntry {
title,
class: w.get_classname().unwrap_or_default(),
pid: w.get_process_id().unwrap_or_default(),
foreground,
topmost,
minimized,
visible,
z_order,
rect,
owner_title,
})
})
.collect())
}
pub fn close_window(pattern: &str) -> Result<String, CtlError> {
use windows::Win32::UI::WindowsAndMessaging::{PostMessageW, WM_CLOSE};
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let win = find_window(&auto, &walker, pattern)?;
let title = win.get_name().unwrap_or_default();
let Some(hwnd) = native_hwnd(&win) else {
return Err(CtlError::new(
ErrorCode::NotActionable,
format!("window {pattern:?} exposes no native handle"),
));
};
unsafe {
PostMessageW(
Some(hwnd),
WM_CLOSE,
windows::Win32::Foundation::WPARAM(0),
windows::Win32::Foundation::LPARAM(0),
)
}
.map_err(|e| CtlError::internal(format!("PostMessageW(WM_CLOSE) failed for {title:?}: {e}")))?;
Ok(title)
}
pub fn resize_window(pattern: &str, width: i32, height: i32) -> Result<String, CtlError> {
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowRect, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOZORDER, SetWindowPos,
};
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let win = find_window(&auto, &walker, pattern)?;
let title = win.get_name().unwrap_or_default();
let Some(hwnd) = native_hwnd(&win) else {
return Err(CtlError::new(
ErrorCode::NotActionable,
format!("window {pattern:?} has no native handle"),
));
};
let mut rect = RECT::default();
unsafe { GetWindowRect(hwnd, &mut rect) }
.map_err(|e| CtlError::internal(format!("GetWindowRect: {e}")))?;
let w = width.max(120);
let h = height.max(120);
unsafe {
SetWindowPos(
hwnd,
Some(HWND(std::ptr::null_mut())),
rect.left,
rect.top,
w,
h,
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE,
)
}
.map_err(|e| CtlError::internal(format!("SetWindowPos: {e}")))?;
Ok(title)
}
pub fn check_snapshot_freshness_by_pattern(
snapshot_id: &str,
pattern: &str,
) -> Result<(), CtlError> {
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let win = find_window(&auto, &walker, pattern)?;
check_snapshot_freshness(snapshot_id, &win)
}