#![cfg(target_os = "windows")]
use std::path::Path;
use std::time::{Duration, Instant};
use std::{mem, thread};
use serde_json::{json, Value};
use windows::core::PCWSTR;
use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT, TRUE, WPARAM};
use windows::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, SRCCOPY,
};
use windows::Win32::UI::WindowsAndMessaging::{
EnumChildWindows, EnumWindows, FindWindowW, GetClassNameW, GetClientRect,
GetDlgCtrlID, GetSystemMetrics, GetWindowTextLengthW, GetWindowTextW,
GetWindowThreadProcessId, IsWindowVisible, SendMessageW, SetForegroundWindow,
SM_CXSCREEN, SM_CYSCREEN, GetWindowRect,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP,
KEYEVENTF_UNICODE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP,
MOUSEEVENTF_MOVE, MOUSEINPUT, VIRTUAL_KEY,
};
const WM_SETTEXT: u32 = 0x000C;
const WM_GETTEXT: u32 = 0x000D;
const WM_GETTEXTLENGTH: u32 = 0x000E;
const BM_CLICK: u32 = 0x00F5;
const CB_GETCOUNT: u32 = 0x0146;
const CB_GETCURSEL: u32 = 0x0147;
const CB_SETCURSEL: u32 = 0x014E;
const CB_GETLBTEXT: u32 = 0x0148;
const CB_GETLBTEXTLEN: u32 = 0x0149;
const WM_SETFOCUS: u32 = 0x0007;
const WM_COMMAND: u32 = 0x0111;
const BN_CLICKED: u32 = 0;
const CBN_SELCHANGE: u32 = 1;
const LB_GETCOUNT: u32 = 0x018B;
const LB_GETCURSEL: u32 = 0x0188;
const LB_SETCURSEL: u32 = 0x0186;
const LB_GETTEXT: u32 = 0x0189;
const LB_GETTEXTLEN: u32 = 0x018A;
type W32Result<T> = Result<T, Box<dyn std::error::Error>>;
#[derive(Debug, Clone)]
pub struct Control {
pub hwnd: HWND,
pub class: String,
pub text: String,
pub id: i32,
pub rect: RECT,
pub visible: bool,
pub index: usize,
}
pub struct Win32 {
hwnd: HWND,
title: String,
}
impl Win32 {
pub fn connect(window_spec: &str) -> W32Result<Self> {
let hwnd = find_window(window_spec)?;
let title = get_window_text(hwnd);
Ok(Win32 { hwnd, title })
}
pub fn children(&self) -> W32Result<Value> {
let controls = enum_children(self.hwnd);
let items: Vec<Value> = controls
.iter()
.map(|c| {
json!({
"index": c.index,
"class": c.class,
"text": c.text,
"id": c.id,
"visible": c.visible,
"rect": {
"x": c.rect.left, "y": c.rect.top,
"w": c.rect.right - c.rect.left,
"h": c.rect.bottom - c.rect.top,
},
})
})
.collect();
Ok(json!(items))
}
pub fn set_text(&self, selector: &str, text: &str) -> W32Result<Value> {
let ctrl = self.find_control(selector)?;
let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
SendMessageW(ctrl.hwnd, WM_SETTEXT, WPARAM(0), LPARAM(wide.as_ptr() as isize));
}
Ok(json!({
"result": "set_text",
"control": ctrl.class,
"text": text,
"id": ctrl.id,
}))
}
pub fn get_text(&self, selector: &str) -> W32Result<Value> {
let ctrl = self.find_control(selector)?;
let text = get_control_text(ctrl.hwnd);
Ok(json!({
"text": text,
"class": ctrl.class,
"id": ctrl.id,
}))
}
pub fn click(&self, selector: &str) -> W32Result<Value> {
let ctrl = self.find_control(selector)?;
if ctrl.class.contains("Button") {
unsafe {
SendMessageW(ctrl.hwnd, BM_CLICK, WPARAM(0), LPARAM(0));
}
let ctrl_id = ctrl.id as u16;
let notify = BN_CLICKED as u16;
let wparam = ((notify as usize) << 16) | (ctrl_id as usize);
unsafe {
SendMessageW(self.hwnd, WM_COMMAND, WPARAM(wparam), LPARAM(ctrl.hwnd.0 as isize));
}
return Ok(json!({
"result": "bm_click",
"name": ctrl.text,
"id": ctrl.id,
}));
}
let cx = (ctrl.rect.left + ctrl.rect.right) / 2;
let cy = (ctrl.rect.top + ctrl.rect.bottom) / 2;
mouse_click(cx, cy);
Ok(json!({
"result": "mouse_click",
"name": ctrl.text,
"x": cx, "y": cy,
}))
}
pub fn set_combo(&self, selector: &str, text: &str) -> W32Result<Value> {
let ctrl = self.find_control(selector)?;
let count = unsafe {
SendMessageW(ctrl.hwnd, CB_GETCOUNT, WPARAM(0), LPARAM(0))
};
let target = text.to_lowercase();
for i in 0..count.0 as i32 {
let len = unsafe {
SendMessageW(ctrl.hwnd, CB_GETLBTEXTLEN, WPARAM(i as usize), LPARAM(0))
};
let mut buf = vec![0u16; (len.0 as usize) + 1];
unsafe {
SendMessageW(ctrl.hwnd, CB_GETLBTEXT, WPARAM(i as usize), LPARAM(buf.as_mut_ptr() as isize));
}
let item = String::from_utf16_lossy(&buf[..len.0 as usize]);
if item.to_lowercase().contains(&target) {
unsafe {
SendMessageW(ctrl.hwnd, CB_SETCURSEL, WPARAM(i as usize), LPARAM(0));
}
let ctrl_id = ctrl.id as u16;
let notify = CBN_SELCHANGE as u16;
let wparam = ((notify as usize) << 16) | (ctrl_id as usize);
unsafe {
SendMessageW(self.hwnd, WM_COMMAND, WPARAM(wparam), LPARAM(ctrl.hwnd.0 as isize));
}
return Ok(json!({
"result": "combo_set",
"selected": item,
"index": i,
}));
}
}
Err(format!("combo item '{}' not found in {} items", text, count.0).into())
}
pub fn read_combo(&self, selector: &str) -> W32Result<Value> {
let ctrl = self.find_control(selector)?;
let count = unsafe {
SendMessageW(ctrl.hwnd, CB_GETCOUNT, WPARAM(0), LPARAM(0))
};
let cur = unsafe {
SendMessageW(ctrl.hwnd, CB_GETCURSEL, WPARAM(0), LPARAM(0))
};
let mut items = Vec::new();
for i in 0..count.0 as i32 {
let len = unsafe {
SendMessageW(ctrl.hwnd, CB_GETLBTEXTLEN, WPARAM(i as usize), LPARAM(0))
};
let mut buf = vec![0u16; (len.0 as usize) + 1];
unsafe {
SendMessageW(ctrl.hwnd, CB_GETLBTEXT, WPARAM(i as usize), LPARAM(buf.as_mut_ptr() as isize));
}
items.push(String::from_utf16_lossy(&buf[..len.0 as usize]));
}
Ok(json!({
"items": items,
"selected": cur.0 as i32,
"count": count.0 as i32,
}))
}
pub fn screenshot(&self, path: &Path) -> W32Result<()> {
capture_window_bmp(self.hwnd, path)
}
pub fn wait_for(&self, selector: &str, timeout_ms: u64) -> W32Result<Value> {
let start = Instant::now();
loop {
if let Ok(ctrl) = self.find_control(selector) {
return Ok(json!({
"found": true,
"class": ctrl.class,
"text": ctrl.text,
"elapsed_ms": start.elapsed().as_millis() as u64,
}));
}
if start.elapsed().as_millis() >= timeout_ms as u128 {
return Ok(json!({ "found": false, "status": "timeout" }));
}
thread::sleep(Duration::from_millis(200));
}
}
pub fn raw_keys(&self, keys: &str) -> W32Result<Value> {
let _ = unsafe { SetForegroundWindow(self.hwnd) };
thread::sleep(Duration::from_millis(100));
send_string(keys);
Ok(json!({ "result": "sent", "keys": keys }))
}
pub fn list_windows(&self) -> W32Result<Value> {
Ok(json!(enumerate_top_windows()))
}
fn find_control(&self, selector: &str) -> W32Result<Control> {
let controls = enum_children(self.hwnd);
if let Some(idx_str) = selector.strip_prefix('#') {
let idx: usize = idx_str.parse().map_err(|_| format!("bad index: {}", selector))?;
return controls.into_iter().find(|c| c.index == idx)
.ok_or_else(|| format!("no control at index {}", idx).into());
}
if let Some(id_str) = selector.strip_prefix("id:") {
let id: i32 = id_str.parse().map_err(|_| format!("bad id: {}", selector))?;
return controls.into_iter().find(|c| c.id == id)
.ok_or_else(|| format!("no control with id {}", id).into());
}
if let Some(rest) = selector.strip_prefix("class:") {
let (class_name, idx) = parse_class_index(rest);
let matches: Vec<Control> = controls.into_iter()
.filter(|c| c.class.contains(&class_name))
.collect();
let i = idx.unwrap_or(0);
return matches.into_iter().nth(i)
.ok_or_else(|| format!("no {}[{}] found", class_name, i).into());
}
if let Some(text) = selector.strip_prefix("text:") {
let lower = text.to_lowercase();
return controls.into_iter()
.find(|c| c.text.to_lowercase().contains(&lower))
.ok_or_else(|| format!("no control with text '{}'", text).into());
}
let lower = selector.to_lowercase();
controls.into_iter()
.find(|c| c.text.to_lowercase().contains(&lower))
.ok_or_else(|| format!("no control matching '{}'", selector).into())
}
}
fn find_window(spec: &str) -> W32Result<HWND> {
if let Some(pid_str) = spec.strip_prefix("pid:") {
let target_pid: u32 = pid_str.parse()?;
for w in enumerate_top_windows() {
if w.get("pid").and_then(|v| v.as_u64()) == Some(target_pid as u64) {
let h = w.get("hwnd").and_then(|v| v.as_u64()).unwrap_or(0);
return Ok(HWND(h as *mut _));
}
}
return Err(format!("no window for pid {}", target_pid).into());
}
let is_wildcard = spec.contains('*');
let pattern = spec.replace('*', "").to_lowercase();
for w in enumerate_top_windows() {
let title = w.get("title").and_then(|v| v.as_str()).unwrap_or("");
let matched = if is_wildcard {
title.to_lowercase().contains(&pattern)
} else {
title == spec
};
if matched {
let h = w.get("hwnd").and_then(|v| v.as_u64()).unwrap_or(0);
return Ok(HWND(h as *mut _));
}
}
Err(format!("no window matching '{}'", spec).into())
}
fn enumerate_top_windows() -> Vec<Value> {
let mut results: Vec<Value> = Vec::new();
unsafe extern "system" fn cb(hwnd: HWND, lparam: LPARAM) -> BOOL {
let results = &mut *(lparam.0 as *mut Vec<Value>);
if !IsWindowVisible(hwnd).as_bool() { return TRUE; }
let mut buf = [0u16; 512];
let len = GetWindowTextW(hwnd, &mut buf);
if len == 0 { return TRUE; }
let title = String::from_utf16_lossy(&buf[..len as usize]);
let mut pid = 0u32;
GetWindowThreadProcessId(hwnd, Some(&mut pid));
results.push(json!({ "hwnd": hwnd.0 as u64, "title": title, "pid": pid }));
TRUE
}
unsafe { let _ = EnumWindows(Some(cb), LPARAM(&mut results as *mut _ as isize)); }
results
}
fn enum_children(parent: HWND) -> Vec<Control> {
let mut controls: Vec<Control> = Vec::new();
unsafe extern "system" fn cb(hwnd: HWND, lparam: LPARAM) -> BOOL {
let controls = &mut *(lparam.0 as *mut Vec<Control>);
let idx = controls.len();
let class = get_class_name(hwnd);
let text = get_control_text(hwnd);
let id = GetDlgCtrlID(hwnd);
let visible = IsWindowVisible(hwnd).as_bool();
let mut rect = RECT::default();
let _ = GetWindowRect(hwnd, &mut rect);
controls.push(Control {
hwnd, class, text, id, rect, visible, index: idx,
});
TRUE
}
unsafe {
let _ = EnumChildWindows(parent, Some(cb), LPARAM(&mut controls as *mut _ as isize));
}
controls
}
fn get_class_name(hwnd: HWND) -> String {
let mut buf = [0u16; 256];
let len = unsafe { GetClassNameW(hwnd, &mut buf) };
String::from_utf16_lossy(&buf[..len as usize])
}
fn get_control_text(hwnd: HWND) -> String {
let len = unsafe { SendMessageW(hwnd, WM_GETTEXTLENGTH, WPARAM(0), LPARAM(0)) };
if len.0 <= 0 { return String::new(); }
let mut buf = vec![0u16; (len.0 as usize) + 1];
unsafe {
SendMessageW(hwnd, WM_GETTEXT, WPARAM(buf.len()), LPARAM(buf.as_mut_ptr() as isize));
}
String::from_utf16_lossy(&buf[..len.0 as usize])
}
fn get_window_text(hwnd: HWND) -> String {
let mut buf = [0u16; 512];
let len = unsafe { GetWindowTextW(hwnd, &mut buf) };
String::from_utf16_lossy(&buf[..len as usize])
}
fn parse_class_index(s: &str) -> (String, Option<usize>) {
if let Some(bracket) = s.rfind('[') {
if s.ends_with(']') {
let idx: usize = s[bracket + 1..s.len() - 1].parse().unwrap_or(0);
return (s[..bracket].to_string(), Some(idx));
}
}
(s.to_string(), None)
}
fn mouse_click(x: i32, y: i32) {
let sw = unsafe { GetSystemMetrics(SM_CXSCREEN) };
let sh = unsafe { GetSystemMetrics(SM_CYSCREEN) };
let ax = (x * 65535) / sw;
let ay = (y * 65535) / sh;
let inputs = [
INPUT { r#type: INPUT_MOUSE, Anonymous: INPUT_0 { mi: MOUSEINPUT {
dx: ax, dy: ay, mouseData: 0,
dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE, time: 0, dwExtraInfo: 0,
}}},
INPUT { r#type: INPUT_MOUSE, Anonymous: INPUT_0 { mi: MOUSEINPUT {
dx: ax, dy: ay, mouseData: 0,
dwFlags: MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_ABSOLUTE, time: 0, dwExtraInfo: 0,
}}},
INPUT { r#type: INPUT_MOUSE, Anonymous: INPUT_0 { mi: MOUSEINPUT {
dx: ax, dy: ay, mouseData: 0,
dwFlags: MOUSEEVENTF_LEFTUP | MOUSEEVENTF_ABSOLUTE, time: 0, dwExtraInfo: 0,
}}},
];
unsafe { SendInput(&inputs, mem::size_of::<INPUT>() as i32); }
thread::sleep(Duration::from_millis(50));
}
fn send_string(text: &str) {
for c in text.chars() {
let scan = c as u16;
let inputs = [
INPUT { r#type: INPUT_KEYBOARD, Anonymous: INPUT_0 { ki: KEYBDINPUT {
wVk: VIRTUAL_KEY(0), wScan: scan, dwFlags: KEYEVENTF_UNICODE, time: 0, dwExtraInfo: 0,
}}},
INPUT { r#type: INPUT_KEYBOARD, Anonymous: INPUT_0 { ki: KEYBDINPUT {
wVk: VIRTUAL_KEY(0), wScan: scan, dwFlags: KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0,
}}},
];
unsafe { SendInput(&inputs, mem::size_of::<INPUT>() as i32); }
thread::sleep(Duration::from_millis(10));
}
}
fn capture_window_bmp(hwnd: HWND, path: &Path) -> W32Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
unsafe {
let mut rect = RECT::default();
GetClientRect(hwnd, &mut rect)?;
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
if w <= 0 || h <= 0 { return Err("zero-size window".into()); }
let hdc = GetDC(hwnd);
let mem_dc = CreateCompatibleDC(hdc);
let bmp = CreateCompatibleBitmap(hdc, w, h);
let old = SelectObject(mem_dc, bmp);
let _ = BitBlt(mem_dc, 0, 0, w, h, hdc, 0, 0, SRCCOPY);
let row = ((w * 3 + 3) / 4) * 4;
let size = (row * h) as usize;
let mut px = vec![0u8; size];
let mut bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: w, biHeight: h, biPlanes: 1, biBitCount: 24,
biCompression: BI_RGB.0 as u32, biSizeImage: size as u32,
..Default::default()
},
..Default::default()
};
GetDIBits(mem_dc, bmp, 0, h as u32, Some(px.as_mut_ptr() as _), &mut bmi, DIB_RGB_COLORS);
SelectObject(mem_dc, old);
let _ = DeleteObject(bmp);
let _ = DeleteDC(mem_dc);
ReleaseDC(hwnd, hdc);
let file_size = 54 + size as u32;
let mut buf = Vec::with_capacity(file_size as usize);
buf.extend_from_slice(b"BM");
buf.extend_from_slice(&file_size.to_le_bytes());
buf.extend_from_slice(&[0u8; 4]);
buf.extend_from_slice(&54u32.to_le_bytes());
buf.extend_from_slice(&40u32.to_le_bytes());
buf.extend_from_slice(&w.to_le_bytes());
buf.extend_from_slice(&h.to_le_bytes());
buf.extend_from_slice(&1u16.to_le_bytes());
buf.extend_from_slice(&24u16.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&(size as u32).to_le_bytes());
buf.extend_from_slice(&2835u32.to_le_bytes());
buf.extend_from_slice(&2835u32.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&px);
std::fs::write(path, buf)?;
}
Ok(())
}