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 element_identities: Vec<serde_json::Value>,
pub truncated: bool,
pub truncation_reasons: Vec<&'static str>,
pub wake_ms: u32,
}
impl CaptureResult {
pub fn require_complete(&self) -> Result<(), CtlError> {
if self.truncated {
Err(crate::observation::incomplete(&self.truncation_reasons))
} else {
Ok(())
}
}
}
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()
.map_err(|e| crate::read_channel::failure("window.name", e))?,
class: target
.get_classname()
.map_err(|e| crate::read_channel::failure("window.class", e))?,
pid: target
.get_process_id()
.map_err(|e| crate::read_channel::failure("window.pid", e))?,
};
let wake_ms = {
let _t = actl_core::trace::scope("capture.wake");
ensure_window_responsive(&target)
};
let window_runtime_id = target
.get_runtime_id()
.map_err(|e| crate::read_channel::failure("window.runtime_id", e))?;
let mut nodes = Vec::new();
let mut element_identities = Vec::new();
let truncation_reasons = {
let _t = actl_core::trace::scope("capture.walk");
walk(
&walker,
&target,
0,
None,
&mut nodes,
&mut element_identities,
)?
};
let truncated = !truncation_reasons.is_empty();
Ok(CaptureResult {
window,
window_runtime_id,
nodes,
element_identities,
truncated,
truncation_reasons,
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],
elements: &[serde_json::Value],
) -> Result<(), CtlError> {
persist_snapshot_scoped(id, window_title, window_runtime_id, elements, None)
}
pub fn persist_snapshot_scoped(
id: &str,
window_title: &str,
window_runtime_id: &[i32],
elements: &[serde_json::Value],
scope: Option<&serde_json::Value>,
) -> Result<(), CtlError> {
let dir = snapshot_dir().ok_or_else(|| crate::internal("snapshot directory unavailable"))?;
let mut record = serde_json::json!({
"snapshot_id": id,
"window_title": window_title,
"window_runtime_id": window_runtime_id,
"elements": elements,
});
if let Some(scope) = scope {
record["scope"] = scope.clone();
}
std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_vec(&record).map_err(crate::internal)?,
)
.map_err(crate::internal)?;
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);
}
}
Ok(())
}
pub(crate) fn load_snapshot_record(id: &str) -> Option<serde_json::Value> {
if id.is_empty()
|| !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return None;
}
let dir = snapshot_dir()?;
let text = std::fs::read_to_string(dir.join(format!("{id}.json"))).ok()?;
serde_json::from_str(&text).ok()
}
fn load_snapshot(id: &str) -> Option<(String, Vec<i32>)> {
let v = load_snapshot_record(id)?;
let rid = v["window_runtime_id"]
.as_array()?
.iter()
.map(|x| x.as_i64().and_then(|n| i32::try_from(n).ok()))
.collect::<Option<Vec<_>>>()?;
Some((v["window_title"].as_str()?.to_string(), 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 !runtime_ids_match(&recorded, &now) {
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, SendMessageTimeoutW, 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();
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(timing().probe_ms) {
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,
) -> Result<Vec<UIElement>, CtlError> {
let roots = crate::observation::desktop_children(auto)?;
let roots = crate::window_observation::select(roots, |w| {
let hwnd = w
.get_native_window_handle()
.map_err(|e| crate::read_channel::failure("window.hwnd", e))?;
Ok(!crate::display_support::is_display_window(hwnd.into()))
})?;
crate::window_observation::inventory(Ok(roots), crate::observation::children, |w| {
w.get_control_type()
.map(|t| t == ControlType::Window)
.map_err(|e| crate::read_channel::failure("window.role", e))
})
}
const WINUI_AUX_CLASSES: [&str; 2] = ["ApplicationFrameWindow", "ApplicationFrameTitleBarWindow"];
pub(crate) fn find_window(
auto: &UIAutomation,
_walker: &UITreeWalker,
pattern: &str,
) -> Result<UIElement, CtlError> {
if pattern.starts_with("hwnd:") {
return crate::scoped_locate::window(auto, Some(pattern));
}
let hosts = match crate::native_windows::title_elements(auto, pattern)? {
Some(windows) => windows,
None => crate::observation::desktop_children(auto)?,
};
let mut matches = Vec::new();
for w in &hosts {
if native_hwnd(w).is_some_and(crate::display_support::is_display_window) {
continue;
}
if w.get_name()
.map_err(|e| crate::read_channel::failure("window.name", e))?
.contains(pattern)
{
matches.push(w.clone());
}
}
if matches.is_empty() {
for host in hosts {
if native_hwnd(&host).is_some_and(crate::display_support::is_display_window) {
continue;
}
for w in crate::observation::children(&host)? {
if w.get_control_type()
.map_err(|e| crate::read_channel::failure("window.role", e))?
== ControlType::Window
&& w.get_name()
.map_err(|e| crate::read_channel::failure("window.name", e))?
.contains(pattern)
{
matches.push(w);
}
}
}
}
let classes: Vec<String> = matches
.iter()
.map(|w| {
w.get_classname()
.map_err(|e| crate::read_channel::failure("window.class", e))
})
.collect::<Result<_, _>>()?;
let has_primary = classes
.iter()
.any(|cls| !WINUI_AUX_CLASSES.contains(&cls.as_str()));
let pool: Vec<UIElement> = matches
.into_iter()
.zip(classes)
.filter(|(_, cls)| !has_primary || !WINUI_AUX_CLASSES.contains(&cls.as_str()))
.map(|(element, _)| element)
.collect();
match pool.len() {
0 => Err(CtlError::new(
ErrorCode::NotFound,
format!("no top-level window title contains {pattern:?}"),
)),
1 => pool
.into_iter()
.next()
.ok_or_else(|| CtlError::internal("window candidate missing")),
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)?;
for _ in 0..=MAX_DEPTH {
actl_core::wait_control::check()?;
match crate::observation::parent(walker, &cur)? {
Some(p) => cur = p,
None => return Ok(cur),
}
}
Err(crate::observation::incomplete(&["depth_limit"]))
}
fn walk(
_walker: &UITreeWalker,
elem: &UIElement,
_depth: u32,
_parent: Option<usize>,
nodes: &mut Vec<UiNode>,
identities: &mut Vec<serde_json::Value>,
) -> Result<Vec<&'static str>, CtlError> {
let observed = crate::observation::tree(elem)?;
append_observed(observed, nodes, identities)
}
pub(crate) fn append_observed(
observed: crate::observation::Observed<UIElement>,
nodes: &mut Vec<UiNode>,
identities: &mut Vec<serde_json::Value>,
) -> Result<Vec<&'static str>, CtlError> {
for entry in observed.entries {
let element = entry.element;
let node = UiNode {
depth: entry.depth,
role: format!(
"{:?}",
element
.get_control_type()
.map_err(|e| crate::read_channel::failure("control_type", e))?
),
name: Some(
element
.get_name()
.map_err(|e| crate::read_channel::failure("name", e))?,
),
automation_id: Some(
element
.get_automation_id()
.map_err(|e| crate::read_channel::failure("automation_id", e))?,
),
parent: entry.parent,
};
if actl_core::is_interactive_role(&node.role) {
identities.push(crate::identity::observed_identity(&element)?);
}
nodes.push(node);
}
Ok(observed.reasons)
}
pub fn wait_window(substr: &str, timeout_ms: u64) -> Result<String, CtlError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
actl_core::wait_control::check()?;
if let Some(window) = crate::native_windows()?
.into_iter()
.find(|w| w.visible && w.title.contains(substr))
{
return Ok(window.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)?;
top_windows(&auto, &walker)?
.into_iter()
.map(|w| {
w.get_runtime_id()
.map_err(|e| crate::read_channel::failure("window.runtime_id", e))
})
.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 {
actl_core::wait_control::check()?;
for window in top_windows(&auto, &walker)? {
let title = window
.get_name()
.map_err(|e| crate::read_channel::failure("window.name", e))?;
if title.contains(substr) {
let id = window
.get_runtime_id()
.map_err(|e| crate::read_channel::failure("window.runtime_id", e))?;
if !baseline.contains(&id) {
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));
}
}
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)?;
let mut action = crate::feedback::window_action("focus-window", &win, true)?;
crate::window_ready::activate(&win)?;
action.delivered();
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 hwnd: Option<isize>,
}
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 _dpi = crate::capture::Pmv2Guard::enter();
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()
};
top_windows(&auto, &walker)?
.into_iter()
.map(|w| {
let title = w
.get_name()
.map_err(|e| crate::read_channel::failure("window.name", e))?;
if title.is_empty() {
return Ok(None);
}
let handle = w
.get_native_window_handle()
.map_err(|e| crate::read_channel::failure("window.hwnd", e))?;
let handle: HWND = handle.into();
let hwnd = (!handle.0.is_null()).then_some(handle);
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),
};
Ok(Some(WindowEntry {
title,
class: w
.get_classname()
.map_err(|e| crate::read_channel::failure("window.class", e))?,
pid: w
.get_process_id()
.map_err(|e| crate::read_channel::failure("window.pid", e))?,
hwnd: hwnd.map(|h| h.0 as isize),
foreground,
topmost,
minimized,
visible,
z_order,
rect,
owner_title,
}))
})
.collect::<Result<Vec<_>, CtlError>>()
.map(|items| items.into_iter().flatten().collect())
}
pub fn close_window(pattern: &str, system_close: bool) -> 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)?;
let mut action = crate::feedback::window_action("close-window", &win, false)?;
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"),
));
};
post_close(hwnd, system_close, &title)?;
action.delivered();
Ok(title)
}
fn post_close(
hwnd: windows::Win32::Foundation::HWND,
system_close: bool,
title: &str,
) -> Result<(), CtlError> {
use windows::Win32::UI::WindowsAndMessaging::{PostMessageW, WM_CLOSE, WM_SYSCOMMAND};
unsafe {
if system_close {
PostMessageW(
Some(hwnd),
WM_SYSCOMMAND,
windows::Win32::Foundation::WPARAM(0xF060), windows::Win32::Foundation::LPARAM(0),
)
} else {
PostMessageW(
Some(hwnd),
WM_CLOSE,
windows::Win32::Foundation::WPARAM(0),
windows::Win32::Foundation::LPARAM(0),
)
}
}
.map_err(|e| {
CtlError::internal(format!(
"PostMessageW({}) failed for {title:?}: {e}",
if system_close {
"WM_SYSCOMMAND/SC_CLOSE"
} else {
"WM_CLOSE"
}
))
})
}
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 mut action = crate::feedback::window_action("resize-window", &win, false)?;
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}")))?;
action.delivered();
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)
}
#[derive(Debug, Clone, Copy)]
pub enum WindowRef<'a> {
Title(&'a str),
Pid(u32),
Hwnd(isize),
}
pub fn resolve_window(r: WindowRef<'_>) -> Result<UIElement, CtlError> {
let auto = UIAutomation::new().map_err(crate::internal)?;
let walker = auto.create_tree_walker().map_err(crate::internal)?;
match r {
WindowRef::Title(pattern) => find_window(&auto, &walker, pattern),
WindowRef::Pid(pid) => {
if !crate::native_windows()?.iter().any(|w| w.pid == pid) {
return Err(CtlError::new(
ErrorCode::NotFound,
format!("no top-level window owned by pid {pid}"),
));
}
let matches = crate::window_observation::select(top_windows(&auto, &walker)?, |w| {
w.get_process_id()
.map(|p| p == pid)
.map_err(|e| crate::read_channel::failure("window.pid", e))
})?;
match matches.len() {
0 => Err(CtlError::new(
ErrorCode::NotFound,
format!("no top-level window owned by pid {pid}"),
)),
1 => matches
.into_iter()
.next()
.ok_or_else(|| CtlError::internal("window candidate missing")),
n => {
let titles: Vec<String> = matches
.iter()
.filter_map(|w| w.get_name().ok())
.take(5)
.collect();
Err(CtlError::with_evidence(
ErrorCode::Ambiguous,
format!("pid {pid} owns {n} windows; pass --hwnd or a title"),
serde_json::json!({ "candidates": titles }),
))
}
}
}
WindowRef::Hwnd(raw) => {
crate::display_support::reject_display(raw)?;
auto.element_from_handle(uiautomation::types::Handle::from(raw))
.map_err(|_| {
CtlError::new(ErrorCode::NotFound, format!("no window at hwnd {raw:#x}"))
})
}
}
}
pub fn parse_hwnd(raw: &str) -> Result<isize, CtlError> {
let t = raw.trim();
let v = if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
isize::from_str_radix(hex, 16).ok()
} else {
t.parse::<isize>().ok()
};
v.filter(|v| *v != 0).ok_or_else(|| {
CtlError::protocol(format!(
"invalid --hwnd {raw:?}: expected decimal or 0x-prefixed hex"
))
})
}
pub fn close_window_by(r: WindowRef<'_>, system_close: bool) -> Result<String, CtlError> {
let win = resolve_window(r)?;
let mut action = crate::feedback::window_action("close-window", &win, false)?;
let title = win.get_name().unwrap_or_default();
let Some(hwnd) = native_hwnd(&win) else {
return Err(CtlError::new(
ErrorCode::NotActionable,
format!("window {title:?} exposes no native handle"),
));
};
post_close(hwnd, system_close, &title)?;
action.delivered();
Ok(title)
}
pub fn focus_window_by(r: WindowRef<'_>) -> Result<String, CtlError> {
let win = resolve_window(r)?;
let mut action = crate::feedback::window_action("focus-window", &win, true)?;
crate::window_ready::activate(&win)?;
action.delivered();
Ok(win.get_name().unwrap_or_default())
}
pub fn foreground_window() -> Option<(String, isize)> {
let hwnd = unsafe { windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow() };
if hwnd.0.is_null() {
return None;
}
window_title_of(hwnd).map(|t| (t, hwnd.0 as isize))
}
pub fn resolve_app_path(program: &str) -> Option<String> {
use windows::Win32::System::Registry::{
HKEY_LOCAL_MACHINE, KEY_QUERY_VALUE, REG_SZ, RegCloseKey, RegOpenKeyExW, RegQueryValueExW,
};
use windows::core::PCWSTR;
let bs = char::from_u32(0x5C)?; if program.contains(bs) || program.contains('/') {
return None;
}
let lookup = |subkey: String| -> Option<String> {
let wpath: Vec<u16> = subkey.encode_utf16().chain(std::iter::once(0)).collect();
let mut hkey = Default::default();
let opened = unsafe {
RegOpenKeyExW(
HKEY_LOCAL_MACHINE,
PCWSTR(wpath.as_ptr()),
None,
KEY_QUERY_VALUE,
&mut hkey,
)
};
if opened.is_err() {
return None;
}
let mut buf = [0u16; 1024];
let mut len = (buf.len() * 2) as u32;
let mut ty = REG_SZ;
let ok = unsafe {
RegQueryValueExW(
hkey,
PCWSTR::null(), None,
Some(&mut ty),
Some(buf.as_mut_ptr().cast()),
Some(&mut len),
)
.is_ok()
};
unsafe {
let _ = RegCloseKey(hkey);
}
ok.then(|| {
let end = (len as usize / 2).saturating_sub(1);
String::from_utf16_lossy(&buf[..end]).trim().to_string()
})
.filter(|s| !s.is_empty())
};
let sep = std::path::MAIN_SEPARATOR_STR;
let base = format!("SOFTWARE{sep}Microsoft{sep}Windows{sep}CurrentVersion{sep}App Paths");
lookup(format!("{base}{sep}{program}")).or_else(|| lookup(format!("{base}{sep}{program}.exe")))
}
pub fn hwnd_of(elem: &UIElement) -> Option<windows::Win32::Foundation::HWND> {
native_hwnd(elem)
}
pub fn capture_hwnd(hwnd_raw: isize) -> Result<CaptureResult, CtlError> {
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
let target = resolve_window(WindowRef::Hwnd(hwnd_raw))?;
let window = WindowInfo {
title: target
.get_name()
.map_err(|e| crate::read_channel::failure("window.name", e))?,
class: target
.get_classname()
.map_err(|e| crate::read_channel::failure("window.class", e))?,
pid: target
.get_process_id()
.map_err(|e| crate::read_channel::failure("window.pid", e))?,
};
let wake_ms = ensure_window_responsive(&target);
let window_runtime_id = target
.get_runtime_id()
.map_err(|e| crate::read_channel::failure("window.runtime_id", e))?;
let mut nodes = Vec::new();
let mut element_identities = Vec::new();
let truncation_reasons = {
let _t = actl_core::trace::scope("capture.walk");
walk(
&walker,
&target,
0,
None,
&mut nodes,
&mut element_identities,
)?
};
let truncated = !truncation_reasons.is_empty();
Ok(CaptureResult {
window,
window_runtime_id,
nodes,
element_identities,
truncated,
truncation_reasons,
wake_ms,
})
}
fn runtime_ids_match(recorded: &[i32], current: &[i32]) -> bool {
!recorded.is_empty() && recorded == current
}
fn classify_front_text(text: &str) -> &'static str {
if text.contains("找不到") || text.contains("错误") || text.contains("失败") {
"error"
} else if text.contains("保存") {
"save"
} else if text.contains("确认") || text.contains("是否") {
"confirm"
} else if text.contains("更新") || text.contains("升级") {
"update"
} else {
"unknown"
}
}
pub fn front_report(app: &str) -> Result<serde_json::Value, CtlError> {
let cap = capture(Some(app))?;
let title = cap.window.title.clone();
let all = crate::native_windows::native_windows()?;
let main = all.iter().find(|w| w.title == title);
let owned: Vec<serde_json::Value> = all
.iter()
.filter(|w| w.owner_title.as_deref() == Some(title.as_str()) && w.visible)
.map(|w| {
serde_json::json!({"hwnd": w.hwnd, "title": w.title, "class": w.class,
"visible": w.visible, "foreground": w.foreground})
})
.collect();
let nodes = &cap.nodes;
let mut surfaces = Vec::new();
let mut i = 0;
while i < nodes.len() {
let node = &nodes[i];
if node.role != "Window" || node.depth == 0 {
i += 1;
continue;
}
let mut texts: Vec<String> = Vec::new();
let mut buttons: Vec<String> = Vec::new();
if let Some(name) = node.name.as_deref().filter(|s| !s.is_empty()) {
texts.push(name.to_owned());
}
let mut j = i + 1;
while j < nodes.len() && nodes[j].depth > node.depth {
let inner = &nodes[j];
if let Some(name) = inner.name.as_deref().filter(|s| !s.is_empty()) {
if inner.role == "Button" {
buttons.push(name.to_owned());
} else if inner.role == "Text" {
texts.push(name.to_owned());
}
}
j += 1;
}
if !texts.is_empty() || !buttons.is_empty() {
let joined = texts.join(" ");
surfaces.push(serde_json::json!({
"name": node.name.clone().unwrap_or_default(),
"text": joined,
"buttons": buttons,
"classification": classify_front_text(&joined),
}));
}
i = j;
}
let front_clear = owned.is_empty() && surfaces.is_empty();
Ok(serde_json::json!({
"window": {"title": title, "class": cap.window.class, "pid": cap.window.pid,
"hwnd": main.and_then(|w| w.hwnd), "foreground": main.map(|w| w.foreground)},
"owned": owned,
"in_tree": surfaces,
"front_clear": front_clear,
}))
}
#[cfg(test)]
mod identity_regression_tests {
#[test]
fn missing_window_identity_must_not_authorize_replay() {
assert!(!super::runtime_ids_match(&[], &[]));
assert!(!super::runtime_ids_match(&[], &[42]));
}
}