actl-uia 0.1.3

Windows UIA backend: the ONLY crate allowed to touch COM/unsafe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
//! window —— 窗口域:发现(find_window 家族)、capture(DFS 遍历)、
//! snapshot 身份持久化与回放校验、窗口管理命令(focus/close/resize/list/wait)。
//! 叠放事实(foreground/z_order 等)混走 UIA + Win32 物理层,来源在条目内注明。

use actl_core::{CtlError, ErrorCode, UiNode};
use uiautomation::types::ControlType;
use uiautomation::{UIAutomation, UIElement, UITreeWalker};

use crate::{internal, timing};

/// 收集上限与深度上限(防失控 UI;截断在输出中标记 truncated)。
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,
    /// 窗口 RuntimeId(snapshot_id 版本化的比对键;跨进程稳定)
    pub window_runtime_id: Vec<i32>,
    /// DFS 序节点流,`parent` 指向同流索引(供 core 层 skeleton 投影)
    pub nodes: Vec<UiNode>,
    pub truncated: bool,
    /// 激活感知等待的毫秒数(0 = 目标本就在响应;>0 = 目标曾挂起,已唤醒)
    pub wake_ms: u32,
}

/// 捕获一个顶层窗口的 UIA 子树。
/// `app`:窗口标题子串;None = 焦点元素所在顶层窗口(spike #2:焦点链可能落在后台 UI)。
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,
    })
}

// ─── snapshot_id 版本化-lite(M2:窗口身份绑定)──────────────────────────
/// @eN 绑定的是"快照那一刻的窗口实例"。跨命令重放时窗口可能已关闭重开
/// (同标题 ≠ 同实例),纯计数重放会静默点进新窗口的巧合位置。方案:快照
/// 落盘 {snapshot_id → 窗口 RuntimeId};回放命令带 `--snapshot <id>` 时
/// 校验当前窗口实例与快照一致,不一致 → STALE_REF。
/// 元素级校验(树前缀比对)留待完整会话设计,与 fallback 链的 evidence 演进同批。
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)
}

/// 快照记录落盘(保留最近 SNAPSHOT_KEEP 份,按修改时间淘汰)。
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(),
    );
    // 淘汰旧记录:按修改时间留最新 SNAPSHOT_KEEP 份
    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))
}

/// 回放校验:当前窗口实例须与快照记录一致(RuntimeId 比对)。
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(())
}

/// 激活感知目标解析(M2 第一批,docs/spike-findings"UWP 挂起 = UIA 停摆"):
/// 挂起应用的每个跨进程 UIA 属性调用都会 stall ~2s(DCOM 等待应用恢复)。
/// 先用不阻塞调用方的 WM_NULL 探针测目标是否在泵消息;不在 → ShowWindowAsync
/// 异步唤醒 + 有界等待(≤1.6s)。把隐形的逐调用 stall 变成一次有界的、
/// 可上报的等待。无原生句柄的元素(UIA-only)直接放行。
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;
    };
    // 探针 60ms:不影响正常路径,挂起应用恰好被这 60ms 暴露
    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 {
        // 异步投递不等待应用;激活请求本身是 UWP 恢复的最常见触发器
        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
}

/// 元素的原生 HWND(None = 无句柄/无效)。crate 的 Handle 未暴露原始值;
/// Handle/HANDLE/HWND 均为单指针包装,布局一致(同 close_window 的先例)。
/// HWND 的窗口标题(命中检查的比对键;空标题返回 None)。
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))
}

/// 顶层窗口 + 挂靠宿主的 owned dialog(IFileSaveDialog 等不是 root 的直接子节点,
/// 实测新记事本的"另存为"在宿主窗口的 depth-1,以 role=Window 挂靠)。
/// 注意:不做可见性过滤——UWP 应用后台挂起时 CoreWindow 会被 cloak
/// (IsWindowVisible=false),硬过滤会让快照只剩空壳框架、内容全失联
/// (金任务 1 实测 0/10);同名噪声由 find_window 的 WINUI_AUX_CLASSES 折叠消化。
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
}

/// root 直接子节点(顶层窗口)。快路径只走这里:不触 owned-dialog 深扫。
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
}

/// 窗口的直接子 Window(owned dialog,如挂靠宿主的"另存为")。
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)
}

/// WinUI 同一可见窗口的辅助 HWND(标题相同;实测计算器 3 个同名"计算器":
/// ApplicationFrameWindow 空壳框架 + TitleBarWindow 标题栏 + CoreWindow 内容,
/// 分属框架/内容两个 pid)。目标解析时折叠辅助件,**保留 CoreWindow**——
/// 内容元素(按钮等)挂在 CoreWindow 子树,框架子树是空壳(金任务 1 实测:
/// 偏好框架时 num1Button 全部失联 0/10)。
const WINUI_AUX_CLASSES: [&str; 2] = ["ApplicationFrameWindow", "ApplicationFrameTitleBarWindow"];

/// 在顶层窗口(含 owned dialog)中按标题子串定位**唯一**窗口。
/// 无匹配 → NOT_FOUND;**多匹配 → AMBIGUOUS 并附候选标题**(fail-closed:
/// 此前首个命中静默胜出,同名/含同名子串的窗口会把操作引向错误目标,doc 09 §3.3)。
/// 两阶段:先只扫 root 直接子节点(快路径,常见一步命中);0 命中才对每个
/// 顶层窗口深扫 owned dialog(如"另存为"挂靠宿主 depth-1)——深扫是每窗口
/// 几十次跨进程 COM 往返,放热路径实测把命令耗时从 ~40ms 拖到 ~2s。
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() {
        // owned dialog 兜底:只对有子 Window 的宿主扫
        for host in root_children(auto, walker) {
            matches.extend(
                owned_dialogs_of(walker, &host)
                    .into_iter()
                    .filter(|w| name_matches(w, pattern)),
            );
        }
    }
    // 折叠 WinUI 辅助 HWND:存在非辅助窗口时,辅助件不参与解析
    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), // 到达根(尽头 Err 语义,见 crate 注释)
        }
    }
}

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,
    }
}

/// 等待标题包含 `substr` 的顶层窗口出现(--expect 的执行体,06 §4:默认 2s 轮询)。
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));
    }
}

/// 当前全部顶层窗口(含 owned dialog)的 UIA RuntimeId——--expect 的"新窗口"基线。
/// 用 RuntimeId 而非标题:标题是可变身份(如记事本脏标记"*无标题"、保存后改名),
/// 按标题比对会把标题变化的既有窗口误判为"新窗口"(实测踩坑)。
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())
}

/// 等待一个**基线中不存在**、标题包含 `substr` 的新窗口出现。
/// --expect 的后置断言语义:证明"本动作导致了新窗口",而不是"存在同名窗口"
/// (后者会被预先打开的同名窗口恒真欺骗,doc 09 §2)。
/// 基线按 RuntimeId 比对:同名新窗口能通过,标题变化的既有窗口不会误判。
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())
}

/// focus-window:把标题匹配的顶层窗口带到前台(UIA SetFocus,即等效前台)。
/// press/type 等键盘命令作用于全局焦点——多窗口场景必须先 focus-window(06 §3.6 裁定表)。
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())
}

/// list-windows:枚举顶层窗口 + owned dialog(title/class/pid),供 agent 选择目标。
/// list-windows 条目:title/class/pid 来自 UIA(与定位链同源),叠放事实
/// 来自 Win32 物理层——agent 据此回答"谁在最上面/是否被挡/弹窗挂在谁下面"。
#[derive(Debug, Clone)]
pub struct WindowEntry {
    pub title: String,
    pub class: String,
    pub pid: u32,
    /// 物理前台(GetForegroundWindow 的根;注入护栏的同一判据)
    pub foreground: bool,
    /// WS_EX_TOPMOST("总在最前"弹窗:输入法悬浮/overlay 等)
    pub topmost: bool,
    pub minimized: bool,
    /// 注意:UWP 后台挂起时 CoreWindow 被 cloak,visible=false 属预期(不代表已死)
    pub visible: bool,
    /// Win32 Z 序(0 = 最顶);无原生句柄的窗口为 None
    pub z_order: Option<u32>,
    /// 窗口矩形 [left, top, right, bottom](虚拟桌面坐标,多屏可为负)
    pub rect: Option<[i32; 4]>,
    /// owned dialog 的宿主窗口标题(None = 顶层窗口)
    pub owner_title: Option<String>,
}

/// list-windows:枚举顶层窗口 + owned dialog,附物理层叠放事实。
/// Z 序 = GetTopWindow→GetWindow(GW_HWNDNEXT) 链的位次;前台比对沿用
/// 标题兜底先例(WinUI 三件套分属不同 HWND,严格比对会漏)。
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)?;

    // Z 链一次取全:HWND → 位次(0 = 最顶)
    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);
        }
    }
    // 物理前台(根 + 标题双判据,WinUI 兜底同 pointer_physical 先例)
    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())
}

/// close-window:优雅关闭标题匹配的唯一窗口(WM_CLOSE 异步投递;未保存内容
/// 由应用自行弹提示——语义关闭,不是强杀)。
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)
}

/// resize-window:标题唯一匹配窗口 → SetWindowPos(不动位置,只改尺寸)。
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)
}

/// check_snapshot_freshness 的按模式入口:解析窗口后比对 RuntimeId。
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)
}