app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
rust: Windows window-control layer (enumerate, process, messages, menu, focus, capture, UIA), all cross-process calls hung-gated
10 files changed
+1583−13
rust/crates/kicad-platform/Cargo.toml+3rust/crates/kicad-platform/src/win/capture.rs+84rust/crates/kicad-platform/src/win/enumerate.rs+216rust/crates/kicad-platform/src/win/focus.rs+143rust/crates/kicad-platform/src/win/menu.rs+95rust/crates/kicad-platform/src/win/messages.rs+130rust/crates/kicad-platform/src/win/mod.rs+176rust/crates/kicad-platform/src/win/process.rs+300rust/crates/kicad-platform/src/win/uia.rs+228rust/crates/kicad-platform/src/windows.rs+208−13rust/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("e_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); } }