use actl_core::target::Target;
use actl_core::{CtlError, ErrorCode};
use uiautomation::patterns::UIValuePattern;
use uiautomation::{UIAutomation, UIElement, UITreeWalker};
use windows::Win32::Foundation::HGLOBAL;
use windows::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, GetClipboardData, OpenClipboard, SetClipboardData,
};
use windows::Win32::System::Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalUnlock};
use crate::internal;
use crate::locate::{Located, locate, role_of};
use crate::window::MAX_ELEMENTS;
pub const PROPS: &[&str] = &[
"name",
"role",
"automation_id",
"offscreen",
"value",
"text",
];
pub struct PropertyInfo {
pub name: Option<String>,
pub role: String,
pub automation_id: Option<String>,
pub offscreen: Option<bool>,
pub value: Option<String>,
pub value_via: Option<&'static str>,
pub text: Option<String>,
}
pub fn read_property(
app: Option<&str>,
target: &Target,
property: Option<&str>,
near: Option<&str>,
) -> Result<PropertyInfo, CtlError> {
if let Some(p) = property {
if !PROPS.contains(&p) {
return Err(CtlError::protocol(format!(
"unknown property {p:?}: expected one of {PROPS:?}"
)));
}
}
let loc = locate(app, target, near)?;
let want = |p: &str| property.map(|sel| sel == p).unwrap_or(true);
let mut value_via = None;
let value = want("value")
.then(|| {
let (v, via) = read_value_via(&loc.element);
value_via = via;
v
})
.flatten();
let text = want("text")
.then(|| {
use uiautomation::patterns::UITextPattern;
loc.element
.get_pattern::<UITextPattern>()
.ok()
.and_then(|t| t.get_document_range().ok())
.and_then(|r| r.get_text(-1).ok())
.filter(|t| !t.is_empty())
})
.flatten();
Ok(PropertyInfo {
name: want("name").then_some(loc.name.clone()).flatten(),
role: if want("role") {
loc.role.clone()
} else {
String::new()
},
automation_id: want("automation_id")
.then_some(loc.automation_id.clone())
.flatten(),
offscreen: want("offscreen")
.then(|| loc.element.is_offscreen().ok())
.flatten(),
value,
value_via,
text,
})
}
fn read_value_via(elem: &UIElement) -> (Option<String>, Option<&'static str>) {
use uiautomation::patterns::UITextPattern;
if let Some(v) = elem
.get_pattern::<UIValuePattern>()
.ok()
.and_then(|p| p.get_value().ok())
.filter(|v| !v.is_empty())
{
return (Some(v), Some("value-pattern"));
}
let text = elem
.get_pattern::<UITextPattern>()
.ok()
.and_then(|t| t.get_document_range().ok())
.and_then(|r| r.get_text(-1).ok())
.filter(|t| !t.is_empty());
(text.clone(), text.is_some().then_some("text-pattern"))
}
fn read_value(elem: &UIElement) -> Option<String> {
read_value_via(elem).0
}
pub enum VerifyKind {
Exists,
Value { expected: String, contains: bool },
Checked,
}
pub struct VerifyOutcome {
pub located: Located,
pub actual: Option<String>,
pub mode: &'static str,
}
pub fn verify(
app: Option<&str>,
target: &Target,
near: Option<&str>,
kind: VerifyKind,
) -> Result<VerifyOutcome, CtlError> {
use uiautomation::patterns::UITogglePattern;
let fail = |what: &str, actual: Option<String>| {
CtlError::with_evidence(
ErrorCode::AssertionFailed,
format!("verify {what} failed"),
serde_json::json!({ "actual": actual }),
)
};
match kind {
VerifyKind::Exists => {
let loc = locate(app, target, near)?;
Ok(VerifyOutcome {
located: loc,
actual: None,
mode: "exists",
})
}
VerifyKind::Value { expected, contains } => {
let loc = locate(app, target, near)?;
let actual = read_value(&loc.element);
let ok = match (&actual, contains) {
(Some(a), true) => a.contains(&expected),
(Some(a), false) => a == &expected,
(None, _) => false,
};
if !ok {
return Err(fail("value", actual));
}
Ok(VerifyOutcome {
located: loc,
actual,
mode: if contains { "value-contains" } else { "value" },
})
}
VerifyKind::Checked => {
let loc = locate(app, target, near)?;
let state = loc
.element
.get_pattern::<UITogglePattern>()
.ok()
.and_then(|t| t.get_toggle_state().ok())
.map(|s| format!("{s:?}"));
if state.as_deref() != Some("On") {
return Err(fail("checked", state));
}
Ok(VerifyOutcome {
located: loc,
actual: state,
mode: "checked",
})
}
}
}
pub fn extract_table(
app: Option<&str>,
target: &Target,
) -> Result<(Vec<Vec<String>>, &'static str), CtlError> {
use uiautomation::patterns::UIGridPattern;
let loc = locate(app, target, None)?;
if let Ok(grid) = loc.element.get_pattern::<UIGridPattern>() {
let rows = grid.get_row_count().unwrap_or(0);
let cols = grid.get_column_count().unwrap_or(0);
if rows > 0 && cols > 0 && (rows * cols) as usize <= MAX_ELEMENTS {
let mut table = Vec::with_capacity(rows as usize);
let mut ok = true;
for r in 0..rows {
let mut row = Vec::with_capacity(cols as usize);
for c in 0..cols {
match grid.get_item(r, c) {
Ok(cell) => row.push(
read_value(&cell)
.unwrap_or_else(|| cell.get_name().unwrap_or_default()),
),
Err(_) => {
ok = false;
break;
}
}
}
if !ok {
break;
}
table.push(row);
}
if ok && !table.is_empty() {
return Ok((table, "grid-pattern"));
}
}
}
let auto = UIAutomation::new().map_err(internal)?;
let walker = auto.create_tree_walker().map_err(internal)?;
fn rows_under(walker: &UITreeWalker, elem: &UIElement, depth: u32, out: &mut Vec<UIElement>) {
if depth > crate::window::MAX_DEPTH {
return;
}
let mut child = walker.get_first_child(elem).ok();
while let Some(c) = child {
let role = role_of(&c);
if role == "DataItem" || role == "ListItem" {
out.push(c.clone());
} else {
rows_under(walker, &c, depth + 1, out);
}
child = walker.get_next_sibling(&c).ok();
}
}
fn cells_under(walker: &UITreeWalker, elem: &UIElement, depth: u32, out: &mut Vec<String>) {
if depth > crate::window::MAX_DEPTH {
return;
}
let role = role_of(elem);
if role == "Text" || role == "Edit" {
if let Some(n) = elem.get_name().ok().filter(|n| !n.is_empty()) {
out.push(n);
}
}
let mut child = walker.get_first_child(elem).ok();
while let Some(c) = child {
cells_under(walker, &c, depth + 1, out);
child = walker.get_next_sibling(&c).ok();
}
}
let mut row_elems = Vec::new();
rows_under(&walker, &loc.element, 0, &mut row_elems);
if row_elems.is_empty() {
return Err(CtlError::new(
ErrorCode::NotActionable,
format!(
"{} ({}) contains no DataItem/ListItem rows",
target.describe(),
loc.role
),
));
}
let mut table = Vec::new();
for r in &row_elems {
let mut cells = Vec::new();
cells_under(&walker, r, 0, &mut cells);
if !cells.is_empty() {
table.push(cells);
}
}
Ok((table, "tree-flatten"))
}
const CF_UNICODETEXT: u32 = 13;
struct ClipGuard;
impl Drop for ClipGuard {
fn drop(&mut self) {
unsafe { CloseClipboard() }.ok();
}
}
fn open_clipboard() -> Result<ClipGuard, CtlError> {
if unsafe { OpenClipboard(None) }.is_ok() {
Ok(ClipGuard)
} else {
Err(CtlError::new(
ErrorCode::NotActionable,
"clipboard is held by another process; retry shortly",
))
}
}
pub fn get_clipboard_text() -> Result<Option<String>, CtlError> {
unsafe {
let _guard = open_clipboard()?;
if !windows::Win32::System::DataExchange::IsClipboardFormatAvailable(CF_UNICODETEXT).is_ok()
{
return Ok(None);
}
let Ok(h) = GetClipboardData(CF_UNICODETEXT) else {
return Ok(None);
};
let hg = HGLOBAL(h.0);
let p = GlobalLock(hg) as *const u16;
if p.is_null() {
return Ok(None);
}
let mut len = 0usize;
while *p.add(len) != 0 {
len += 1;
}
let text = String::from_utf16_lossy(std::slice::from_raw_parts(p, len));
let _ = GlobalUnlock(hg);
Ok(Some(text))
}
}
pub fn set_clipboard_text(text: &str) -> Result<(), CtlError> {
use windows::Win32::Foundation::{GlobalFree, HANDLE};
let mut wide: Vec<u16> = text.encode_utf16().collect();
wide.push(0);
unsafe {
let _guard = open_clipboard()?;
EmptyClipboard().map_err(|e| CtlError::internal(format!("EmptyClipboard: {e}")))?;
let h = GlobalAlloc(GMEM_MOVEABLE, wide.len() * 2)
.map_err(|e| CtlError::internal(format!("GlobalAlloc: {e}")))?;
let p = GlobalLock(h) as *mut u16;
if p.is_null() {
let _ = GlobalFree(Some(h));
return Err(CtlError::internal("GlobalLock failed"));
}
std::ptr::copy_nonoverlapping(wide.as_ptr(), p, wide.len());
let _ = GlobalUnlock(h);
if SetClipboardData(CF_UNICODETEXT, Some(HANDLE(h.0))).is_err() {
let _ = GlobalFree(Some(h));
return Err(CtlError::internal("SetClipboardData failed"));
}
Ok(())
}
}