← Commit history
rust/crates/kicad-platform/Cargo.toml+3
@@ -28,6 +28,9 @@ windows = { version = "0.61", features = [     "Win32_UI_Shell",     "Win32_UI_HiDpi",     "Win32_System_Com",+    "Win32_Security",                 # SECURITY_ATTRIBUTES in the CreateProcessW signature (spawn_background)+    "Win32_Storage_Xps",              # PrintWindow lives here in the windows crate (capture_window)+    "Win32_System_SystemInformation", # GetTickCount (seconds_since_input) ] } uiautomation = "0.24" 
rust/crates/kicad-platform/src/win/capture.rsadded+84
@@ -0,0 +1,84 @@+//! Window pixels for the canvas-painted probe: PrintWindow(PW_RENDERFULLCONTENT) into a+//! compatible bitmap, read back with GetDIBits. Only the capture is ported; the image goes+//! to ab for the evidence pipeline and the PNG encoding stays out of this crate.+//! Python: kicad_ui.py `_canvas_probe` and the local half of `_screenshot_hwnd`.++use windows::Win32::Foundation::HWND;+use windows::Win32::Graphics::Gdi::{+    CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits, ReleaseDC, SelectObject, BITMAPINFO,+    BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HGDIOBJ,+};+use windows::Win32::Storage::Xps::{PrintWindow, PRINT_WINDOW_FLAGS};+use windows::Win32::UI::WindowsAndMessaging::PW_RENDERFULLCONTENT;++use super::enumerate::{minimized, rect};+use super::{hwnd_u64, require_responsive};+use crate::Capture;++/// Windows renders the whole window (including the DWM-composed GL canvas) into the+/// memory DC; `rgb` comes back top-down, 3 bytes per pixel.+pub fn capture(h: HWND) -> Result<Capture, String> {+    if minimized(h) {+        return Err(format!("window {} is minimized; PrintWindow renders nothing for a minimized window (call show_no_activate first)", hwnd_u64(h)));+    }+    // PrintWindow sends WM_PRINT to the target synchronously; a hung KiCad would hold us.+    require_responsive(h, "capture_window", 500)?;+    let (_, _, width, height) = rect(h);+    if width <= 0 || height <= 0 {+        return Err(format!("window {} has an empty rect", hwnd_u64(h)));+    }++    // SAFETY: every GDI object created here is released on every path below; the pixel+    // buffer is sized exactly width * height * 4 and GetDIBits is told the same size+    // through the header. The window is responsive, so PrintWindow returns.+    unsafe {+        let hdc = GetDC(Some(h));+        if hdc.is_invalid() {+            return Err("GetDC failed".into());+        }+        let mem = CreateCompatibleDC(Some(hdc));+        let bmp = CreateCompatibleBitmap(hdc, width, height);+        if mem.is_invalid() || bmp.is_invalid() {+            if !bmp.is_invalid() {+                let _ = DeleteObject(HGDIOBJ(bmp.0));+            }+            if !mem.is_invalid() {+                let _ = DeleteDC(mem);+            }+            ReleaseDC(Some(h), hdc);+            return Err("CreateCompatibleDC/Bitmap failed".into());+        }+        let old = SelectObject(mem, HGDIOBJ(bmp.0));+        let printed = PrintWindow(h, mem, PRINT_WINDOW_FLAGS(PW_RENDERFULLCONTENT)).as_bool();++        let mut bmi = BITMAPINFO::default();+        bmi.bmiHeader = BITMAPINFOHEADER {+            biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,+            biWidth: width,+            biHeight: -height, // negative: top-down rows, no flip needed+            biPlanes: 1,+            biBitCount: 32,+            biCompression: BI_RGB.0,+            ..Default::default()+        };+        let mut bgra = vec![0u8; (width as usize) * (height as usize) * 4];+        let lines = GetDIBits(mem, bmp, 0, height as u32, Some(bgra.as_mut_ptr() as *mut _), &mut bmi, DIB_RGB_COLORS);++        SelectObject(mem, old);+        let _ = DeleteObject(HGDIOBJ(bmp.0));+        let _ = DeleteDC(mem);+        ReleaseDC(Some(h), hdc);++        if !printed {+            return Err(format!("PrintWindow returned FALSE for {}", hwnd_u64(h)));+        }+        if lines <= 0 {+            return Err("GetDIBits returned no scan lines".into());+        }+        let mut rgb = Vec::with_capacity((width as usize) * (height as usize) * 3);+        for px in bgra.chunks_exact(4) {+            rgb.extend_from_slice(&[px[2], px[1], px[0]]);+        }+        Ok(Capture { width: width as u32, height: height as u32, rgb })+    }+}
rust/crates/kicad-platform/src/win/enumerate.rsadded+216
@@ -0,0 +1,216 @@+//! Window enumeration and per-window facts: EnumWindows, EnumChildWindows, class names,+//! titles that never hang, rects, owner, monitor test, the GL canvas child.+//! Python: close_windows.py `_win_find_windows_by_pid`, `_win_title_nohang`,+//! `_win_class_name`, `_win_get_dialog_body_text`; kicad_ui.py `_gl_canvas_rect`;+//! win_focus.py `_enum_kicad_hwnds`, `_visible_on_any_monitor`;+//! open_symbol_editor.py `_find_child_by_class_and_parent`.++use std::collections::HashMap;++use windows::core::BOOL;+use windows::Win32::Foundation::{HWND, LPARAM, RECT};+use windows::Win32::Graphics::Gdi::{GetMonitorInfoW, MonitorFromRect, MONITORINFO, MONITOR_DEFAULTTONULL};+use windows::Win32::UI::WindowsAndMessaging::{+    EnumChildWindows, EnumWindows, GetClassNameW, GetParent, GetWindow, GetWindowRect, GetWindowThreadProcessId,+    InternalGetWindowText, IsIconic, IsWindowVisible, IsZoomed, GW_OWNER, WM_GETTEXT,+};++use super::{from_wide, hwnd_u64, is_kicad_exe, responsive, send_timeout, Sent};+use crate::WindowInfo;++/// Enumeration callback: push every handle into the Vec behind LPARAM.+unsafe extern "system" fn collect(h: HWND, l: LPARAM) -> BOOL {+    // SAFETY: LPARAM carries a `*mut Vec<HWND>` that the enumerating function keeps alive+    // for the whole (synchronous) enumeration; no other reference exists meanwhile.+    let v = unsafe { &mut *(l.0 as *mut Vec<HWND>) };+    v.push(h);+    BOOL(1)+}++/// Every top-level window, visible or not, in z-order (topmost first).+pub fn top_level_windows() -> Vec<HWND> {+    let mut out: Vec<HWND> = Vec::with_capacity(256);+    // SAFETY: `out` outlives the call; `collect` only pushes into it.+    let _ = unsafe { EnumWindows(Some(collect), LPARAM(&mut out as *mut _ as isize)) };+    out+}++/// Every descendant of `parent` (EnumChildWindows walks the whole subtree, which is what+/// the Python callers relied on: the symbol editor's filter `Edit` sits two levels down).+pub fn descendants(parent: HWND) -> Vec<HWND> {+    let mut out: Vec<HWND> = Vec::with_capacity(64);+    // SAFETY: as above; EnumChildWindows is synchronous.+    let _ = unsafe { EnumChildWindows(Some(parent), Some(collect), LPARAM(&mut out as *mut _ as isize)) };+    out+}++pub fn class_name(h: HWND) -> String {+    let mut buf = [0u16; 256];+    // SAFETY: buffer length is passed with the buffer; GetClassNameW reads no cross-thread state.+    let n = unsafe { GetClassNameW(h, &mut buf) };+    from_wide(&buf[..n.max(0) as usize])+}++/// A window's caption without a cross-thread message: `InternalGetWindowText` reads the+/// window's own text store, so a frame frozen behind a modal still yields its title+/// (winvm 2026-07: GetWindowTextW hung the dialog scan). Empty when the store is empty,+/// which is the case for a standard Edit control (its text lives in the control).+pub fn title_nohang(h: HWND) -> String {+    let mut buf = [0u16; 512];+    // SAFETY: buffer length passed with the buffer.+    let n = unsafe { InternalGetWindowText(h, &mut buf) };+    from_wide(&buf[..n.max(0) as usize])+}++/// Text via a time-boxed WM_GETTEXT (`SMTO_ABORTIFHUNG`). Empty on timeout or hang.+pub fn text_timeout(h: HWND, timeout_ms: u32) -> Option<String> {+    let mut buf = vec![0u16; 4096];+    match send_timeout(h, WM_GETTEXT, buf.len() - 1, buf.as_mut_ptr() as isize, timeout_ms) {+        Sent::Answered(n) => Some(from_wide(&buf[..n.min(buf.len() - 1)])),+        _ => None,+    }+}++pub fn pid_of(h: HWND) -> u32 {+    let mut pid = 0u32;+    // SAFETY: `pid` outlives the call.+    unsafe { GetWindowThreadProcessId(h, Some(&mut pid)) };+    pid+}++pub fn thread_of(h: HWND) -> u32 {+    // SAFETY: no out pointer requested.+    unsafe { GetWindowThreadProcessId(h, None) }+}++/// (x, y, width, height) in physical pixels (the process is per-monitor DPI aware).+pub fn rect(h: HWND) -> (i32, i32, i32, i32) {+    let mut r = RECT::default();+    // SAFETY: `r` outlives the call.+    if unsafe { GetWindowRect(h, &mut r) }.is_err() {+        return (0, 0, 0, 0);+    }+    (r.left, r.top, r.right - r.left, r.bottom - r.top)+}++pub fn owner(h: HWND) -> u64 {+    // SAFETY: GetWindow reads the window manager's tables only.+    unsafe { GetWindow(h, GW_OWNER) }.map(hwnd_u64).unwrap_or(0)+}++pub fn parent(h: HWND) -> u64 {+    // SAFETY: as above.+    unsafe { GetParent(h) }.map(hwnd_u64).unwrap_or(0)+}++pub fn visible(h: HWND) -> bool {+    // SAFETY: table read.+    unsafe { IsWindowVisible(h).as_bool() }+}++pub fn minimized(h: HWND) -> bool {+    // SAFETY: table read.+    unsafe { IsIconic(h).as_bool() }+}++pub fn maximized(h: HWND) -> bool {+    // SAFETY: table read.+    unsafe { IsZoomed(h).as_bool() }+}++/// Cache of pid -> executable base name so a full enumeration opens each process once.+#[derive(Default)]+pub struct ExeCache(HashMap<u32, String>);++impl ExeCache {+    pub fn exe(&mut self, pid: u32) -> String {+        self.0.entry(pid).or_insert_with(|| super::process::exe_base_name(pid)).clone()+    }+}++/// Assemble a `WindowInfo` for a top-level window. `probe_ms` > 0 runs the WM_NULL+/// responsiveness probe (only worth paying for on KiCad windows during a bulk listing).+pub fn info(h: HWND, cache: &mut ExeCache, probe_ms: u32) -> WindowInfo {+    let pid = pid_of(h);+    let exe = cache.exe(pid);+    let hung = probe_ms > 0 && !responsive(h, probe_ms);+    WindowInfo {+        hwnd: hwnd_u64(h),+        title: title_nohang(h),+        class_name: class_name(h),+        pid,+        exe,+        visible: visible(h),+        owner: owner(h),+        rect: rect(h),+        minimized: minimized(h),+        maximized: maximized(h),+        hung,+    }+}++/// Every visible top-level window. KiCad windows get the 150 ms WM_NULL probe so `hung`+/// means something; other applications' threads are left alone.+pub fn list_visible() -> Vec<WindowInfo> {+    let mut cache = ExeCache::default();+    top_level_windows()+        .into_iter()+        .filter(|&h| visible(h))+        .map(|h| {+            let probe = if is_kicad_exe(&cache.exe(pid_of(h))) { 150 } else { 0 };+            info(h, &mut cache, probe)+        })+        .collect()+}++/// Every descendant of a window with class and text, `owner` carrying the PARENT handle+/// so a caller can join "the `Edit` whose parent is titled `searchCtrl`". Text comes from+/// the window's own store first; when the dialog's thread answers a WM_NULL, controls+/// with an empty store (Edit boxes) get a short time-boxed WM_GETTEXT as well.+pub fn children(parent_hwnd: HWND) -> Vec<WindowInfo> {+    let mut cache = ExeCache::default();+    let pumping = responsive(parent_hwnd, 150);+    descendants(parent_hwnd)+        .into_iter()+        .map(|h| {+            let mut w = info(h, &mut cache, 0);+            w.owner = parent(h);+            if w.title.is_empty() && pumping {+                if let Some(t) = text_timeout(h, 150) {+                    w.title = t;+                }+            }+            w+        })+        .collect()+}++/// The wxGLCanvas child's rect relative to the top-left of `frame`'s window rect (the+/// same box `capture_window` renders), as (x, y, width, height). None when no GL canvas+/// exists (Cairo fallback canvas, or not an editor).+pub fn gl_canvas_rect(frame: HWND) -> Option<(i32, i32, i32, i32)> {+    let canvas = descendants(frame).into_iter().find(|&c| class_name(c) == "wxGLCanvas")?;+    let (cx, cy, cw, ch) = rect(canvas);+    let (fx, fy, _, _) = rect(frame);+    Some((cx - fx, cy - fy, cw, ch))+}++/// Is any part of the (x, y, w, h) rect on a monitor? `MONITOR_DEFAULTTONULL`, so a+/// second or third display counts exactly like the primary.+pub fn on_a_monitor(r: (i32, i32, i32, i32)) -> bool {+    let rc = RECT { left: r.0, top: r.1, right: r.0 + r.2, bottom: r.1 + r.3 };+    // SAFETY: `rc` outlives the call.+    let hmon = unsafe { MonitorFromRect(&rc, MONITOR_DEFAULTTONULL) };+    if hmon.is_invalid() {+        return false;+    }+    let mut mi = MONITORINFO { cbSize: std::mem::size_of::<MONITORINFO>() as u32, ..Default::default() };+    // SAFETY: cbSize is set; `mi` outlives the call.+    if !unsafe { GetMonitorInfoW(hmon, &mut mi) }.as_bool() {+        return false;+    }+    let m = mi.rcMonitor;+    let vis_w = rc.right.min(m.right) - rc.left.max(m.left);+    let vis_h = rc.bottom.min(m.bottom) - rc.top.max(m.top);+    vis_w > 0 && vis_h > 0+}
rust/crates/kicad-platform/src/win/focus.rsadded+143
@@ -0,0 +1,143 @@+//! Foreground, z-order and placement. The rules here are John's, verbatim from+//! win_focus.py 0.9.181: never minimize (a minimized window cannot be screenshotted),+//! never move a window off screen, never WS_EX_NOACTIVATE (it blocks the user's own+//! click), never install a hook. One z-order demotion (`push_to_background`) and one+//! sanctioned activation (`bring_to_front`), which reports what Windows actually did.+//! Python: `_set_foreground_robust`, `bring_to_user`, `show_without_activating`,+//! `_push_kicad_to_background`, `capture_placement`, `restore_placement`.++use windows::Win32::Foundation::{HWND, RECT};+use windows::Win32::System::Threading::GetCurrentThreadId;+use windows::Win32::UI::WindowsAndMessaging::{+    BringWindowToTop, GetForegroundWindow, GetWindowPlacement, SetForegroundWindow, SetWindowPlacement, SetWindowPos, ShowWindow,+    HWND_BOTTOM, SC_MAXIMIZE, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SW_RESTORE, SW_SHOW, SW_SHOWMAXIMIZED, SW_SHOWNOACTIVATE,+    SW_SHOWMINIMIZED, WINDOWPLACEMENT, WM_SYSCOMMAND, WPF_RESTORETOMAXIMIZED,+};++use super::enumerate::{minimized, thread_of};+use super::messages::post;+use super::{hwnd_u64, require_responsive};+use crate::Placement;++/// Deadline for the WM_NULL gate in front of the calls Windows makes synchronous.+const GATE_MS: u32 = 300;++pub fn foreground() -> u64 {+    // SAFETY: no arguments; reads the foreground handle.+    hwnd_u64(unsafe { GetForegroundWindow() })+}++/// THE backgrounding primitive (0.9.181): exactly one SetWindowPos to HWND_BOTTOM with+/// SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE. Not minimized, not moved, not restyled.+/// Once-per-window bookkeeping belongs to the etiquette layer, not here.+pub fn push_to_background(h: HWND) -> Result<(), String> {+    // SetWindowPos sends WM_WINDOWPOSCHANGING to the target synchronously; gate it.+    require_responsive(h, "push_to_background", GATE_MS)?;+    // SAFETY: valid, responsive window; flags forbid move, size and activation.+    unsafe { SetWindowPos(h, Some(HWND_BOTTOM), 0, 0, 0, 0, SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE) }+        .map_err(|e| format!("SetWindowPos(HWND_BOTTOM) on {}: {e}", hwnd_u64(h)))+}++/// Make a window visible (restoring it if minimized) without activating it. The+/// strongest thing the bridge does to a window on its own initiative.+pub fn show_no_activate(h: HWND) -> Result<(), String> {+    require_responsive(h, "show_no_activate", GATE_MS)?;+    // SAFETY: valid, responsive window. Return value is the previous visibility, not an error.+    let _ = unsafe { ShowWindow(h, SW_SHOWNOACTIVATE) };+    Ok(())+}++/// The one sanctioned foreground. A plain SetForegroundWindow from a background process+/// is refused by the foreground lock, so we attach our input queue to the current+/// foreground thread and the target thread first (the documented AttachThreadInput+/// technique), raise, activate, detach, then MEASURE: the result is whether+/// GetForegroundWindow reports the target afterwards, not whether the calls returned.+/// Minimized windows are restored; anything else keeps its size (SW_RESTORE would+/// un-maximize a maximized editor, which the Python version got wrong).+pub fn bring_to_front(h: HWND) -> Result<bool, String> {+    require_responsive(h, "bring_to_front", GATE_MS)?;+    // SAFETY: reading the foreground handle.+    let fg = unsafe { GetForegroundWindow() };+    if fg == h {+        return Ok(true);+    }+    // SAFETY: valid, responsive window.+    let _ = unsafe { ShowWindow(h, if minimized(h) { SW_RESTORE } else { SW_SHOW }) };++    // SAFETY: thread ids are plain integers; every successful attach is detached below+    // on the same thread before this function returns.+    let attached = unsafe {+        use windows::Win32::System::Threading::AttachThreadInput;+        let cur = GetCurrentThreadId();+        let mut attached = Vec::with_capacity(2);+        let fg_tid = if fg.is_invalid() { 0 } else { thread_of(fg) };+        for tid in [fg_tid, thread_of(h)] {+            if tid != 0 && tid != cur && !attached.contains(&tid) && AttachThreadInput(cur, tid, true).as_bool() {+                attached.push(tid);+            }+        }+        let _ = BringWindowToTop(h);+        let _ = SetForegroundWindow(h);+        attached+    };+    // SAFETY: detaching exactly what was attached above.+    unsafe {+        use windows::Win32::System::Threading::AttachThreadInput;+        let cur = GetCurrentThreadId();+        for tid in attached {+            let _ = AttachThreadInput(cur, tid, false);+        }+    }+    // Let the shell settle before measuring; the activation is processed asynchronously+    // by the target thread.+    std::thread::sleep(std::time::Duration::from_millis(60));+    // SAFETY: reading the foreground handle.+    Ok(unsafe { GetForegroundWindow() } == h)+}++fn read_placement(h: HWND) -> Result<WINDOWPLACEMENT, String> {+    let mut wp = WINDOWPLACEMENT { length: std::mem::size_of::<WINDOWPLACEMENT>() as u32, ..Default::default() };+    // SAFETY: length is set; `wp` outlives the call. GetWindowPlacement reads window state+    // without sending a message.+    unsafe { GetWindowPlacement(h, &mut wp) }.map_err(|e| format!("GetWindowPlacement({}): {e}", hwnd_u64(h)))?;+    Ok(wp)+}++/// The restored (normal) rectangle and the maximized flag, in the workspace coordinates+/// GetWindowPlacement uses, so `set_placement` round-trips exactly across a restart.+pub fn placement(h: HWND) -> Result<Placement, String> {+    let wp = read_placement(h)?;+    let r = wp.rcNormalPosition;+    Ok(Placement {+        x: r.left,+        y: r.top,+        width: r.right - r.left,+        height: r.bottom - r.top,+        // A minimized window remembers whether it was maximized in the flags.+        maximized: wp.showCmd == SW_SHOWMAXIMIZED.0 as u32+            || (wp.showCmd == SW_SHOWMINIMIZED.0 as u32 && wp.flags.contains(WPF_RESTORETOMAXIMIZED)),+    })+}++/// Put a window back where the user had it, without activating it and NEVER minimized+/// (a minimized window blanks PrintWindow, which silently breaks the evidence pipeline,+/// #48 ask 4). The rect lands through SetWindowPlacement with SW_SHOWNOACTIVATE; a+/// maximized placement is then requested from the window's own thread with a posted+/// WM_SYSCOMMAND SC_MAXIMIZE, because every maximize command Windows offers a foreign+/// process activates the window.+pub fn set_placement(h: HWND, p: &Placement) -> Result<(), String> {+    require_responsive(h, "set_placement", GATE_MS)?;+    if p.width < 50 || p.height < 50 {+        return Err(format!("refusing a {}x{} placement (a minimized or parked stub is not a placement)", p.width, p.height));+    }+    let mut wp = read_placement(h)?;+    wp.flags = Default::default();+    wp.showCmd = SW_SHOWNOACTIVATE.0 as u32;+    wp.rcNormalPosition = RECT { left: p.x, top: p.y, right: p.x + p.width, bottom: p.y + p.height };+    // SAFETY: length set by read_placement; `wp` outlives the call; the window is responsive.+    unsafe { SetWindowPlacement(h, &wp) }.map_err(|e| format!("SetWindowPlacement({}): {e}", hwnd_u64(h)))?;+    if p.maximized {+        post(h, WM_SYSCOMMAND, SC_MAXIMIZE as usize, 0)?;+    }+    Ok(())+}
rust/crates/kicad-platform/src/win/menu.rsadded+95
@@ -0,0 +1,95 @@+//! The native menu bar: walk it, read ids, fire WM_COMMAND. Plugin-free and focus-free+//! (issue #26). wxFrame menus on Windows are real Win32 menus attached with SetMenu, so+//! GetMenu / GetSubMenu / GetMenuStringW / GetMenuItemID read them from any process, and+//! ids resolved from the live bar are version-proof where hardcoded ids were not.+//! Python: win_menu.py `find_menu_command`, `invoke_menu_command`, `find_by_accelerator`.++use windows::Win32::Foundation::HWND;+use windows::Win32::UI::WindowsAndMessaging::{GetMenu, GetMenuItemCount, GetMenuItemID, GetMenuStringW, GetSubMenu, HMENU, MF_BYPOSITION, WM_COMMAND};++use super::messages::post;+use super::{from_wide, hwnd_u64, strip_amp};+use crate::MenuItem;++/// Deepest submenu level walked (win_menu.py `max_depth`).+const MAX_DEPTH: usize = 4;++fn item_text(menu: HMENU, pos: u32) -> String {+    let mut buf = [0u16; 256];+    // SAFETY: buffer passed with its length; MF_BYPOSITION means `pos` is an index.+    let n = unsafe { GetMenuStringW(menu, pos, Some(&mut buf), MF_BYPOSITION) };+    from_wide(&buf[..n.max(0) as usize])+}++/// Split "Save\tCtrl+S" into ("Save", Some("Ctrl+S")), mnemonics stripped.+pub fn split_caption(raw: &str) -> (String, Option<String>) {+    match raw.split_once('\t') {+        Some((label, accel)) => (strip_amp(label).trim().to_string(), Some(accel.trim().to_string()).filter(|a| !a.is_empty())),+        None => (strip_amp(raw).trim().to_string(), None),+    }+}++fn walk(menu: HMENU, depth: usize, path: &mut Vec<String>, out: &mut Vec<MenuItem>) {+    // SAFETY: a menu handle read from another process's window is still a valid global+    // user object for these read-only queries.+    let n = unsafe { GetMenuItemCount(Some(menu)) };+    for i in 0..n.max(0) as u32 {+        let (label, accel) = split_caption(&item_text(menu, i));+        // SAFETY: as above.+        let sub = unsafe { GetSubMenu(menu, i as i32) };+        if !sub.is_invalid() {+            if depth < MAX_DEPTH {+                path.push(label);+                walk(sub, depth + 1, path, out);+                path.pop();+            }+            continue;+        }+        if label.is_empty() {+            continue; // separator+        }+        // SAFETY: as above.+        let id = unsafe { GetMenuItemID(menu, i as i32) };+        if id == 0 || id == u32::MAX {+            continue;+        }+        let mut full = path.clone();+        full.push(label);+        out.push(MenuItem { path: full, id, accel });+    }+}++/// Every leaf command in the window's menu bar, top level first in each path. Empty when+/// the window has no Win32 menu (an owner-drawn or wx-custom bar, or not a frame).+pub fn tree(h: HWND) -> Result<Vec<MenuItem>, String> {+    // SAFETY: GetMenu reads the window's menu handle; no message is sent.+    let bar = unsafe { GetMenu(h) };+    if bar.is_invalid() {+        return Err(format!("window {} has no Win32 menu bar (wrong hwnd, a dialog, or a custom-drawn bar)", hwnd_u64(h)));+    }+    let mut out = Vec::with_capacity(128);+    walk(bar, 0, &mut Vec::new(), &mut out);+    Ok(out)+}++/// PostMessageW WM_COMMAND with the menu id: the action runs on KiCad's thread with no+/// focus, no cursor and no wait.+pub fn invoke(h: HWND, id: u32) -> Result<(), String> {+    if id == 0 {+        return Err("menu id 0 is not a command".into());+    }+    post(h, WM_COMMAND, id as usize, 0)+}++#[cfg(test)]+mod tests {+    use super::split_caption;++    #[test]+    fn captions_split_into_label_and_accelerator() {+        assert_eq!(split_caption("&Save\tCtrl+S"), ("Save".into(), Some("Ctrl+S".into())));+        assert_eq!(split_caption("3D &Viewer\tAlt+3"), ("3D Viewer".into(), Some("Alt+3".into())));+        assert_eq!(split_caption("E&xit"), ("Exit".into(), None));+        assert_eq!(split_caption(""), (String::new(), None));+    }+}
rust/crates/kicad-platform/src/win/messages.rsadded+130
@@ -0,0 +1,130 @@+//! Posted and time-boxed messages to one hwnd: keys, clicks, text, WM_CLOSE, BM_CLICK,+//! plus the idle clock and the GUI-thread focus probe.+//! Python: kicad_ui.py `_post_key`, `_post_click`; close_windows.py `_win_click_button`,+//! `_win_dismiss_dialog`, `_win_close_and_wait`; navigate_symbol.ps1 (WM_SETTEXT);+//! win_focus.py `_seconds_since_user_input`; open_symbol_editor.py `_gui_thread_focus`.++use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};+use windows::Win32::System::SystemInformation::GetTickCount;+use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};+use windows::Win32::UI::WindowsAndMessaging::{+    GetGUIThreadInfo, PostMessageW, BM_CLICK, GUITHREADINFO, WM_CHAR, WM_CLOSE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDBLCLK,+    WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_SETTEXT,+};++use super::enumerate::{class_name, descendants, text_timeout, thread_of, title_nohang, visible};+use super::{hwnd_u64, pack_point, send_timeout, strip_amp, wide, Sent};++/// MK_LBUTTON: the left button is down while the message is delivered.+const MK_LBUTTON: usize = 0x0001;++/// PostMessageW to one window's queue: no focus, no shared input queue, never blocks.+pub fn post(h: HWND, msg: u32, wparam: usize, lparam: isize) -> Result<(), String> {+    // SAFETY: PostMessageW copies its scalar arguments; nothing is borrowed after the call.+    unsafe { PostMessageW(Some(h), msg, WPARAM(wparam), LPARAM(lparam)) }.map_err(|e| format!("PostMessageW(0x{:x}) to {}: {e}", msg, hwnd_u64(h)))+}++pub fn post_close(h: HWND) -> Result<(), String> {+    post(h, WM_CLOSE, 0, 0)+}++/// WM_KEYDOWN, optional WM_CHAR, WM_KEYUP. Chords are never attempted this way (the menu+/// walk resolves them to WM_COMMAND instead).+pub fn post_key(h: HWND, vk: u16, ch: Option<char>) -> Result<(), String> {+    post(h, WM_KEYDOWN, vk as usize, 0)?;+    if let Some(c) = ch {+        post(h, WM_CHAR, c as usize, 0)?;+    }+    post(h, WM_KEYUP, vk as usize, 0)+}++/// A left click at client coordinates, posted, so the user's physical pointer never moves.+/// `double` appends the WM_LBUTTONDBLCLK pair the tree rows in the editors respond to.+pub fn post_click(h: HWND, x: i32, y: i32, double: bool) -> Result<(), String> {+    let lp = pack_point(x, y);+    post(h, WM_MOUSEMOVE, 0, lp)?;+    post(h, WM_LBUTTONDOWN, MK_LBUTTON, lp)?;+    post(h, WM_LBUTTONUP, 0, lp)?;+    if double {+        post(h, WM_LBUTTONDBLCLK, MK_LBUTTON, lp)?;+        post(h, WM_LBUTTONUP, 0, lp)?;+    }+    Ok(())+}++/// WM_SETTEXT, time-boxed. The edit control's EN_CHANGE fires on the target thread, which+/// is how the library tree filter reacts without any keystroke.+pub fn set_text(h: HWND, text: &str) -> Result<(), String> {+    let buf = wide(text);+    match send_timeout(h, WM_SETTEXT, 0, buf.as_ptr() as isize, 1500) {+        Sent::Answered(_) => Ok(()),+        Sent::TimedOut => Err(format!("timeout: WM_SETTEXT to {} was delivered but the control did not return within 1500 ms (the text may still have landed; verify by reading it back)", hwnd_u64(h))),+        Sent::Failed => Err(format!("not_delivered: WM_SETTEXT to {} (window hung or gone)", hwnd_u64(h))),+    }+}++/// Text of a control with a deadline: WM_GETTEXT first (the live contents of an Edit),+/// the window's own text store when the control does not answer in time.+pub fn window_text(h: HWND, timeout_ms: u32) -> Result<String, String> {+    if let Some(t) = text_timeout(h, timeout_ms) {+        return Ok(t);+    }+    Ok(title_nohang(h))+}++/// Find a visible `Button` child whose caption (mnemonic stripped, trimmed, lower case)+/// is one of `labels` (compared lower case).+pub fn find_button(dialog: HWND, labels: &[&str]) -> Option<(HWND, String)> {+    let wanted: Vec<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect();+    descendants(dialog).into_iter().find_map(|c| {+        if class_name(c) != "Button" || !visible(c) {+            return None;+        }+        let caption = title_nohang(c);+        let key = strip_amp(&caption).trim().to_lowercase();+        wanted.contains(&key).then_some((c, caption))+    })+}++/// BM_CLICK through SendMessageTimeoutW (1500 ms, SMTO_ABORTIFHUNG). NEVER a plain+/// SendMessage (#83, winvm 2026-09-07): the first-run wizard's Cancel opens its "Are you+/// sure?" Confirmation MODALLY inside the button handler, so a synchronous click does not+/// return until that box is answered, and the only thing that would answer it is the+/// sweep that is blocked. Time-boxed, the click is delivered, we get control back, and the+/// next pass answers the follow-up dialog. Returns the method string for the verb's record.+pub fn bm_click(dialog: HWND, labels: &[&str]) -> Option<String> {+    let (btn, caption) = find_button(dialog, labels)?;+    Some(match send_timeout(btn, BM_CLICK, 0, 0, 1500) {+        Sent::Answered(_) => format!("bm_click:{caption}"),+        Sent::TimedOut => format!("bm_click_timeout:{caption}"),+        Sent::Failed => format!("bm_click_not_delivered:{caption}"),+    })+}++/// Seconds since the user's last keyboard or mouse input, system wide (GetLastInputInfo).+/// Tick arithmetic wraps every 49.7 days, hence the wrapping subtraction.+pub fn seconds_since_input() -> Result<f64, String> {+    let mut lii = LASTINPUTINFO { cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32, dwTime: 0 };+    // SAFETY: cbSize is set; `lii` outlives the call.+    if !unsafe { GetLastInputInfo(&mut lii) }.as_bool() {+        return Err("GetLastInputInfo failed".into());+    }+    // SAFETY: no arguments.+    let now = unsafe { GetTickCount() };+    Ok(now.wrapping_sub(lii.dwTime) as f64 / 1000.0)+}++/// The hwnd holding keyboard focus inside the thread that owns `frame` (GetGUIThreadInfo;+/// cross-process, no focus change). Ready for the verb layer's "did the filter box take+/// the text" check; not on the trait yet.+#[allow(dead_code)]+pub fn gui_thread_focus(frame: HWND) -> Option<u64> {+    let tid = thread_of(frame);+    if tid == 0 {+        return None;+    }+    let mut gti = GUITHREADINFO { cbSize: std::mem::size_of::<GUITHREADINFO>() as u32, ..Default::default() };+    // SAFETY: cbSize is set; `gti` outlives the call.+    unsafe { GetGUIThreadInfo(tid, &mut gti) }.ok()?;+    (!gti.hwndFocus.is_invalid()).then(|| hwnd_u64(gti.hwndFocus))+}
rust/crates/kicad-platform/src/win/mod.rsadded+176
@@ -0,0 +1,176 @@+//! Windows window-control primitives (phase 3 of docs/rust-port-plan.md), ported from the+//! Python bridge's handlers/win_focus.py, close_windows.py, win_menu.py, kicad_ui.py,+//! uia.py and navigate_symbol.ps1. Each module is one family of primitives; windows.rs+//! wires them into the `Platform` trait.+//!+//! House rules, enforced here and not left to callers:+//! - Every blocking cross-process message goes through `SendMessageTimeoutW` with+//!   `SMTO_ABORTIFHUNG` and a deadline (`send_timeout`). Nothing in this layer may hang the+//!   bridge on a stuck KiCad (0.9.160 stranded a parked window for hours, 0.9.165 took the+//!   whole bridge down on the boot path, #83 deadlocked on a modal inside a button handler).+//! - Calls that Windows itself turns into synchronous messages (SetWindowPos, ShowWindow,+//!   SetWindowPlacement, BringWindowToTop, PrintWindow, UIA) are gated on `responsive()`+//!   first and refused on a hung window, for the same reason.+//! - No SendInput, keybd_event, mouse_event, SetCursorPos, SetWindowsHookEx, no minimize,+//!   no off-screen parking, no WS_EX_NOACTIVATE. `push_to_background` is the only z-order+//!   demotion and `bring_to_front` the only sanctioned activation.+//! - Handles cross the trait as u64 and become `HWND` here; wide strings are built with+//!   `wide()` and read back with `from_wide()`.++pub mod capture;+pub mod enumerate;+pub mod focus;+pub mod menu;+pub mod messages;+pub mod process;+pub mod uia;++use std::ffi::c_void;++use windows::Win32::Foundation::{GetLastError, ERROR_TIMEOUT, HWND, LPARAM, WPARAM};+use windows::Win32::UI::WindowsAndMessaging::{IsWindow, SendMessageTimeoutW, SMTO_ABORTIFHUNG};++/// Executable base names (lower case) that count as KiCad. Mirrors `_KICAD_EXES` in+/// win_focus.py plus gerbview, which close_windows.py's loose scan included.+pub const KICAD_EXES: &[&str] = &[+    "kicad.exe",+    "eeschema.exe",+    "pcbnew.exe",+    "kicad-cli.exe",+    "pl_editor.exe",+    "bitmap2component.exe",+    "pcb_calculator.exe",+    "gerbview.exe",+];++pub fn is_kicad_exe(exe: &str) -> bool {+    KICAD_EXES.iter().any(|k| k.eq_ignore_ascii_case(exe))+}++/// u64 (trait side) to HWND (this side). A window handle is a 32-bit value zero-extended+/// into a pointer-sized field, so the round trip is exact.+pub fn hwnd(h: u64) -> HWND {+    HWND(h as usize as *mut c_void)+}++pub fn hwnd_u64(h: HWND) -> u64 {+    h.0 as usize as u64+}++/// Is this a live window handle? Cheap (no cross-thread message).+pub fn is_window(h: HWND) -> bool {+    // SAFETY: IsWindow accepts any value and only reads user32's handle table.+    unsafe { IsWindow(Some(h)).as_bool() }+}++/// Validate a trait-side handle before touching it.+pub fn live(h: u64) -> Result<HWND, String> {+    let w = hwnd(h);+    if h == 0 || !is_window(w) {+        return Err(format!("hwnd {h} is not a window"));+    }+    Ok(w)+}++/// NUL-terminated UTF-16.+pub fn wide(s: &str) -> Vec<u16> {+    s.encode_utf16().chain(std::iter::once(0)).collect()+}++/// UTF-16 buffer to String, stopping at the first NUL (or the whole slice).+pub fn from_wide(buf: &[u16]) -> String {+    let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());+    String::from_utf16_lossy(&buf[..end])+}++/// Outcome of a time-boxed cross-process send.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Sent {+    /// The window procedure returned; the value is the LRESULT written by the target.+    Answered(usize),+    /// Delivered, but the target did not return within the deadline (a modal loop inside+    /// the handler, #83). The message is still being processed on the target's thread.+    TimedOut,+    /// Not delivered: the target thread is hung (SMTO_ABORTIFHUNG) or the handle is bad.+    Failed,+}++/// The one blocking primitive in this layer: `SendMessageTimeoutW` with+/// `SMTO_ABORTIFHUNG` and a deadline. Never a plain SendMessageW.+pub fn send_timeout(h: HWND, msg: u32, wparam: usize, lparam: isize, timeout_ms: u32) -> Sent {+    let mut result: usize = 0;+    // SAFETY: `result` outlives the call; lparam is either a scalar or a pointer the caller+    // keeps alive for the duration of this synchronous call.+    let ret = unsafe {+        SendMessageTimeoutW(h, msg, WPARAM(wparam), LPARAM(lparam), SMTO_ABORTIFHUNG, timeout_ms, Some(&mut result))+    };+    if ret.0 != 0 {+        return Sent::Answered(result);+    }+    // SAFETY: plain thread-local error read.+    if unsafe { GetLastError() } == ERROR_TIMEOUT {+        Sent::TimedOut+    } else {+        Sent::Failed+    }+}++/// Does the window's thread pump messages? `SendMessageTimeoutW(WM_NULL)` with+/// `SMTO_ABORTIFHUNG` (win_focus.py `_window_responsive`). The gate in front of every call+/// Windows would otherwise turn into a synchronous cross-process message.+pub fn responsive(h: HWND, timeout_ms: u32) -> bool {+    use windows::Win32::UI::WindowsAndMessaging::WM_NULL;+    matches!(send_timeout(h, WM_NULL, 0, 0, timeout_ms), Sent::Answered(_))+}++/// Refuse an operation on a window that failed the probe, with a message that says why.+pub fn require_responsive(h: HWND, what: &str, timeout_ms: u32) -> Result<(), String> {+    if responsive(h, timeout_ms) {+        Ok(())+    } else {+        Err(format!("{what}: window {} did not answer WM_NULL within {timeout_ms} ms (hung or busy); refusing so the bridge cannot block on it", hwnd_u64(h)))+    }+}++/// Client-coordinate pair packed the way mouse messages carry it: y in the high word,+/// x in the low word (kicad_ui.py `_post_click`).+pub fn pack_point(x: i32, y: i32) -> isize {+    (((y & 0xFFFF) << 16) | (x & 0xFFFF)) as isize+}++/// Menu and button captions carry '&' mnemonics; comparisons strip them.+pub fn strip_amp(s: &str) -> String {+    s.replace('&', "")+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn wide_round_trip() {+        let w = wide("KiCad 10.0");+        assert_eq!(w.last(), Some(&0));+        assert_eq!(from_wide(&w), "KiCad 10.0");+        assert_eq!(from_wide(&[0x41, 0x42, 0, 0x43]), "AB");+    }++    #[test]+    fn point_packing_matches_python() {+        // (cy << 16) | (cx & 0xFFFF)+        assert_eq!(pack_point(10, 58), (58 << 16) | 10);+        assert_eq!(pack_point(0x1234, 0x5678), 0x5678_1234);+    }++    #[test]+    fn hwnd_round_trip() {+        assert_eq!(hwnd_u64(hwnd(0x0001_0ABC)), 0x0001_0ABC);+        assert!(hwnd(0).is_invalid());+    }++    #[test]+    fn kicad_exe_set() {+        assert!(is_kicad_exe("PCBNEW.EXE"));+        assert!(!is_kicad_exe("chrome.exe"));+    }+}
rust/crates/kicad-platform/src/win/process.rsadded+300
@@ -0,0 +1,300 @@+//! Process queries and the quiet GUI spawn.+//! Python: win_focus.py `_hwnd_exe_name`, `_pid_exe_base`, `_parent_pid_map`,+//! `background_startupinfo`; close_windows.py `_win_get_kicad_pids`;+//! launch.py's Popen with STARTF_USESHOWWINDOW / SW_SHOWNOACTIVATE and stderr to a log.++use std::os::windows::io::AsRawHandle;+use std::path::Path;++use windows::core::{PCWSTR, PWSTR};+use windows::Win32::Foundation::{+    CloseHandle, GetLastError, SetHandleInformation, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, FILETIME, HANDLE,+    HANDLE_FLAG_INHERIT,+};+use windows::Win32::System::Diagnostics::ToolHelp::{+    CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,+};+use windows::Win32::System::Threading::{+    CreateProcessW, GetExitCodeProcess, GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, TerminateProcess,+    CREATE_UNICODE_ENVIRONMENT, DETACHED_PROCESS, PROCESS_INFORMATION, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,+    PROCESS_TERMINATE, STARTF_USESHOWWINDOW, STARTF_USESTDHANDLES, STARTUPINFOW,+};+use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNOACTIVATE;++use super::{from_wide, is_kicad_exe, wide};+use crate::ProcessInfo;++/// Process exit code that means "still running" (STILL_ACTIVE).+const STILL_ACTIVE_CODE: u32 = 259;++/// Owned process handle that closes on drop.+struct Proc(HANDLE);++impl Drop for Proc {+    fn drop(&mut self) {+        // SAFETY: the handle came from OpenProcess and is closed exactly once.+        unsafe {+            let _ = CloseHandle(self.0);+        }+    }+}++fn open_limited(pid: u32) -> Option<Proc> {+    // SAFETY: OpenProcess with a query-only right; a failure is an Err, not a bad handle.+    unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok().map(Proc)+}++/// Full image path of a process ("" when it cannot be opened, e.g. a protected process).+pub fn exe_path(pid: u32) -> String {+    let Some(p) = open_limited(pid) else { return String::new() };+    let mut buf = vec![0u16; 1024];+    let mut len = buf.len() as u32;+    // SAFETY: `len` is the buffer capacity in characters and is updated to the length written.+    match unsafe { QueryFullProcessImageNameW(p.0, PROCESS_NAME_WIN32, PWSTR(buf.as_mut_ptr()), &mut len) } {+        Ok(()) => from_wide(&buf[..len as usize]),+        Err(_) => String::new(),+    }+}++/// Executable base name, lower case ("pcbnew.exe"), "" when unknown.+pub fn exe_base_name(pid: u32) -> String {+    base_name(&exe_path(pid))+}++fn base_name(path: &str) -> String {+    path.rsplit(['\\', '/']).next().unwrap_or("").to_ascii_lowercase()+}++/// Unix epoch seconds when the process started, if the process can be opened.+fn started_at(p: &Proc) -> Option<u64> {+    let mut create = FILETIME::default();+    let mut exit = FILETIME::default();+    let mut kernel = FILETIME::default();+    let mut user = FILETIME::default();+    // SAFETY: four out-params that outlive the call.+    unsafe { GetProcessTimes(p.0, &mut create, &mut exit, &mut kernel, &mut user) }.ok()?;+    let ft = ((create.dwHighDateTime as u64) << 32) | create.dwLowDateTime as u64;+    // FILETIME counts 100 ns ticks since 1601-01-01; 116444736000000000 ticks to 1970.+    Some(ft.saturating_sub(116_444_736_000_000_000) / 10_000_000)+}++/// One Toolhelp pass: (pid, parent pid, exe base name from the snapshot) for every process.+pub fn snapshot() -> Vec<(u32, u32, String)> {+    let mut out = Vec::with_capacity(256);+    // SAFETY: the snapshot handle is closed below on every path.+    let Ok(snap) = (unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }) else { return out };+    let mut pe = PROCESSENTRY32W { dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };+    // SAFETY: dwSize is set; `pe` outlives each call.+    if unsafe { Process32FirstW(snap, &mut pe) }.is_ok() {+        loop {+            out.push((pe.th32ProcessID, pe.th32ParentProcessID, from_wide(&pe.szExeFile).to_ascii_lowercase()));+            // SAFETY: as above.+            if unsafe { Process32NextW(snap, &mut pe) }.is_err() {+                break;+            }+        }+    }+    // SAFETY: closing the snapshot handle we own.+    unsafe {+        let _ = CloseHandle(snap);+    }+    out+}++/// Running processes whose exe base name is in `exe_names` (any KiCad exe when empty).+/// The Toolhelp snapshot supplies pid, parent and name even for processes we cannot open;+/// the full path and start time need `PROCESS_QUERY_LIMITED_INFORMATION` and are best effort.+pub fn list(exe_names: &[&str]) -> Vec<ProcessInfo> {+    let wanted = |exe: &str| {+        if exe_names.is_empty() {+            is_kicad_exe(exe)+        } else {+            exe_names.iter().any(|n| n.eq_ignore_ascii_case(exe))+        }+    };+    snapshot()+        .into_iter()+        .filter(|(pid, _, exe)| *pid != 0 && wanted(exe))+        .map(|(pid, ppid, exe)| {+            let p = open_limited(pid);+            let path = exe_path(pid).replace('\\', "/");+            ProcessInfo { pid, exe, path, parent_pid: ppid, started_at: p.as_ref().and_then(started_at) }+        })+        .collect()+}++/// Is the process alive? `OpenProcess` + `GetExitCodeProcess`; a pid that does not exist+/// fails with ERROR_INVALID_PARAMETER, a protected process with ERROR_ACCESS_DENIED+/// (alive, just not ours to inspect). None only when Windows answers something else.+pub fn alive(pid: u32) -> Option<bool> {+    if pid == 0 {+        return Some(false);+    }+    match open_limited(pid) {+        Some(p) => {+            let mut code = 0u32;+            // SAFETY: `code` outlives the call.+            match unsafe { GetExitCodeProcess(p.0, &mut code) } {+                Ok(()) => Some(code == STILL_ACTIVE_CODE),+                Err(_) => None,+            }+        }+        // SAFETY: reading the thread's last error right after the failed OpenProcess.+        None => match unsafe { GetLastError() } {+            e if e == ERROR_INVALID_PARAMETER => Some(false),+            e if e == ERROR_ACCESS_DENIED => Some(true),+            _ => None,+        },+    }+}++pub fn kill(pid: u32) -> Result<(), String> {+    // SAFETY: handle closed by Proc's Drop.+    let p = unsafe { OpenProcess(PROCESS_TERMINATE, false, pid) }.map(Proc).map_err(|e| format!("OpenProcess({pid}): {e}"))?;+    // SAFETY: valid handle with PROCESS_TERMINATE.+    unsafe { TerminateProcess(p.0, 1) }.map_err(|e| format!("TerminateProcess({pid}): {e}"))+}++/// Quote one argument the way CreateProcessW's C runtime parsing expects.+pub fn quote_arg(a: &str) -> String {+    if !a.is_empty() && !a.chars().any(|c| c == ' ' || c == '\t' || c == '"' || c == '\n') {+        return a.to_string();+    }+    let mut out = String::from("\"");+    let mut backslashes = 0;+    for c in a.chars() {+        match c {+            '\\' => backslashes += 1,+            '"' => {+                out.push_str(&"\\".repeat(backslashes * 2 + 1));+                out.push('"');+                backslashes = 0;+            }+            _ => {+                out.push_str(&"\\".repeat(backslashes));+                out.push(c);+                backslashes = 0;+            }+        }+    }+    out.push_str(&"\\".repeat(backslashes * 2));+    out.push('"');+    out+}++/// Our environment plus `extra`, as a CREATE_UNICODE_ENVIRONMENT block.+fn env_block(extra: &[(String, String)]) -> Vec<u16> {+    let mut vars: Vec<(String, String)> = std::env::vars().collect();+    for (k, v) in extra {+        vars.retain(|(name, _)| !name.eq_ignore_ascii_case(k));+        vars.push((k.clone(), v.clone()));+    }+    vars.sort_by(|a, b| a.0.to_uppercase().cmp(&b.0.to_uppercase()));+    let mut block: Vec<u16> = Vec::new();+    for (k, v) in vars {+        block.extend(format!("{k}={v}").encode_utf16());+        block.push(0);+    }+    block.push(0);+    block+}++/// Spawn a GUI program with STARTF_USESHOWWINDOW = SW_SHOWNOACTIVATE so its first window+/// opens behind the user's work instead of grabbing the foreground (best effort: an app can+/// override nCmdShow, which is why the etiquette layer also watches). stdout and stderr go+/// to `log` when given (KiCad writes everything it has to say to stderr; a GUI process has+/// no console, so without this the only diagnostics channel is thrown away). Detached: no+/// console inherited, and the child outlives the bridge.+pub fn spawn_background(exe: &Path, args: &[String], env: &[(String, String)], log: Option<&Path>) -> Result<u32, String> {+    if !exe.is_file() {+        return Err(format!("{} does not exist", exe.display()));+    }+    let exe_s = exe.to_string_lossy().to_string();+    let mut cmdline = quote_arg(&exe_s);+    for a in args {+        cmdline.push(' ');+        cmdline.push_str(&quote_arg(a));+    }+    let app = wide(&exe_s);+    let mut cmd = wide(&cmdline);++    let mut si = STARTUPINFOW { cb: std::mem::size_of::<STARTUPINFOW>() as u32, ..Default::default() };+    si.dwFlags = STARTF_USESHOWWINDOW;+    si.wShowWindow = SW_SHOWNOACTIVATE.0 as u16;++    // Keep the log file open across CreateProcessW; the child inherits a duplicate.+    let log_file = match log {+        Some(p) => {+            let f = std::fs::OpenOptions::new().create(true).append(true).open(p).map_err(|e| format!("open log {}: {e}", p.display()))?;+            let h = HANDLE(f.as_raw_handle() as *mut _);+            // SAFETY: `h` is the live handle of `f`, which outlives the spawn.+            unsafe { SetHandleInformation(h, HANDLE_FLAG_INHERIT.0, HANDLE_FLAG_INHERIT) }.map_err(|e| format!("SetHandleInformation: {e}"))?;+            si.dwFlags |= STARTF_USESTDHANDLES;+            si.hStdInput = HANDLE::default();+            si.hStdOutput = h;+            si.hStdError = h;+            Some(f)+        }+        None => None,+    };+    let inherit = log_file.is_some();++    let block = if env.is_empty() { None } else { Some(env_block(env)) };+    let mut pi = PROCESS_INFORMATION::default();+    // SAFETY: `app`, `cmd`, `block`, `si` and `pi` all outlive the call; `cmd` is a mutable+    // NUL-terminated buffer as CreateProcessW requires; the environment block is+    // double-NUL-terminated UTF-16 and flagged as such.+    let res = unsafe {+        CreateProcessW(+            PCWSTR(app.as_ptr()),+            Some(PWSTR(cmd.as_mut_ptr())),+            None,+            None,+            inherit,+            CREATE_UNICODE_ENVIRONMENT | DETACHED_PROCESS,+            block.as_ref().map(|b| b.as_ptr() as *const _),+            PCWSTR::null(),+            &si,+            &mut pi,+        )+    };+    drop(log_file);+    res.map_err(|e| format!("CreateProcessW({}): {e}", exe.display()))?;+    // SAFETY: both handles were returned by a successful CreateProcessW and are closed once.+    unsafe {+        let _ = CloseHandle(pi.hThread);+        let _ = CloseHandle(pi.hProcess);+    }+    Ok(pi.dwProcessId)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn quoting_matches_msvcrt_rules() {+        assert_eq!(quote_arg("plain"), "plain");+        assert_eq!(quote_arg(""), "\"\"");+        assert_eq!(quote_arg("C:\\Users\\john\\My Board\\b.kicad_pcb"), "\"C:\\Users\\john\\My Board\\b.kicad_pcb\"");+        assert_eq!(quote_arg("say \"hi\""), "\"say \\\"hi\\\"\"");+        assert_eq!(quote_arg("trail\\ "), "\"trail\\ \"");+        assert_eq!(quote_arg("end\\"), "end\\");+        assert_eq!(quote_arg("a b\\"), "\"a b\\\\\"");+    }++    #[test]+    fn base_names() {+        assert_eq!(base_name("C:\\Program Files\\KiCad\\10.0\\bin\\PCBNEW.exe"), "pcbnew.exe");+        assert_eq!(base_name("C:/x/kicad.exe"), "kicad.exe");+        assert_eq!(base_name(""), "");+    }++    #[test]+    fn env_block_is_double_terminated_and_overrides() {+        let b = env_block(&[("KICAD_ENABLE_WXTRACE".into(), "1".into())]);+        assert_eq!(&b[b.len() - 2..], &[0, 0]);+        let s = String::from_utf16_lossy(&b);+        assert!(s.contains("KICAD_ENABLE_WXTRACE=1\0"));+    }+}
rust/crates/kicad-platform/src/win/uia.rsadded+228
@@ -0,0 +1,228 @@+//! UI Automation in process, through the `uiautomation` crate (Windows UIA COM). Replaces+//! handlers/uia.py (a PowerShell child running System.Windows.Automation, with its+//! 0xC0000409 teardown crashes and a console blip) and the UIA half of navigate_symbol.ps1.+//!+//! What the Python did, kept exactly:+//! - exact-name FindFirst first, then a bounded FindAll over all descendants filtered by+//!   case-insensitive substring;+//! - for an invoke, prefer a match that ACTUALLY supports InvokePattern: KiCad exposes the+//!   label "Footprint Editor" and the toolbar button with the same name, and grabbing the+//!   label yields not_invokable. SelectionItem.Select is the fallback for menu-like items;+//! - for a set-value, prefer a ValuePattern match; an empty name means "the element for+//!   this hwnd itself" (the filter Edit, navigate_symbol.ps1 step 1);+//! - no focus, no cursor. wx may still self-activate its frame after a state change+//!   (measured in the old foreground_guard.py), which is the etiquette layer's job.+//!+//! UIA calls block on the provider (KiCad's thread), so every entry point runs the WM_NULL+//! probe first and refuses a hung window.++use uiautomation::core::UICacheRequest;+use uiautomation::patterns::{UIInvokePattern, UIPatternType, UISelectionItemPattern, UIValuePattern};+use uiautomation::types::{ControlType, Handle, TreeScope, UIProperty};+use uiautomation::variants::Variant;+use uiautomation::{UIAutomation, UIElement};+use windows::Win32::Foundation::{HWND, RPC_E_CHANGED_MODE};+use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED};++use super::{hwnd_u64, require_responsive, strip_amp};++/// Upper bound on descendants inspected in a substring search (the symbol editor's+/// library tree alone can expose thousands of rows).+const MAX_ELEMENTS: usize = 4000;++/// COM apartment for the calling thread, released on drop. A thread that already runs+/// an STA keeps it (RPC_E_CHANGED_MODE is not an error for us).+struct Com(bool);++impl Com {+    fn init() -> Result<Com, String> {+        // SAFETY: CoInitializeEx on the current thread; balanced by CoUninitialize in Drop+        // only when this call actually took a reference.+        let hr = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };+        if hr.is_ok() {+            Ok(Com(true))+        } else if hr == RPC_E_CHANGED_MODE {+            Ok(Com(false))+        } else {+            Err(format!("CoInitializeEx: {hr}"))+        }+    }+}++impl Drop for Com {+    fn drop(&mut self) {+        if self.0 {+            // SAFETY: matches the successful CoInitializeEx above; all COM objects created+            // between the two are dropped before this runs (they live inside the function).+            unsafe { CoUninitialize() };+        }+    }+}++#[derive(Clone, Copy, PartialEq, Eq)]+enum Want {+    Invoke,+    Value,+}++fn prop_true(el: &UIElement, p: UIProperty) -> bool {+    el.get_cached_property_value(p).ok().and_then(|v| v.try_into().ok()).unwrap_or(false)+}++fn supports(el: &UIElement, want: Want) -> bool {+    match want {+        Want::Invoke => prop_true(el, UIProperty::IsInvokePatternAvailable) || prop_true(el, UIProperty::IsSelectionItemPatternAvailable),+        Want::Value => prop_true(el, UIProperty::IsValuePatternAvailable),+    }+}++fn cache(ua: &UIAutomation) -> Result<UICacheRequest, String> {+    let c = ua.create_cache_request().map_err(|e| format!("UIA cache request: {e}"))?;+    for p in [+        UIProperty::Name,+        UIProperty::ControlType,+        UIProperty::IsInvokePatternAvailable,+        UIProperty::IsSelectionItemPatternAvailable,+        UIProperty::IsValuePatternAvailable,+    ] {+        c.add_property(p).map_err(|e| format!("UIA cache request: {e}"))?;+    }+    for p in [UIPatternType::Invoke, UIPatternType::SelectionItem, UIPatternType::Value] {+        let _ = c.add_pattern(p);+    }+    Ok(c)+}++fn root_of(ua: &UIAutomation, h: HWND) -> Result<UIElement, String> {+    ua.element_from_handle(Handle::from(hwnd_u64(h) as isize)).map_err(|e| format!("UIA ElementFromHandle({}): {e}", hwnd_u64(h)))+}++/// Exact-name FindFirst (one cross-process query), then the bounded substring sweep.+/// Returns the element and its name. Prefers an element that supports the wanted pattern,+/// falling back to the first name match so the caller can report not_invokable honestly.+fn find(ua: &UIAutomation, root: &UIElement, name: &str, want: Want) -> Result<Option<(UIElement, String)>, String> {+    let req = cache(ua)?;+    if let Ok(cond) = ua.create_property_condition(UIProperty::Name, Variant::from(name), None) {+        if let Ok(all) = root.find_all_build_cache(TreeScope::Descendants, &cond, &req) {+            if let Some(el) = all.iter().find(|el| supports(el, want)).or(all.first()) {+                return Ok(Some((el.clone(), el.get_cached_name().unwrap_or_else(|_| name.to_string()))));+            }+        }+    }+    let needle = name.to_lowercase();+    let cond = ua.create_true_condition().map_err(|e| format!("UIA condition: {e}"))?;+    let all = match root.find_all_build_cache(TreeScope::Descendants, &cond, &req) {+        Ok(v) => v,+        Err(e) => return Err(format!("UIA FindAll under {}: {e}", root.get_name().unwrap_or_default())),+    };+    let mut first: Option<(UIElement, String)> = None;+    for el in all.iter().take(MAX_ELEMENTS) {+        let n = el.get_cached_name().unwrap_or_default();+        if n.is_empty() || !n.to_lowercase().contains(&needle) {+            continue;+        }+        if supports(el, want) {+            return Ok(Some((el.clone(), n)));+        }+        if first.is_none() {+            first = Some((el.clone(), n));+        }+    }+    Ok(first)+}++/// Invoke (or Select) the first descendant whose name contains `name`. Returns+/// "invoke:<name>" or "select:<name>" naming the element that took the action.+pub fn invoke(h: HWND, name: &str) -> Result<String, String> {+    require_responsive(h, "uia_invoke", 500)?;+    let _com = Com::init()?;+    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;+    let root = root_of(&ua, h)?;+    let Some((el, found)) = find(&ua, &root, name, Want::Invoke)? else {+        return Err(format!("control_not_found: no descendant of {} named like {name:?}", hwnd_u64(h)));+    };+    if let Ok(p) = el.get_pattern::<UIInvokePattern>() {+        p.invoke().map_err(|e| format!("Invoke on {found:?}: {e}"))?;+        return Ok(format!("invoke:{found}"));+    }+    if let Ok(p) = el.get_pattern::<UISelectionItemPattern>() {+        p.select().map_err(|e| format!("Select on {found:?}: {e}"))?;+        return Ok(format!("select:{found}"));+    }+    Err(format!("not_invokable: {found:?} supports neither InvokePattern nor SelectionItemPattern"))+}++/// ValuePattern.SetValue on the descendant whose name contains `name`, or on the element+/// for `h` itself when `name` is empty (the library filter Edit).+pub fn set_value(h: HWND, name: &str, text: &str) -> Result<(), String> {+    require_responsive(h, "uia_set_value", 500)?;+    let _com = Com::init()?;+    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;+    let root = root_of(&ua, h)?;+    let (el, found) = if name.is_empty() {+        let n = root.get_name().unwrap_or_default();+        (root, n)+    } else {+        match find(&ua, &root, name, Want::Value)? {+            Some(x) => x,+            None => return Err(format!("control_not_found: no descendant of {} named like {name:?}", hwnd_u64(h))),+        }+    };+    let p = el.get_pattern::<UIValuePattern>().map_err(|_| format!("not_settable: {found:?} has no ValuePattern"))?;+    p.set_value(text).map_err(|e| format!("SetValue on {found:?}: {e}"))+}++/// Dialog-button fallback for `click_button`: a Button-typed descendant whose caption+/// (mnemonic stripped, trimmed, lower case) is EXACTLY one of `labels`, invoked. Used when+/// the dialog draws its buttons itself (wx, not a Win32 `Button` child), so BM_CLICK had+/// nothing to hit. Exact match on purpose: a substring "ok" must never land on "Look".+pub fn click_button(dialog: HWND, labels: &[&str]) -> Result<String, String> {+    require_responsive(dialog, "uia click_button", 500)?;+    let _com = Com::init()?;+    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;+    let root = root_of(&ua, dialog)?;+    let req = cache(&ua)?;+    let cond = ua+        .create_property_condition(UIProperty::ControlType, Variant::from(ControlType::Button as i32), None)+        .map_err(|e| format!("UIA condition: {e}"))?;+    let all = root.find_all_build_cache(TreeScope::Descendants, &cond, &req).map_err(|e| format!("UIA FindAll(Button): {e}"))?;+    let wanted: Vec<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect();+    let mut seen = Vec::new();+    for el in all.iter().take(MAX_ELEMENTS) {+        let n = el.get_cached_name().unwrap_or_default();+        let key = strip_amp(&n).trim().to_lowercase();+        if !wanted.contains(&key) {+            if !n.is_empty() {+                seen.push(n);+            }+            continue;+        }+        let p = el.get_pattern::<UIInvokePattern>().map_err(|_| format!("not_invokable: button {n:?} has no InvokePattern"))?;+        p.invoke().map_err(|e| format!("Invoke on {n:?}: {e}"))?;+        return Ok(format!("uia_invoke:{n}"));+    }+    Err(format!("control_not_found: no UIA button {labels:?} on {} (buttons seen: {seen:?})", hwnd_u64(dialog)))+}++/// Names of descendants containing `name` (all named descendants when empty), bounded.+pub fn find_names(h: HWND, name: &str, limit: usize) -> Result<Vec<String>, String> {+    require_responsive(h, "uia_find", 500)?;+    let _com = Com::init()?;+    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;+    let root = root_of(&ua, h)?;+    let req = cache(&ua)?;+    let cond = ua.create_true_condition().map_err(|e| format!("UIA condition: {e}"))?;+    let all = root.find_all_build_cache(TreeScope::Descendants, &cond, &req).map_err(|e| format!("UIA FindAll: {e}"))?;+    let needle = name.to_lowercase();+    let mut out = Vec::new();+    for el in all.iter().take(MAX_ELEMENTS) {+        let n = el.get_cached_name().unwrap_or_default();+        if !n.is_empty() && (needle.is_empty() || n.to_lowercase().contains(&needle)) {+            out.push(n);+            if out.len() >= limit.max(1) {+                break;+            }+        }+    }+    Ok(out)+}
rust/crates/kicad-platform/src/windows.rs+208−13
@@ -1,11 +1,27 @@ //! Windows implementation. Registry (HKLM/HKCU SOFTWARE\KiCad\<ver>\InstallationPath) plus //! the standard install folders, %APPDATA%/kicad/<ver> settings, %USERPROFILE%/Documents/KiCad/<ver>.-//! Phase 3 adds window control (windows crate), UIA (uiautomation crate) and the etiquette loop.+//!+//! Phase 3 (window control, UIA, dialog primitives) lives in `win/`: one module per family+//! of primitives, each ported from the Python handler that carried the measurements. This+//! file only converts trait-side u64 handles into HWNDs and delegates. Conventions callers+//! rely on:+//! - `child_windows` returns every DESCENDANT (EnumChildWindows semantics) with `owner` set+//!   to the direct parent, so "the `Edit` under the `searchCtrl` parent" is a join on+//!   class_name and the parent's title.+//! - `gl_canvas_rect` is (x, y, w, h) relative to the top-left of the window rect, which is+//!   the box `capture_window` renders, so the probe can index the capture directly.+//! - `bring_to_front` returns the MEASURED result: GetForegroundWindow afterwards.+//! - Every call Windows would make synchronous on KiCad's thread is gated on a WM_NULL probe+//!   and refused (Err) on a hung window; nothing here can block the bridge.++#[path = "win/mod.rs"]+mod win;  use std::path::{Path, PathBuf}; use std::process::Command; -use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform};+use crate::{install_from_cli, major_minor, Capabilities, Capture, KicadInstall, MenuItem, Placement, Platform, ProcessInfo, WindowInfo};+use win::{hwnd, live};  pub struct Native; @@ -78,9 +94,9 @@ impl Platform for Native {             os: "windows",             owner: "the kicad-bridge maintainer thread",             kicad_cli: true,-            window_control: false, // phase 3-            ui_automation: false,  // phase 3-            dialog_sweep: false,   // phase 3+            window_control: true,+            ui_automation: true,+            dialog_sweep: true,             focus_etiquette: false, // phase 4             ipc_api: false,        // phase 2             silent_install: false, // phase 1b@@ -120,13 +136,192 @@ impl Platform for Native {         Some(userprofile()?.join("Documents"))     }     fn process_alive(&self, pid: u32) -> Option<bool> {-        // Phase 3 replaces this with OpenProcess through the windows crate; until then a-        // quiet tasklist query (no console window) answers the question.-        let mut cmd = Command::new("tasklist");-        cmd.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]);-        self.quiet_command(&mut cmd);-        let out = cmd.output().ok()?;-        let text = String::from_utf8_lossy(&out.stdout);-        Some(text.contains(&format!("\"{pid}\"")))+        // OpenProcess + GetExitCodeProcess (no tasklist child, no console blip).+        win::process::alive(pid)+    }++    // ---- Phase 3: window control, dialogs, UIA. See win/ for the primitives.++    fn list_windows(&self) -> Result<Vec<WindowInfo>, String> {+        Ok(win::enumerate::list_visible())+    }+    fn window_info(&self, h: u64) -> Result<Option<WindowInfo>, String> {+        if h == 0 || !win::is_window(hwnd(h)) {+            return Ok(None);+        }+        let mut cache = win::enumerate::ExeCache::default();+        Ok(Some(win::enumerate::info(hwnd(h), &mut cache, 150)))+    }+    fn child_windows(&self, h: u64) -> Result<Vec<WindowInfo>, String> {+        Ok(win::enumerate::children(live(h)?))+    }+    fn processes(&self, exe_names: &[&str]) -> Result<Vec<ProcessInfo>, String> {+        Ok(win::process::list(exe_names))+    }+    fn spawn_background(&self, exe: &Path, args: &[String], env: &[(String, String)], log: Option<&Path>) -> Result<u32, String> {+        win::process::spawn_background(exe, args, env, log)+    }+    fn kill_process(&self, pid: u32) -> Result<(), String> {+        win::process::kill(pid)+    }+    fn responsive(&self, h: u64, timeout_ms: u32) -> Result<bool, String> {+        Ok(win::responsive(live(h)?, timeout_ms))+    }+    fn post_close(&self, h: u64) -> Result<(), String> {+        win::messages::post_close(live(h)?)+    }+    fn menu_tree(&self, h: u64) -> Result<Vec<MenuItem>, String> {+        win::menu::tree(live(h)?)+    }+    fn menu_invoke(&self, h: u64, id: u32) -> Result<(), String> {+        win::menu::invoke(live(h)?, id)+    }+    fn post_key(&self, h: u64, vk: u16, ch: Option<char>) -> Result<(), String> {+        win::messages::post_key(live(h)?, vk, ch)+    }+    fn post_click(&self, h: u64, x: i32, y: i32, double: bool) -> Result<(), String> {+        win::messages::post_click(live(h)?, x, y, double)+    }+    fn set_text(&self, h: u64, text: &str) -> Result<(), String> {+        win::messages::set_text(live(h)?, text)+    }+    fn window_text(&self, h: u64, timeout_ms: u32) -> Result<String, String> {+        win::messages::window_text(live(h)?, timeout_ms)+    }+    fn click_button(&self, dialog: u64, labels: &[&str]) -> Result<String, String> {+        let d = live(dialog)?;+        if let Some(method) = win::messages::bm_click(d, labels) {+            return Ok(method);+        }+        // No Win32 Button child with that caption (a wx-drawn button): UIA Invoke, exact caption.+        win::uia::click_button(d, labels)+    }+    fn uia_invoke(&self, h: u64, name: &str) -> Result<String, String> {+        win::uia::invoke(live(h)?, name)+    }+    fn uia_set_value(&self, h: u64, name: &str, text: &str) -> Result<(), String> {+        win::uia::set_value(live(h)?, name, text)+    }+    fn uia_find(&self, h: u64, name: &str, limit: usize) -> Result<Vec<String>, String> {+        win::uia::find_names(live(h)?, name, limit)+    }+    fn foreground(&self) -> Result<u64, String> {+        Ok(win::focus::foreground())+    }+    fn push_to_background(&self, h: u64) -> Result<(), String> {+        win::focus::push_to_background(live(h)?)+    }+    fn show_no_activate(&self, h: u64) -> Result<(), String> {+        win::focus::show_no_activate(live(h)?)+    }+    fn bring_to_front(&self, h: u64) -> Result<bool, String> {+        win::focus::bring_to_front(live(h)?)+    }+    fn placement(&self, h: u64) -> Result<Placement, String> {+        win::focus::placement(live(h)?)+    }+    fn set_placement(&self, h: u64, p: &Placement) -> Result<(), String> {+        win::focus::set_placement(live(h)?, p)+    }+    fn on_a_monitor(&self, rect: (i32, i32, i32, i32)) -> Result<bool, String> {+        Ok(win::enumerate::on_a_monitor(rect))+    }+    fn capture_window(&self, h: u64) -> Result<Capture, String> {+        win::capture::capture(live(h)?)+    }+    fn gl_canvas_rect(&self, h: u64) -> Result<Option<(i32, i32, i32, i32)>, String> {+        Ok(win::enumerate::gl_canvas_rect(live(h)?))+    }+    fn seconds_since_input(&self) -> Result<f64, String> {+        win::messages::seconds_since_input()+    }+    fn init_process(&self) {+        // Per-monitor DPI awareness so every rect is in physical pixels (kicad_ui.py did+        // this at import). Fails harmlessly if the manifest or an earlier call already set it.+        use windows::Win32::UI::HiDpi::{SetProcessDpiAwareness, PROCESS_PER_MONITOR_DPI_AWARE};+        use windows::Win32::UI::WindowsAndMessaging::SetProcessDPIAware;+        // SAFETY: process-wide setting, no pointers.+        unsafe {+            if SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE).is_err() {+                let _ = SetProcessDPIAware();+            }+        }+    }+}++/// Live-API smoke tests. They only assert what any Windows desktop session guarantees+/// (a foreground window exists, the idle clock ticks, bad handles are refused) and never+/// touch a real KiCad; John runs the KiCad probes by hand on ConfRoomROG.+#[cfg(all(test, windows))]+mod tests {+    use super::*;++    #[test]+    fn lists_at_least_the_desktop_shell() {+        let ws = Native.list_windows().unwrap();+        assert!(!ws.is_empty(), "EnumWindows returned nothing");+        assert!(ws.iter().all(|w| w.hwnd != 0 && w.visible));+    }++    #[test]+    fn bad_handles_are_refused_not_hung() {+        assert!(matches!(Native.window_info(0), Ok(None)));+        assert!(matches!(Native.window_info(0xDEAD_BEEF_0000), Ok(None)));+        assert!(Native.responsive(0, 100).is_err());+        assert!(Native.post_close(0).is_err());+        assert!(Native.menu_tree(0).is_err());+        assert!(Native.child_windows(0).is_err());+        assert!(Native.capture_window(0).is_err());+    }++    #[test]+    fn foreground_and_idle_clock_answer() {+        assert!(Native.foreground().is_ok());+        let idle = Native.seconds_since_input().unwrap();+        assert!(idle.is_finite() && idle >= 0.0);+    }++    #[test]+    fn foreground_window_round_trips_through_info() {+        let fg = Native.foreground().unwrap();+        if fg == 0 {+            return; // a session with no foreground (service session); nothing to check+        }+        let info = Native.window_info(fg).unwrap().expect("foreground window exists");+        assert_eq!(info.hwnd, fg);+        assert!(info.pid != 0);+        assert!(Native.responsive(fg, 500).unwrap_or(false) || info.hung);+        let p = Native.placement(fg).unwrap();+        assert!(p.width > 0 || p.maximized);+        assert!(Native.on_a_monitor(info.rect).unwrap() || info.minimized);+    }++    #[test]+    fn our_own_process_is_alive_and_listed() {+        let me = std::process::id();+        assert_eq!(Native.process_alive(me), Some(true));+        assert_eq!(Native.process_alive(0), Some(false));+        let exe = std::env::current_exe().unwrap().file_name().unwrap().to_string_lossy().to_lowercase();+        let ps = Native.processes(&[&exe]).unwrap();+        assert!(ps.iter().any(|p| p.pid == me), "{exe} not found in {ps:?}");+    }++    #[test]+    fn spawn_background_runs_and_logs() {+        let dir = std::env::temp_dir().join(format!("kicad-bridge-spawn-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let log = dir.join("spawn.log");+        let cmd = Path::new(r"C:\Windows\System32\cmd.exe");+        let pid = Native.spawn_background(cmd, &["/c".into(), "echo hello-from-spawn".into()], &[("KICAD_BRIDGE_TEST".into(), "1".into())], Some(&log)).unwrap();+        assert!(pid != 0);+        for _ in 0..50 {+            if Native.process_alive(pid) == Some(false) {+                break;+            }+            std::thread::sleep(std::time::Duration::from_millis(100));+        }+        let text = std::fs::read_to_string(&log).unwrap_or_default();+        assert!(text.contains("hello-from-spawn"), "log was: {text:?}");+        let _ = std::fs::remove_dir_all(&dir);     } }