← Commit history

rust: phase 3 window verbs (20), ab callback client, KiCad window model; kicad_errors on the native window walk; caller headers forwarded; 160 tests

John Lauer ·ffd9cd0f10 ·28d ago ·parent 3ecdded
7 files changed +4149−12
rust/crates/kicad-bridge/src/main.rs+1
@@ -94,6 +94,7 @@ fn main() {     let actual_port = server.server_addr().to_ip().map(|a| a.port()).unwrap_or(port);     eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port}"); +    kicad_platform::native().init_process();     let mut state = util::State::new();      for mut req in server.incoming_requests() {
rust/crates/kicad-bridge/src/verbs.rs+2
@@ -21,6 +21,8 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal         let names: Vec<&str> = catalog::all().iter().map(|v| v.name).collect();         return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", ")));     };+    // The ab callback client forwards this request's caller identity (headers first, args.caller as fallback).+    kicad_core::ab::set_caller(kicad_core::ab::caller_from(args, &_caller.thread, &_caller.container, &_caller.reason));     // Verb groups ported later live in their own files; each returns Some when it owns the verb.     if let Some(mut out) = crate::groups::dispatch(state, command, args) {         if let Some(o) = out.as_object_mut() {
rust/crates/kicad-bridge/src/verbs_misc.rs+7−3
@@ -104,15 +104,19 @@ fn export_molecule(state: &mut State, args: &Value) -> Value { fn errors(args: &Value) -> Value {     let include_all = args.get("includeAll").and_then(Value::as_bool).unwrap_or(false);     let caps = kicad_platform::native().capabilities();+    if caps.window_control {+        let rows = crate::verbs_windows::dialog_rows();+        return bridge_log::errors(&rows, include_all, "native window walk");+    }     let source = format!(-        "unavailable: the native {} build has no window enumeration yet (phase 3, owner {}); no dialog was read",+        "unavailable: the native {} build has no window enumeration yet (owner {}); no dialog was read",         caps.os, caps.owner     );     let mut v = bridge_log::errors(&[], include_all, &source);     v["windowEnumeration"] = json!(false);     v["_hint"] = json!(format!(-        "{} NOTE: this native build cannot see windows yet, so count 0 means 'not looked', not 'no dialog'. Read dialogs through ab (desktop_list_windows, then desktop_ui_tree on the hwnd) or the Python bridge's kicad_errors until phase 3 lands.",-        bridge_log::ERRORS_HINT+        "{} NOTE: this build cannot see windows on {}, so count 0 means 'not looked', not 'no dialog'.",+        bridge_log::ERRORS_HINT, caps.os     ));     v }
rust/crates/kicad-bridge/src/verbs_windows.rs+2702−6
@@ -1,11 +1,2707 @@-//! Verb group "windows". Placeholder until phase 3 lands.-use serde_json::Value;+//! Verb group "windows": the KiCad window verbs, phase 3 of docs/rust-port-plan.md.+//! Ported from handlers/launch.py, open_files.py, open_symbol_editor.py,+//! open_footprint_editor.py, close_windows.py, kicad_ui.py, kicad_windows.py, state.py,+//! software_opengl.py, place_footprint.py, fix_keyboard.py and the dispatch wrapper in+//! server.py (dialog sweep, foreground etiquette, window labelling).+//!+//! Division of labour: the bridge owns KiCad, ab owns Windows. Window enumeration with+//! z-order, every screenshot (with owned popups and coordMap), graceful closes, toasts and+//! captions go to ab through `kicad_core::ab`; the local platform layer+//! (`kicad_platform::native()`) is the fallback for the list, and the ONLY path for the+//! things ab has no verb for: spawning without activation, the native menu walk plus+//! WM_COMMAND, posted keys and clicks to one hwnd, WM_SETTEXT, button clicks on a dialog,+//! UIA Invoke/SetValue, and the canvas-painted probe (raw pixels, KiCad-specific).+//! Nothing in this file names an OS: when a platform method answers "not implemented on+//! <os>", the verb answers `kicad_platform::unsupported`.+//!+//! The ladder (plan, "Front-end control policy"): every window-opening verb records the+//! rung it used as `mechanism`: "menu" (native menu walk + WM_COMMAND), "uia" (UI+//! Automation Invoke/SetValue), "post" (PostMessage keys/clicks/WM_SETTEXT), "spawn" (a+//! process started without activation). SendInput (cursor clicks, focused chords) is out+//! of scope for the native build: those opt-ins answer `not_supported_on_platform`.+//!+//! Etiquette: background-first. Nothing is foregrounded unless the caller passes+//! foreground/showToUser/bringToFront WITH foregroundReason (else errorCode+//! foreground_reason_required). After every window verb the dialog sweep runs (progress+//! dialogs are never dismissed) and the one-shot focus check pushes a KiCad window back+//! once if it took the foreground from a user window. The sentinel/WinEventHook/park+//! guardian is phase 4 and is deliberately not here. -use crate::catalog::Verb;-use crate::util::State;+use std::collections::BTreeMap;+use std::path::{Path, PathBuf};+use std::sync::Mutex;+use std::time::{Duration, Instant}; -pub static VERBS: &[Verb] = &[];+use serde_json::{json, Map, Value}; -pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {+use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use kicad_core::detect::KicadInfo;+use kicad_core::libraries::{self, LibCtx};+use kicad_core::windows_model as wm;+use kicad_core::windows_model::{DialogAction, WindowKind};+use kicad_core::{ab, bridge_log, kicad_common, pcm};+use kicad_platform::{native, unsupported, WindowInfo};++// ── Catalog ───────────────────────────────────────────────────────────────────++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_launch",+        summary: "Cold-start KiCad (project manager) when nothing is running.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,+        input: "{\"project\"?: \"C:/.../x.kicad_pro\", \"waitSeconds\"?: 15, \"traceMasks\"?: \"KICAD_...\"}",+        example: "kicad_launch {}",+        hint: "Idempotent: no-op with a hint if KiCad is already up. Opens in the BACKGROUND (no focus steal). Optional project: path to a .kicad_pro.",+        related: &["kicad_open_editors", "kicad_window_info", "kicad_readiness", "kicad_upgrade"],+        pitfalls: &["first launch on a fresh install can show a setup wizard; kicad_window_info self-heals benign dialogs"],+    },+    Verb {+        name: "kicad_open_board",+        summary: "Open a .kicad_pcb in the PCB editor (pcbnew).",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"waitSeconds\"?: 30, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+        example: "kicad_open_board {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "Pass an ABSOLUTE filePath; opens the board in pcbnew and returns once the window is up. Optional kicadVersion pins a specific install.",+        related: &["kicad_open_3d_viewer", "kicad_run_drc", "kicad_screenshot_all", "kicad_close"],+        pitfalls: &["filePath must be ABSOLUTE; a relative/project-dir path fails", "if the board is already open you get the existing window, not a fresh reload"],+    },+    Verb {+        name: "kicad_open_schematic",+        summary: "Open a .kicad_sch in the schematic editor (eeschema).",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,+        input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"waitSeconds\"?: 30, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+        example: "kicad_open_schematic {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\"}",+        hint: "Absolute filePath to a .kicad_sch; opens eeschema. Optional kicadVersion.",+        related: &["kicad_run_erc", "kicad_export_bom_csv", "kicad_screenshot_all", "kicad_close"],+        pitfalls: &["filePath must be ABSOLUTE", "open the top-level .kicad_sch, not a sub-sheet, to load the whole design"],+    },+    Verb {+        name: "kicad_open_symbol_editor",+        summary: "Open the Symbol Editor (optionally at a symbol).",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,+        input: "{\"symbolName\"?: \"RP2040\", \"libraryName\"?: \"Adom\", \"rescan\"?: bool, \"waitInline\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+        example: "kicad_open_symbol_editor {\"symbolName\":\"RP2040\",\"libraryName\":\"Adom\"}",+        hint: "Opens the Symbol Editor; pass symbolName (+ libraryName to disambiguate short names) to load that symbol into the canvas. The load is VERIFIED against the parsed library:symbol title: if the requested symbol did not load you get success:false with editorOpened:true, not a silent empty editor.",+        related: &["kicad_install_symbol", "kicad_close_symbol_editor", "kicad_screenshot_all"],+        pitfalls: &["the symbol must already be in an installed sym-lib-table library (install_symbol first)", "a cold Symbol Editor indexes every library before its window shows; the verb waits, and returns a retryable symbol_editor_starting contract if the transport budget runs out"],+    },+    Verb {+        name: "kicad_open_footprint_editor",+        summary: "Open the Footprint Editor (optionally at a footprint).",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,+        input: "{\"footprintName\"?: \"SOT-23\", \"libraryName\"?: \"Adom\", \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+        example: "kicad_open_footprint_editor {\"footprintName\":\"SOT-23\",\"libraryName\":\"Adom\"}",+        hint: "Opens the Footprint Editor; pass library/footprint to jump to one.",+        related: &["kicad_install_footprint", "kicad_close_footprint_editor", "kicad_screenshot_all"],+        pitfalls: &["the footprint must be in an installed fp-lib-table library (install_footprint first)", "allowCursorClick (the SendInput launcher click) is not in the native build: the background menu and UIA rungs are the only ones"],+    },+    Verb {+        name: "kicad_open_3d_viewer",+        summary: "Open the 3D board viewer for a .kicad_pcb.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 300,+        input: "{\"editor\"?: \"auto\"|\"fp\"|\"pcb\", \"pcbHwnd\"?: 123, \"fpHwnd\"?: 123, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+        example: "kicad_open_3d_viewer {\"editor\":\"pcb\"}",+        hint: "Opens the 3D viewer from an open PCB Editor (or Footprint Editor) through its View menu, in the background. Follow with screenshot_all to capture it.",+        related: &["kicad_open_board", "kicad_screenshot_all", "kicad_close_3d_viewer"],+        pitfalls: &["3D models only render if the footprints reference installed 3D model files", "the viewer takes a few seconds to render; wait before screenshotting"],+    },+    Verb {+        name: "kicad_open_editors",+        summary: "List currently open KiCad editor windows.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,+        input: "{}",+        example: "kicad_open_editors {}",+        hint: "Read-only INVENTORY (despite the name): lists editors inside a running KiCad. It never launches anything; to cold-start KiCad use kicad_launch.",+        related: &["kicad_launch", "kicad_window_info", "kicad_screenshot_all", "kicad_close"],+        pitfalls: &["with KiCad closed it succeeds with 0 editors; that is not an error, kicad_launch is the verb that starts KiCad"],+    },+    Verb {+        name: "kicad_close",+        summary: "Close KiCad (editors first, then the project manager).",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,+        input: "{\"force\"?: bool, \"discardChanges\"?: bool}",+        example: "kicad_close {}",+        hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. Read the kicad-interaction skill before blind-closing.",+        related: &["kicad_open_editors", "kicad_window_info"],+        pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently"],+    },+    Verb {+        name: "kicad_close_symbol_editor",+        summary: "Close the Symbol Editor window.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+        input: "{}", example: "kicad_close_symbol_editor {}",+        hint: "Closes the Symbol Editor if open; no-op otherwise.",+        related: &["kicad_open_symbol_editor", "kicad_close"],+        pitfalls: &["unsaved edits may prompt; save first if you made changes"],+    },+    Verb {+        name: "kicad_close_footprint_editor",+        summary: "Close the Footprint Editor window.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+        input: "{}", example: "kicad_close_footprint_editor {}",+        hint: "Closes the Footprint Editor if open; no-op otherwise.",+        related: &["kicad_open_footprint_editor", "kicad_close"],+        pitfalls: &["unsaved edits may prompt; save first"],+    },+    Verb {+        name: "kicad_close_3d_viewer",+        summary: "Close the 3D viewer window.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+        input: "{}", example: "kicad_close_3d_viewer {}",+        hint: "Closes the 3D viewer if open; no-op otherwise.",+        related: &["kicad_open_3d_viewer", "kicad_close"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_dismiss_dialogs",+        summary: "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 60,+        input: "{\"all\"?: bool, \"screenshot\"?: true, \"forceSoftwareCanvas\"?: bool, \"debug\"?: bool}",+        example: "kicad_dismiss_dialogs {\"all\":true}",+        hint: "Read-only-ish self-heal: clicks each dialog's OK/default button. window_info also auto-expires benign dialogs; use this to force-clear a specific/all dialogs before retrying an open/screenshot.",+        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_open_board"],+        pitfalls: &["benign-only by default; pass {\"all\":true} to also clear real dialogs you have already read", "a NON-benign dialog left up may indicate a real error; read its body first", "progress dialogs (Load PCB, Loading Symbol Libraries) are never dismissed: their one button is Cancel"],+    },+    Verb {+        name: "kicad_window_info",+        summary: "Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs).",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 60,+        input: "{\"autoDismiss\"?: true}",+        example: "kicad_window_info {}",+        hint: "Read-only; returns each KiCad window's position/size/state. Use before click/send_key to target the right window.",+        related: &["kicad_open_editors", "kicad_click", "kicad_send_key"],+        pitfalls: &["coordinates are screen-absolute; re-read after a window moves/resizes"],+    },+    Verb {+        name: "kicad_state",+        summary: "KiCad's full state of the union: every owned window as inline base64 PNG + logical state.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+        input: "{\"includeImages\"?: true, \"maxWidth\"?: 1100}",+        example: "kicad_state {\"includeImages\":false}",+        hint: "Read-only, non-gated. shots[] = base64 screenshot of EVERY KiCad window (render all of them; one window is not the app); plus editors inventory, modal dialogs, running flag. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll; maxWidth caps image size.",+        related: &["kicad_screenshot_all", "kicad_open_editors", "kicad_window_info"],+        pitfalls: &["shots are inline base64; the response is large, use includeImages:false when you only need the logical state"],+    },+    Verb {+        name: "kicad_screenshot_all",+        summary: "Screenshot every open KiCad window.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+        input: "{}",+        example: "kicad_screenshot_all {}",+        hint: "Captures all KiCad windows through ab's desktop_screenshot_window (background, owned popups included, coordMap per image); the go-to way to VERIFY what actually happened after an open/edit. canvas.rendered says whether the GL canvas actually painted.",+        related: &["kicad_open_editors", "kicad_window_info", "kicad_open_board"],+        pitfalls: &["a window still rendering (e.g. 3D viewer) shows blank; wait a beat first", "with ab unreachable no image is written (abUnavailable:true); only the canvas probe answers"],+    },+    Verb {+        name: "kicad_send_key",+        summary: "Send a key to KiCad; chords auto-resolve to menu commands (background)",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,+        input: "{\"key\": \"enter\"|\"escape\"|\"a\"|\"ctrl+s\", \"hwnd\"?: 123}",+        example: "kicad_send_key {\"hwnd\":123456,\"key\":\"escape\"}",+        hint: "For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use Bridge's desktop_press_key; it owns full chord parsing; do not route chords here. Use this verb only for the case Bridge's focus-first model cannot cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",+        related: &["kicad_window_info", "kicad_click", "kicad_fix_keyboard"],+        pitfalls: &["not for chords; use desktop_press_key", "needs a target hwnd (from window_info)", "allowForeground (real keystrokes) is not in the native build"],+    },+    Verb {+        name: "kicad_click",+        summary: "Click at coordinates inside a KiCad window.",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,+        input: "{\"hwnd\": 123, \"x\": 0.5, \"y\": 0.5, \"relative\"?: true}",+        example: "kicad_click {\"hwnd\":123456,\"x\":0.5,\"y\":0.5}",+        hint: "Posts the click to the window's own queue (background: the user's mouse never moves); read window_info + screenshot_all first to compute coordinates. relative:true (default) takes fractions 0.0-1.0 of the window.",+        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"],+        pitfalls: &["coordinates are window-relative and break if the window resized; re-read window_info", "allowForeground (a real cursor click) is not in the native build"],+    },+    Verb {+        name: "kicad_fix_keyboard",+        summary: "Reset stuck modifier keys on a KiCad window.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{}", example: "kicad_fix_keyboard {}",+        hint: "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave. The native build never sends global keystrokes, so there is never anything to fix.",+        related: &["kicad_send_key"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_place_footprint",+        summary: "Deterministically place a footprint into a board preview.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 130,+        input: "{\"fileName\": \"X.kicad_mod\", \"fileContent\": \"<base64>\", \"footprintName\"?: \"X\", \"x\"?: 148, \"y\"?: 105, \"open\"?: true}",+        example: "kicad_place_footprint {\"fileName\":\"BMI423.kicad_mod\",\"fileContent\":\"KGZvb3RwcmludCAu...\"}",+        hint: "Splices a footprint into a fresh preview board via s-expr (no GUI edit) and opens it in pcbnew in the background; good for previews.",+        related: &["kicad_open_board", "kicad_install_footprint"],+        pitfalls: &["operates on the file; close the board in KiCad first or the edit races the GUI"],+    },+    Verb {+        name: "kicad_enable_software_opengl",+        summary: "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 240,+        input: "{}", example: "kicad_enable_software_opengl {}",+        hint: "Only when KiCad cannot render at all AND there is no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",+        related: &["kicad_dismiss_dialogs", "kicad_upgrade"],+        pitfalls: &["a real GPU / Hyper-V GPU-P passthrough is far better; this is CPU rendering, slow", "writing KiCad's bin needs an elevated Bridge"],+    },+];++/// Verbs that may spawn or raise a KiCad window: they get the 5-round settle sweep and+/// the foreground etiquette (server.py `_SPAWNS_WINDOW`, this group's members).+const SPAWNS_WINDOW: &[&str] = &["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_place_footprint"];++/// Verbs that touch no window at all (no sweep, no focus check).+const NO_GUI: &[&str] = &["kicad_fix_keyboard", "kicad_enable_software_opengl"];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    if !VERBS.iter().any(|v| v.name == command) {+        return None;+    }+    // Caller identity, so every ab callback made while serving this verb is attributed+    // "<thread> (via kicad)". ab relays it as args.caller; headers are the fallback.+    ab::set_caller_from_args(args);+    let gui = !NO_GUI.contains(&command);+    let fg_before = if gui { native().foreground().ok().filter(|h| *h != 0) } else { None };+    let mut out = match command {+        "kicad_launch" => launch(state, args),+        "kicad_open_board" => open_file(state, args, WindowKind::PcbEditor),+        "kicad_open_schematic" => open_file(state, args, WindowKind::SchematicEditor),+        "kicad_open_symbol_editor" => open_symbol_editor(state, args),+        "kicad_open_footprint_editor" => open_footprint_editor(state, args),+        "kicad_open_3d_viewer" => open_3d_viewer(args),+        "kicad_open_editors" => open_editors(),+        "kicad_close" => close_kicad(state, args),+        "kicad_close_symbol_editor" => close_kind(WindowKind::SymbolEditor, "Symbol Editor"),+        "kicad_close_footprint_editor" => close_kind(WindowKind::FootprintEditor, "Footprint Editor"),+        "kicad_close_3d_viewer" => close_kind(WindowKind::Viewer3d, "3D Viewer"),+        "kicad_dismiss_dialogs" => dismiss_dialogs(state, args),+        "kicad_window_info" => window_info(state, args),+        "kicad_state" => kicad_state(state, args),+        "kicad_screenshot_all" => screenshot_all(state),+        "kicad_send_key" => send_key(args),+        "kicad_click" => click(args),+        "kicad_fix_keyboard" => fix_keyboard(),+        "kicad_place_footprint" => place_footprint(state, args),+        "kicad_enable_software_opengl" => enable_software_opengl(state),+        _ => return None,+    };+    if gui && out.get("errorCode") != Some(&json!("not_supported_on_platform")) {+        post_verb(state, command, args, &mut out, fg_before);+    }+    ab::clear_caller();+    Some(out)+}++/// The server.py dispatch wrapper for a window verb: dialog sweep, foreground etiquette,+/// the one-shot focus check, window labelling.+fn post_verb(state: &mut State, command: &str, args: &Value, out: &mut Value, fg_before: Option<u64>) {+    let spawns = SPAWNS_WINDOW.contains(&command);+    let info = state.kicad_info();+    // CONSTANT DIALOG COVERAGE: KiCad throws modal error boxes constantly; sweep after+    // every GUI verb so the AI never runs blind past one and the next verb is unblocked.+    let rounds = if spawns { 5 } else { 1 };+    if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600)) {+        let had_error = swept["hadError"].as_bool().unwrap_or(false);+        if had_error {+            let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();+            out["_dialogHint"] = json!(format!(+                "KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.",+                swept["count"], titles.join(", ")));+        }+        out["_autoDialogs"] = swept;+    }+    // Foreground etiquette (John, 2026-08-14 and 2026-08-16): the ONE sanctioned way a+    // KiCad window comes to the front is an explicit foreground flag WITH a reason.+    let wants_fg = user_wants_foreground(args);+    let success = out.get("success").and_then(Value::as_bool).unwrap_or(true);+    if wants_fg && spawns {+        std::thread::sleep(Duration::from_secs(1)); // let the window exist+        let reason = arg_str(args, "foregroundReason").or_else(|| arg_str(args, "reason")).unwrap_or("").trim().to_string();+        let mut shown: Option<u64> = None;+        if !reason.is_empty() {+            if let Some(h) = verb_window(command, out) {+                if bring_to_user(h, &reason) {+                    shown = Some(h);+                }+            }+        }+        out["_broughtToUser"] = json!(shown.is_some());+        out["windowEtiquette"] = json!(if shown.is_some() { "foreground" } else { "background" });+        if shown.is_none() {+            out["errorCode"] = json!("foreground_reason_required");+            out["_foregroundRefused"] = json!("foreground requested without foregroundReason - pass foregroundReason:'<why the user needs to see this>' and the bridge will caption it for 3s, then foreground.");+            out["_hint"] = json!("errorCode foreground_reason_required: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'.");+        }+    } else if spawns && success {+        let o = out.as_object_mut().unwrap();+        o.entry("windowEtiquette").or_insert(json!("background"));+        o.entry("openedInBackground").or_insert(json!(true));+        o.entry("_foregroundHint").or_insert(json!("Window opened in the BACKGROUND - the user's foreground was not touched. To show it to the user: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'. The bridge captions the reason via an ab toast for 3 seconds, then brings the window forward. Foreground without a reason is refused."));+    }+    // One-shot focus check (the phase 4 guardian's stand-in): did a KiCad window take the+    // foreground from a user window during this verb? Push it back once, and say so.+    let mut events = Vec::new();+    if !(wants_fg && out["_broughtToUser"] == json!(true)) {+        if let Some(ev) = one_shot_focus_check(fg_before) {+            events.push(ev);+        }+    }+    out["focusEvents"] = json!(events);+    if spawns {+        out["_userFocusRestored"] = json!(false);+    }+    // #12.3: label the KiCad window this verb raised with the owning AI thread, so the+    // user's taskbar/Alt-Tab shows which of their ~20 tabs owns it. Best-effort.+    if spawns && success {+        let thread = ab::thread_name();+        let label = match command {+            "kicad_open_symbol_editor" => Some("Symbol Editor"),+            "kicad_open_footprint_editor" => Some("Footprint Editor"),+            "kicad_open_board" => Some("PCB Editor"),+            "kicad_open_schematic" => Some("Schematic Editor"),+            "kicad_open_3d_viewer" => Some("3D Viewer"),+            _ => None,+        };+        if let (false, Some(word)) = (thread.is_empty(), label) {+            if let Ok((wins, _)) = find_windows(false, true) {+                if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(&word.to_lowercase())) {+                    if ab::desktop_set_window_identity(w.hwnd, &format!("KiCad ({thread})"), &format!("KiCad {word} - driven by {thread}")).is_ok() {+                        out["_windowLabeledFor"] = json!(thread);+                    }+                }+            }+        }+    }+    invalidate();+}++// ── Platform plumbing ─────────────────────────────────────────────────────────++fn is_not_impl(e: &str) -> bool {+    e.contains(": not implemented on ")+}++/// Map a platform Result: "not implemented on <os>" becomes the honest+/// not_supported_on_platform reply; anything else is a platform_error.+fn plat<T>(feature: &str, r: Result<T, String>) -> Result<T, Value> {+    r.map_err(|e| if is_not_impl(&e) { unsupported(feature) } else { fail("platform_error", format!("{feature}: {e}"), "The OS layer refused. kicad_diagnostics shows the platform capabilities; kicad_screenshot_all shows what is on screen.") })+}++fn exe_suffix() -> &'static str {+    if native().cli_file_name().ends_with(".exe") { ".exe" } else { "" }+}++/// `<bin_dir>/<name>[.exe]` for the primary install.+fn kicad_exe(info: &KicadInfo, name: &str) -> Option<PathBuf> {+    let inst = info.primary()?;+    let p = PathBuf::from(&inst.bin_dir).join(format!("{name}{}", exe_suffix()));+    if p.is_file() { Some(p) } else { None }+}++fn utc_stamp() -> String {+    // 2026-09-11T12:34:56Z -> 20260911-123456+    let s = kicad_common::now_utc_iso();+    let date: String = s.chars().take(10).filter(|c| c.is_ascii_digit()).collect();+    let time: String = s.chars().skip(11).take(8).filter(|c| c.is_ascii_digit()).collect();+    format!("{date}-{time}")+}++/// Spawn a KiCad GUI without activating its window, stderr captured to a launch log.+fn spawn_background(exe: &Path, args: &[String], tag: &str, trace_masks: Option<&str>) -> Result<(u32, Option<PathBuf>), Value> {+    let log = bridge_log::log_dir().and_then(|d| bridge_log::new_log_path(&d, &utc_stamp(), tag).ok());+    let env = bridge_log::trace_env(trace_masks);+    let pid = plat("spawn_background", native().spawn_background(exe, args, &env, log.as_deref()))?;+    invalidate();+    Ok((pid, log))+}++fn sleep_ms(ms: u64) {+    std::thread::sleep(Duration::from_millis(ms));+}++fn user_wants_foreground(args: &Value) -> bool {+    ["foreground", "showToUser", "bringToFront"].iter().any(|k| args.get(*k).and_then(Value::as_bool).unwrap_or(false))+}++fn user_idle_seconds() -> Option<f64> {+    native().seconds_since_input().ok().filter(|s| *s < 1e8).map(|s| (s * 10.0).round() / 10.0)+}++// ── Window finder (kicad_windows.py): ab first for z-order, local fallback ────++#[derive(Clone, Debug, Default)]+struct Win {+    hwnd: u64,+    title: String,+    class_name: String,+    /// x, y, width, height.+    rect: (i32, i32, i32, i32),+    z: Option<i64>,+    owner: u64,+    pid: u32,+    exe: String,+    minimized: bool,+    maximized: bool,+    hung: bool,+}++impl Win {+    fn kind(&self) -> WindowKind {+        wm::classify(&self.title, &self.class_name, self.owner)+    }+    fn is_kicad(&self) -> bool {+        if !self.exe.is_empty() {+            return wm::is_kicad_exe(&self.exe);+        }+        // No exe known (an ab older than 2.1.12 on a platform with no window_info): fail+        // open on the title, like the Python fallback.+        wm::title_looks_kicad(&self.title)+    }+    fn rect_json(&self) -> Value {+        json!({"x": self.rect.0, "y": self.rect.1, "width": self.rect.2, "height": self.rect.3})+    }+    fn from_info(w: &WindowInfo) -> Win {+        Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: None, owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }+    }+}++struct Cache {+    at: Option<Instant>,+    rows: Vec<Win>,+    source: &'static str,+}++static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });+const CACHE_TTL: Duration = Duration::from_millis(250);++/// Call after anything that opens, closes, or retitles a KiCad window.+fn invalidate() {+    if let Ok(mut c) = CACHE.lock() {+        c.at = None;+        c.rows.clear();+    }+}++fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {+    match ab::desktop_list_windows() {+        Ok(l) if !(l.windows.is_empty() && l.degraded) => {+            let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {+                hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: w.z, owner: w.owner, pid: w.pid,+                exe: w.image.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung,+            }).collect();+            // ab before 2.1.12 carries no image/owner (they arrived together): fill both+            // from the OS layer when it can, only for rows that lack the exe.+            for r in rows.iter_mut() {+                if r.exe.is_empty() {+                    if let Ok(Some(i)) = native().window_info(r.hwnd) {+                        r.exe = i.exe;+                        if r.owner == 0 { r.owner = i.owner; }+                        if r.class_name.is_empty() { r.class_name = i.class_name; }+                    }+                }+            }+            Ok((rows, "ad:desktop_list_windows"))+        }+        _ => {+            let rows = plat("window enumeration (list_windows)", native().list_windows())?;+            Ok((rows.iter().filter(|w| w.visible).map(Win::from_info).collect(), "local:EnumWindows"))+        }+    }+}++/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.+fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {+    let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;+    let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);+    if stale {+        let (rows, src) = list_all_windows()?;+        c.rows = rows;+        c.source = src;+        c.at = Some(Instant::now());+    }+    let src = c.source;+    if all_windows {+        return Ok((c.rows.clone(), src));+    }+    // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).+    let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();+    let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();+    Ok((rows, src))+}++fn kicad_windows() -> Vec<Win> {+    find_windows(false, false).map(|(w, _)| w).unwrap_or_default()+}++fn windows_of_kind(kind: WindowKind) -> Vec<Win> {+    kicad_windows().into_iter().filter(|w| w.kind() == kind).collect()+}++fn first_of_kind(kind: WindowKind) -> Option<u64> {+    windows_of_kind(kind).first().map(|w| w.hwnd)+}++fn find_project_manager() -> Option<u64> {+    kicad_windows().iter().find(|w| wm::is_project_manager(&w.title, w.owner)).map(|w| w.hwnd)+}++/// A window's current title without hanging on a modal-blocked thread.+fn title_of(hwnd: u64) -> String {+    if let Ok(Some(i)) = native().window_info(hwnd) {+        return i.title;+    }+    find_windows(true, true).ok().and_then(|(w, _)| w.into_iter().find(|w| w.hwnd == hwnd)).map(|w| w.title).unwrap_or_default()+}++fn window_alive(hwnd: u64) -> bool {+    match native().window_info(hwnd) {+        Ok(Some(i)) => i.visible,+        Ok(None) => false,+        Err(_) => find_windows(true, true).map(|(w, _)| w.iter().any(|w| w.hwnd == hwnd)).unwrap_or(false),+    }+}++/// The window a verb surfaced, for foreground and labelling: the kind the verb opens,+/// else the topmost KiCad window.+fn verb_window(command: &str, out: &Value) -> Option<u64> {+    if let Some(h) = out.get("hwnd").and_then(Value::as_u64).filter(|h| *h != 0) {+        return Some(h);+    }+    let kind = match command {+        "kicad_open_symbol_editor" => Some(WindowKind::SymbolEditor),+        "kicad_open_footprint_editor" => Some(WindowKind::FootprintEditor),+        "kicad_open_board" | "kicad_place_footprint" => Some(WindowKind::PcbEditor),+        "kicad_open_schematic" => Some(WindowKind::SchematicEditor),+        "kicad_open_3d_viewer" => Some(WindowKind::Viewer3d),+        _ => None,+    };+    let wins = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();+    kind.and_then(|k| wins.iter().find(|w| w.kind() == k).map(|w| w.hwnd)).or_else(|| wins.first().map(|w| w.hwnd))+}++// ── Focus etiquette (win_focus.py, the one-shot part only) ────────────────────++static FOCUS_EVENTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());++fn record_focus_event(ev: Value) {+    if let Ok(mut g) = FOCUS_EVENTS.lock() {+        g.push(ev);+        if g.len() > 200 {+            let n = g.len() - 200;+            g.drain(..n);+        }+    }+}++fn is_kicad_hwnd(hwnd: u64) -> bool {+    if let Ok(Some(i)) = native().window_info(hwnd) {+        return wm::is_kicad_exe(&i.exe);+    }+    kicad_windows().iter().any(|w| w.hwnd == hwnd)+}++/// capture_foreground before, compare after: if a KiCad window now holds the foreground+/// and the user had a non-KiCad window before, push KiCad back once (never activate the+/// user's window: Windows re-activates it naturally) and report the event.+fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {+    let after = native().foreground().ok().filter(|h| *h != 0)?;+    let before = fg_before?;+    if after == before || !is_kicad_hwnd(after) || is_kicad_hwnd(before) {+        return None;+    }+    let title = title_of(after);+    let action = match native().push_to_background(after) {+        Ok(()) => "pushed-to-background",+        Err(_) => "push-failed",+    };+    let ev = json!({"event": "self-raise", "hwnd": after, "title": title, "userWindow": before, "action": action, "at": kicad_common::now_utc_iso()});+    record_focus_event(ev.clone());+    Some(ev)+}++/// Explicit, user-requested foreground (win_focus.bring_to_user): caption the reason+/// first, then restore and activate. Refused without a reason.+fn bring_to_user(hwnd: u64, reason: &str) -> bool {+    if reason.trim().is_empty() {+        return false;+    }+    let msg = format!("KiCad bridge needs your screen for ~3s: {reason}. Background methods were exhausted; this was explicitly allowed by the calling AI (foreground:true).");+    let _ = ab::notify_user("Taking the foreground briefly", &msg, "warning");+    let _ = ab::desktop_caption("kicad-foreground", reason, 3000, "Announce the foreground the calling AI asked for before the KiCad window moves");+    sleep_ms(1200); // let the caption render BEFORE the window moves+    let ok = match native().bring_to_front(hwnd) {+        Ok(b) => b,+        Err(e) if is_not_impl(&e) => ab::desktop_bring_to_front(hwnd, reason).is_ok(),+        Err(_) => false,+    };+    record_focus_event(json!({"event": "user-foregrounded", "hwnd": hwnd, "reason": reason, "ok": ok, "at": kicad_common::now_utc_iso()}));+    ok+}++// ── Dialogs (close_windows.py) ────────────────────────────────────────────────++#[derive(Clone, Debug, Default)]+struct Dialog {+    hwnd: u64,+    title: String,+    class_name: String,+    owner: u64,+    body: String,+    via_global_sweep: bool,+}++/// A dialog's message text from its Static children (never blocks on a hung window).+fn dialog_body_text(hwnd: u64) -> String {+    let Ok(children) = native().child_windows(hwnd) else { return String::new() };+    let texts: Vec<String> = children.iter().filter(|c| c.class_name == "Static").map(|c| c.title.clone()).collect();+    wm::dialog_body(&texts)+}++/// RELIABLE scan for EVERY dialog around a running KiCad: the KiCad-process windows that+/// are dialogs (Win32 dialog class or owned), plus a GLOBAL #32770/error-title sweep so+/// an error box owned by an untracked pcbnew/eeschema/orphan is caught too.+fn scan_dialogs() -> Vec<Dialog> {+    let Ok((all, _)) = find_windows(true, false) else { return Vec::new() };+    let mut out = Vec::new();+    let mut seen = Vec::new();+    for w in all.iter().filter(|w| w.is_kicad()) {+        if !wm::is_dialog(&w.class_name, w.owner) {+            continue;+        }+        out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: false });+        seen.push(w.hwnd);+    }+    for w in all.iter() {+        if seen.contains(&w.hwnd) {+            continue;+        }+        if wm::looks_like_global_dialog(&w.title, &w.class_name) {+            out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });+            seen.push(w.hwnd);+        }+    }+    out+}++fn find_save_prompt() -> Option<Dialog> {+    scan_dialogs().into_iter().find(|d| wm::is_save_prompt(&d.title, &d.body))+}++/// Dismiss a dialog: a known dismiss button (OK for info boxes, Cancel/Finish for+/// wizards) through a time-boxed click, then Escape + WM_CLOSE. All posted to the exact+/// hwnd, no focus theft.+fn dismiss_dialog(hwnd: u64) -> String {+    for tier in wm::DISMISS_BUTTON_TIERS {+        if native().click_button(hwnd, tier).is_ok() {+            invalidate();+            return "clicked-button".into();+        }+    }+    let _ = native().post_key(hwnd, wm::VK_ESCAPE, None);+    let _ = native().post_close(hwnd);+    invalidate();+    "escape+WM_CLOSE".into()+}++/// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed.+fn screenshot_dialog(hwnd: u64, title: &str) -> String {+    ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default()+}++/// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops+/// attempting OpenGL on this machine. Called only right after dismissing the "could not+/// use OpenGL" notice, which is PROOF OpenGL is unavailable here.+fn force_software_canvas(version: &str) -> bool {+    let Some(root) = native().config_root() else { return false };+    let mut targets: Vec<PathBuf> = Vec::new();+    if !version.is_empty() {+        let d = root.join(kicad_platform::major_minor(version));+        if d.is_dir() {+            targets.push(d.join("kicad_common.json"));+        }+    }+    if targets.is_empty() {+        targets = kicad_common::common_files(&root);+        if targets.is_empty() && !version.is_empty() {+            targets.push(root.join(kicad_platform::major_minor(version)).join("kicad_common.json"));+        }+    }+    let mut changed = false;+    for cfg in targets {+        let mut doc = std::fs::read_to_string(&cfg).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));+        if !wm::force_software_canvas_doc(&mut doc) {+            continue;+        }+        if let Some(p) = cfg.parent() {+            let _ = std::fs::create_dir_all(p);+        }+        if let Ok(text) = serde_json::to_string_pretty(&doc) {+            if std::fs::write(&cfg, text).is_ok() {+                changed = true;+            }+        }+    }+    changed+}++/// close_windows._win_sweep_dialogs: expire the benign dialogs (or ALL dialogs with+/// dismiss_all), looping because dismissing one often spawns the next.+fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value {+    let mut dismissed = Vec::new();+    let mut remaining = Vec::new();+    let mut forced = false;+    let mut shot: Vec<u64> = Vec::new();+    let deadline = Instant::now() + Duration::from_secs(25);+    for _pass in 0..5 {+        if Instant::now() > deadline {+            break;+        }+        remaining.clear();+        let mut acted = false;+        for d in scan_dialogs() {+            match wm::sweep_decision(&d.title, &d.body, dismiss_all) {+                DialogAction::LeaveProgress => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body, "progress": true})),+                DialogAction::Dismiss { benign } => {+                    let mut entry = json!({"title": d.title, "body": d.body});+                    if screenshot && !shot.contains(&d.hwnd) {+                        entry["screenshot"] = json!(screenshot_dialog(d.hwnd, &d.title));+                        shot.push(d.hwnd);+                    }+                    entry["action"] = json!(dismiss_dialog(d.hwnd));+                    dismissed.push(entry);+                    acted = true;+                    if benign && !forced {+                        forced = force_software_canvas(&info.version);+                    }+                }+                DialogAction::Leave => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})),+            }+        }+        if !acted {+            break;+        }+        sleep_ms(400);+    }+    json!({"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced})+}++/// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late+/// dialog, screenshots and reads each one found, expires it (never a progress dialog),+/// and returns None when nothing was ever seen.+fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration) -> Option<Value> {+    let rounds = settle_rounds.max(1);+    let mut found = Vec::new();+    for r in 0..rounds {+        invalidate();+        found = scan_dialogs();+        if !found.is_empty() {+            break;+        }+        if r + 1 < rounds {+            std::thread::sleep(settle_gap);+        }+    }+    if found.is_empty() {+        return None;+    }+    let mut dialogs = Vec::new();+    let mut had_error = false;+    let mut seen: Vec<u64> = Vec::new();+    let deadline = Instant::now() + Duration::from_secs(25);+    for _pass in 0..4 {+        if Instant::now() > deadline {+            break;+        }+        let mut acted = false;+        invalidate();+        for d in scan_dialogs() {+            if seen.contains(&d.hwnd) {+                continue;+            }+            seen.push(d.hwnd);+            match wm::auto_sweep_decision(&d.title, &d.body) {+                DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),+                DialogAction::Dismiss { benign } => {+                    if !benign {+                        had_error = true;+                    }+                    let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };+                    let action = dismiss_dialog(d.hwnd);+                    dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot}));+                    acted = true;+                }+                DialogAction::Leave => {}+            }+        }+        if !acted {+            break;+        }+        sleep_ms(350);+    }+    if dialogs.is_empty() {+        return None;+    }+    Some(json!({"count": dialogs.len(), "dialogs": dialogs, "hadError": had_error}))+}++/// The rows `kicad_errors` reads (bridge_log::DialogRow), for the misc group to consume+/// once kicad_errors moves to a group that runs after this one.+pub fn dialog_rows() -> Vec<bridge_log::DialogRow> {+    scan_dialogs().into_iter().map(|d| {+        let texts: Vec<String> = native().child_windows(d.hwnd).map(|c| c.iter().filter(|c| c.class_name != "Button").map(|c| c.title.clone()).filter(|t| !t.trim().is_empty()).collect()).unwrap_or_default();+        bridge_log::DialogRow { hwnd: d.hwnd, title: d.title, texts, uia_text: None }+    }).collect()+}++// ── Menu walk, UIA, posted keys: the rungs ────────────────────────────────────++/// Resolve a menu item whose label contains `needle` from the window's live menu bar and+/// fire it with WM_COMMAND. Ok(Some((id, label))) when fired, Ok(None) when no such item.+fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>, String> {+    let items = native().menu_tree(hwnd)?;+    let want = needle.to_lowercase();+    for it in items {+        let Some(label) = it.path.last() else { continue };+        let low = label.replace('&', "").to_lowercase();+        if low.contains(&want) && it.id != 0 {+            native().menu_invoke(hwnd, it.id)?;+            invalidate();+            return Ok(Some((it.id, label.replace('&', ""))));+        }+    }+    Ok(None)+}++/// win_menu.find_by_accelerator: a chord to the menu command that carries it.+fn menu_invoke_by_accel(hwnd: u64, chord: &str) -> Result<Option<(u32, String)>, String> {+    let items = native().menu_tree(hwnd)?;+    let want = wm::norm_accel(chord);+    for it in items {+        if let Some(a) = &it.accel {+            if wm::norm_accel(a) == want && it.id != 0 {+                native().menu_invoke(hwnd, it.id)?;+                invalidate();+                let label = it.path.last().map(|l| l.replace('&', "")).unwrap_or_default();+                return Ok(Some((it.id, label)));+            }+        }+    }+    Ok(None)+}++/// Poll for a window of `kind` until `deadline`; `repost` fires once at `repost_after`.+fn wait_for_kind(kind: WindowKind, deadline: Instant, repost: Option<(u64, u32)>, repost_after: Duration) -> Option<u64> {+    let t0 = Instant::now();+    let mut reposted = false;+    loop {+        invalidate();+        if let Some(h) = first_of_kind(kind) {+            return Some(h);+        }+        if Instant::now() >= deadline {+            return None;+        }+        if !reposted && t0.elapsed() > repost_after {+            if let Some((host, id)) = repost {+                let _ = native().menu_invoke(host, id);+            }+            reposted = true;+        }+        sleep_ms(500);+    }+}++/// Every descendant (class, text, rect, visible) of a window with its parent handle.+/// The platform's `child_windows` returns the whole subtree with `owner` carrying the+/// PARENT handle (the Windows implementation is built for exactly this join).+#[derive(Clone, Debug)]+struct Child {+    hwnd: u64,+    class_name: String,+    text: String,+    rect: (i32, i32, i32, i32),+    visible: bool,+    parent: u64,+}++fn descendants(root: u64) -> Vec<Child> {+    let Ok(kids) = native().child_windows(root) else { return Vec::new() };+    kids.iter().map(|k| Child { hwnd: k.hwnd, class_name: k.class_name.clone(), text: k.title.clone(), rect: k.rect, visible: k.visible, parent: if k.owner == 0 { root } else { k.owner } }).collect()+}++/// The library tree's filter Edit: class "Edit" under a parent whose text is "searchCtrl".+fn find_search_edit(frame: u64) -> Option<Child> {+    let all = descendants(frame);+    all.iter().find(|c| c.class_name == "Edit" && all.iter().any(|p| p.hwnd == c.parent && p.text == "searchCtrl")).cloned()+}++static TREE_TOGGLED: Mutex<Vec<(u64, Instant)>> = Mutex::new(Vec::new());++/// The tree search control, showing the Library Tree pane through the frame's own View+/// menu when the user has it hidden (toggle at most once per editor per minute).+fn ensure_tree_search(frame: u64, trace: &mut Vec<String>) -> Option<Child> {+    if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {+        return Some(e);+    }+    let may_toggle = TREE_TOGGLED.lock().map(|g| !g.iter().any(|(h, t)| *h == frame && t.elapsed() < Duration::from_secs(60))).unwrap_or(true);+    if may_toggle {+        match menu_invoke_by_text(frame, "library tree") {+            Ok(Some((id, _))) => {+                trace.push(format!("library tree pane was hidden: View menu toggled (id {id})"));+                if let Ok(mut g) = TREE_TOGGLED.lock() {+                    g.retain(|(h, _)| *h != frame);+                    g.push((frame, Instant::now()));+                }+            }+            Ok(None) => trace.push("search Edit not visible and no 'Library Tree' menu item found".into()),+            Err(e) => trace.push(format!("search Edit not visible; menu walk failed: {e}")),+        }+    }+    for _ in 0..16 {+        if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {+            return Some(e);+        }+        sleep_ms(250);+    }+    None+}++/// The rung used to put text in the filter box: "uia" (ValuePattern) else "post" (WM_SETTEXT).+fn set_filter_text(edit: u64, text: &str) -> Result<&'static str, String> {+    if native().uia_set_value(edit, "", text).is_ok() {+        return Ok("uia");+    }+    native().set_text(edit, text).map(|_| "post")+}++fn post_vk(hwnd: u64, vk: u16) {+    let _ = native().post_key(hwnd, vk, None);+}++/// Which editor title check applies during navigation.+#[derive(Clone, Copy)]+enum Verify<'a> {+    Symbol { name: &'a str, library: &'a str },+    Footprint { name: &'a str, library: &'a str },+    Never,+}++fn verify_title(title: &str, v: Verify<'_>) -> bool {+    match v {+        Verify::Symbol { name, library } => wm::title_confirms_symbol(title, name, library),+        Verify::Footprint { name, library } => wm::title_confirms_footprint(title, name, library),+        Verify::Never => false,+    }+}++/// The plugin-free BACKGROUND library-tree load (0.9.83/0.9.89, wiki #71): text into the+/// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates,+/// Down x1..3 then Enter, a posted double-click on the first row. Zero focus.+/// Returns (confirmed, trace, mechanism).+fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {+    let mut steps: Vec<String> = Vec::new();+    let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});+    let mut mechanism = "post";+    let Some(edit) = ensure_tree_search(frame, &mut steps) else {+        steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());+        trace["steps"] = json!(steps);+        return (false, trace, mechanism);+    };+    trace["editHwnd"] = json!(edit.hwnd);+    match set_filter_text(edit.hwnd, filter) {+        Ok(m) => {+            mechanism = m;+            steps.push(format!("filter text set via {m}"));+        }+        Err(e) => {+            steps.push(format!("could not set the filter text: {e}"));+            trace["steps"] = json!(steps);+            return (false, trace, mechanism);+        }+    }+    sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild+    // Down THROUGH the filter box: KiCad forwards nav keys to the tree.+    post_vk(edit.hwnd, wm::VK_DOWN);+    sleep_ms(500);+    steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));+    // Tree candidates: tall siblings under the Libraries pane (wx generic controls share+    // bland class names, so geometry is the discriminator) plus any wxDataView.+    let all = descendants(frame);+    let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);+    let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);+    let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();+    trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());+    let title_ok = || verify_title(&title_of(frame), verify);+    // Round 1: Enter posted straight to each tree candidate.+    for c in &cands {+        post_vk(c.hwnd, wm::VK_RETURN);+    }+    for _ in 0..6 {+        sleep_ms(700);+        if title_ok() {+            steps.push("VK_RETURN to tree candidates -> title confirms".into());+            trace["steps"] = json!(steps);+            return (true, trace, mechanism);+        }+    }+    steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));+    // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside+    // the tree: Down (first lands on the library node, second on the symbol row), then Enter.+    for downs in 1..=3 {+        for c in &cands {+            post_vk(c.hwnd, wm::VK_DOWN);+        }+        sleep_ms(400);+        for c in &cands {+            post_vk(c.hwnd, wm::VK_RETURN);+        }+        for _ in 0..5 {+            sleep_ms(600);+            if title_ok() {+                steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));+                trace["steps"] = json!(steps);+                return (true, trace, mechanism);+            }+        }+        steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));+    }+    // Round 2: a posted double-click on the first visible row (just under the header).+    for c in &cands {+        let x = (c.rect.2 / 3).max(10);+        let y = 58;+        let _ = native().post_click(c.hwnd, x, y, false);+        let _ = native().post_click(c.hwnd, x, y, true);+    }+    for _ in 0..8 {+        sleep_ms(700);+        if title_ok() {+            steps.push("posted double-click on first row -> title confirms".into());+            trace["steps"] = json!(steps);+            return (true, trace, mechanism);+        }+    }+    steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));+    trace["steps"] = json!(steps);+    (false, trace, mechanism)+}++// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────++struct Budget {+    t0: Instant,+    limit: Duration,+}++impl Budget {+    fn new(args: &Value) -> Budget {+        let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);+        Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }+    }+    fn out(&self) -> bool {+        self.t0.elapsed() > self.limit+    }+    fn elapsed_sec(&self) -> u64 {+        self.t0.elapsed().as_secs()+    }+}++struct OpenSpec {+    kind: WindowKind,+    /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").+    menu_needle: &'static str,+    uia_name: &'static str,+    /// Frames whose menu bar / UIA tree carries the launcher, in preference order+    /// (a running editor of the same family first, the project manager last).+    hosts: &'static [WindowKind],+    error_code: &'static str,+    starting_code: &'static str,+    label: &'static str,+}++const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };+const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };++/// The early-return contract when a slow cold start would outlive the transport budget+/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.+fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {+    invalidate();+    if first_of_kind(spec.kind).is_none() {+        return json!({+            "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,+            "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,+            "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),+            "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),+        });+    }+    json!({+        "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",+        "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),+        "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),+        "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",+    })+}++/// Make sure the project manager exists (spawn kicad.exe in the background if not).+fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {+    invalidate();+    if let Some(h) = find_project_manager() {+        return Ok(h);+    }+    let Some(exe) = kicad_exe(info, "kicad") else {+        return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));+    };+    spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;+    pathways.push("spawned-project-manager".into());+    let deadline = Instant::now() + Duration::from_secs(10);+    loop {+        sleep_ms(500);+        invalidate();+        if let Some(h) = find_project_manager() {+            sleep_ms(2000);+            return Ok(h);+        }+        if Instant::now() > deadline {+            return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));+        }+    }+}++/// Open an editor frame: an existing one wins; else the native menu walk on each host+/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).+fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {+    invalidate();+    if let Some(h) = first_of_kind(spec.kind) {+        return Ok((h, "existing"));+    }+    // Hosts: a live editor frame of the same family first, then the project manager+    // (spawned in the background when nothing at all is running).+    let mut hosts: Vec<u64> = Vec::new();+    for k in spec.hosts {+        if *k == WindowKind::ProjectManager {+            continue;+        }+        hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));+    }+    if spec.hosts.contains(&WindowKind::ProjectManager) {+        let pm = match find_project_manager() {+            Some(h) => h,+            None if hosts.is_empty() => ensure_project_manager(info, pathways)?,+            None => 0,+        };+        if pm != 0 {+            hosts.push(pm);+        }+    }+    if hosts.is_empty() {+        return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));+    }+    // Rung: native menu walk + WM_COMMAND (background, version-proof ids).+    let mut menu_unsupported: Option<Value> = None;+    for &host in &hosts {+        if budget.out() {+            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+        }+        match menu_invoke_by_text(host, spec.menu_needle) {+            Ok(Some((id, _label))) => {+                pathways.push(format!("menu-walk-id-{id}"));+                pathways.push("wm_command".into());+                let deadline = Instant::now() + Duration::from_secs(30);+                if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {+                    return Ok((h, "menu"));+                }+                pathways.push("wm_command-no-window".into());+            }+            Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),+            Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }+            Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),+        }+    }+    // Rung: UI Automation Invoke by accessible name (no focus, no cursor).+    let mut uia_ran = false;+    for &host in &hosts {+        if budget.out() {+            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+        }+        match native().uia_invoke(host, spec.uia_name) {+            Ok(_) => {+                uia_ran = true;+                pathways.push("uia".into());+                let deadline = Instant::now() + Duration::from_secs(30);+                if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {+                    return Ok((h, "uia"));+                }+                pathways.push("uia-no-window".into());+            }+            Err(e) if is_not_impl(&e) => {+                if let Some(u) = menu_unsupported.take() {+                    return Err(u);+                }+                pathways.push("uia-unsupported".into());+            }+            Err(e) => {+                uia_ran = true;+                pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));+            }+        }+    }+    if let (Some(u), false) = (menu_unsupported, uia_ran) {+        return Err(u);+    }+    let idle = user_idle_seconds();+    let busy = idle.map(|i| i < 120.0).unwrap_or(false);+    Err(json!({+        "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,+        "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,+            if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),+        "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",+    }))+}++/// Ask an open editor to close so the next open re-indexes the libraries: ab's+/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is+/// measured here. Returns (gone, how).+fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {+    let how = match ab::desktop_close_window(hwnd, reason) {+        Ok(_) => "ad:desktop_close_window",+        Err(_) => {+            if native().post_close(hwnd).is_err() {+                return (false, "none");+            }+            "local:WM_CLOSE"+        }+    };+    invalidate();+    let deadline = Instant::now() + timeout;+    while Instant::now() < deadline {+        sleep_ms(300);+        invalidate();+        if !window_alive(hwnd) {+            return (true, how);+        }+    }+    (false, how)+}++// ── kicad_launch ──────────────────────────────────────────────────────────────++fn launch(state: &mut State, args: &Value) -> Value {+    let existing = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    if !existing.is_empty() {+        return json!({+            "success": true, "alreadyRunning": true,+            "windows": existing.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),+            "output": format!("KiCad is already running ({} window(s)); nothing launched.", existing.len()),+            "_hint": "Idempotent no-op: KiCad was already up. Use kicad_open_editors to inventory editors, or the open_* verbs to open a specific one.",+        });+    }+    let project = arg_str(args, "project").unwrap_or("").trim().to_string();+    if !project.is_empty() && !Path::new(&project).exists() {+        return fail("project_not_found", format!("Project not found: {project}"), "Absolute Windows path required; %VAR% expansion is not performed.");+    }+    let info = state.kicad_info();+    let Some(exe) = kicad_exe(&info, "kicad") else {+        return fail("kicad_exe_not_found", "kicad.exe not found", "No KiCad installation detected. kicad_readiness reports install state; kicad_upgrade installs KiCad silently (per-user, no UAC).");+    };+    let argv: Vec<String> = if project.is_empty() { vec![] } else { vec![project.clone()] };+    let (pid, log) = match spawn_background(&exe, &argv, "launch", arg_str(args, "traceMasks")) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] != json!("not_supported_on_platform") {+                e["error"] = json!(format!("Failed to launch KiCad: {}", e["error"].as_str().unwrap_or("")));+            }+            return e;+        }+    };+    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(15.0).max(3.0);+    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    let mut windows = Vec::new();+    while Instant::now() < deadline {+        windows = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();+        if !windows.is_empty() {+            break;+        }+        sleep_ms(500);+    }+    let log_path = log.as_ref().map(|p| norm(p));+    if !windows.is_empty() {+        return json!({+            "success": true, "launched": true, "pid": pid, "mechanism": "spawn",+            "windows": windows.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),+            "output": format!("KiCad launched; {} window(s) up.", windows.len()),+            "logPath": log_path,+            "_hint": "Launched in the BACKGROUND (no focus steal). First run on a fresh install may show a setup wizard; kicad_window_info self-heals benign dialogs; kicad_screenshot_all shows every window if something looks stuck.",+            "_next": ["kicad_window_info", "kicad_open_editors", "kicad_open_board"],+        });+    }+    json!({+        "success": false, "pid": pid, "mechanism": "spawn", "logPath": log_path,+        "error": format!("kicad.exe started but no KiCad window appeared within {}s", wait_s as i64),+        "_hint": "Cold start on a slow box (or software-OpenGL) can exceed the wait; retry kicad_window_info in a few seconds before treating this as a failure. A modal (crash-recovery / first-run) can also hold the window back: kicad_screenshot_all will show it.",+        "statusVerb": "kicad_window_info",+    })+}++// ── kicad_open_board / kicad_open_schematic ───────────────────────────────────++fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let Some(file_path) = arg_str(args, "filePath") else {+        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the file on the desktop.");+    };+    let path = PathBuf::from(file_path);+    if !path.exists() {+        return fail("file_not_found", format!("File not found: {file_path}"), "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.");+    }+    let (exe_name, label, lint_verb) = match kind {+        WindowKind::PcbEditor => ("pcbnew", "PCB board", "kicad_lint_board"),+        _ => ("eeschema", "schematic", "kicad_lint_schematic"),+    };+    let Some(exe) = kicad_exe(&info, exe_name) else {+        return fail("kicad_exe_not_found", format!("KiCad executable not found: {exe_name}_exe"), "Report to the user and ask them to reinstall KiCad; the installation appears corrupted.");+    };+    let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();+    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    // Already open: the existing window, not a fresh reload.+    invalidate();+    if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {+        return json!({+            "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",+            "output": format!("{label} editor already has {name} open"),+            "_hint": "The existing window was reused, not reloaded. kicad_close first if you need a fresh load from disk.",+        });+    }+    let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] != json!("not_supported_on_platform") {+                e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));+                e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");+            }+            return e;+        }+    };+    // Verify by window title with one deadline (no nested sleeps).+    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);+    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    let mut appeared: Option<Win> = None;+    while Instant::now() < deadline {+        invalidate();+        let wins = windows_of_kind(kind);+        appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();+        if appeared.is_some() {+            break;+        }+        if native().process_alive(pid) == Some(false) {+            break;+        }+        sleep_ms(500);+    }+    let hint = format!(+        "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."+    );+    let mut out = json!({+        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),+        "output": format!("Launched {label} editor with {name}"),+        "_hint": hint,+    });+    match appeared {+        Some(w) => {+            out["hwnd"] = json!(w.hwnd);+            out["title"] = json!(w.title);+            out["windowAppeared"] = json!(true);+        }+        None => {+            let alive = native().process_alive(pid);+            out["windowAppeared"] = json!(false);+            if alive == Some(false) {+                out["success"] = json!(false);+                out["errorCode"] = json!("editor_exited");+                out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));+                out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));+            } else {+                out["statusVerb"] = json!("kicad_window_info");+                out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));+            }+        }+    }+    out+}++// ── kicad_open_symbol_editor ──────────────────────────────────────────────────++fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE+/// prefix match in either direction against the Adom library's real names.+fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {+    let ctx = lib_ctx(info);+    let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };+    if !lib_path.is_file() {+        return (None, "library file not found".into());+    }+    let names = libraries::list_symbols(&lib_path);+    if names.iter().any(|n| n == requested) {+        return (Some(requested.to_string()), "exact".into());+    }+    if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {+        return (Some(n.clone()), "case-insensitive".into());+    }+    let r = requested.to_lowercase();+    let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();+    match pref.len() {+        1 => (Some(pref[0].clone()), "unique prefix match".into()),+        0 => (None, "no match".into()),+        _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),+    }+}++fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {+    let ctx = lib_ctx(info);+    libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)+}++fn open_symbol_editor(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let mut symbol_name = arg_str(args, "symbolName").unwrap_or("").to_string();+    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();+    // Wiki #62: the file is named for the MPN but the symbol inside may not be. Resolve+    // the caller's name against the library's real names BEFORE navigating and say so.+    let mut note = Map::new();+    if !symbol_name.is_empty() && (library_name.is_empty() || library_name.eq_ignore_ascii_case("adom")) {+        let (resolved, how) = resolve_symbol_alias(&info, &symbol_name);+        match resolved {+            Some(r) if r != symbol_name => {+                note.insert("aliasedFrom".into(), json!(symbol_name));+                note.insert("aliasHow".into(), json!(how));+                symbol_name = r;+            }+            None if how.starts_with("ambiguous") => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not in the library and the prefix match is {how}"))); }+            None if how == "no match" => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not a symbol name in the Adom library (no exact, case-insensitive or prefix match)"))); }+            _ => {}+        }+    }+    let mut out = open_symbol_editor_inner(&info, args, &symbol_name, &library_name);+    if !note.is_empty() {+        if let Some(o) = out.as_object_mut() {+            for (k, v) in &note {+                o.insert(k.clone(), v.clone());+            }+            if let Some(from) = note.get("aliasedFrom").and_then(Value::as_str) {+                let prev = o.get("_hint").and_then(Value::as_str).unwrap_or("").to_string();+                o.insert("_hint".into(), json!(format!("Resolved '{from}' to symbol '{symbol_name}' ({}). {prev}", note["aliasHow"].as_str().unwrap_or(""))));+            }+        }+    }+    out+}++fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, library_name: &str) -> Value {+    let spec = &SYMBOL_SPEC;+    let budget = Budget::new(args);+    let rescan = args.get("rescan").and_then(Value::as_bool).unwrap_or(false);+    let mut pathways: Vec<String> = Vec::new();+    invalidate();+    // Already loaded? (#49: the library is a CONSTRAINT, exact library:symbol match only.)+    if !symbol_name.is_empty() && !rescan {+        for w in windows_of_kind(WindowKind::SymbolEditor) {+            if wm::title_confirms_symbol(&w.title, symbol_name, library_name) {+                let _ = native().show_no_activate(w.hwnd);+                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Symbol '{symbol_name}' is already loaded in the Symbol Editor.")});+                merge(&mut v, wm::resolved_fields(&w.title, library_name, "Symbol Editor"));+                return v;+            }+        }+    }+    // rescan:true (0.9.192): the remedy for library_cache_stale. Close the editor so the+    // open below indexes a fresh one. Opt-in: it closes a window the user may be using.+    let mut rescanned = false;+    let mut close_via = "";+    if rescan {+        if let Some(h) = first_of_kind(WindowKind::SymbolEditor) {+            let (gone, how) = close_and_wait(h, "Close the Symbol Editor so its next open re-indexes the symbol libraries", Duration::from_secs(12));+            close_via = how;+            if gone {+                rescanned = true;+                pathways.push("rescan-closed-stale-editor".into());+            } else {+                pathways.push("rescan-close-failed".into());+            }+        }+    }+    let (frame, mechanism) = match open_editor_frame(info, spec, &budget, &mut pathways) {+        Ok(x) => x,+        Err(e) => return e,+    };+    if mechanism != "existing" {+        sleep_ms(1500); // let the frame settle+    }+    let _ = native().show_no_activate(frame);+    if symbol_name.is_empty() && library_name.is_empty() {+        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Symbol Editor is already open.".to_string() } else { format!("Symbol Editor opened successfully ({mechanism}).") }});+    }+    // Library-only browse mode (0.9.92): filter the tree to the library, no part load.+    if symbol_name.is_empty() {+        let (_, trace, nav_mech) = navigate_tree(frame, library_name, Verify::Never);+        return json!({+            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,+            "output": format!("Symbol Editor open with the '{library_name}' library filtered and selected. No symbol was requested, so none was loaded into the canvas."),+            "via": "win32-library-browse",+            "_hint": "Library browse mode: the tree is filtered to the library. To load a part into the canvas, call again with symbolName too.",+        });+    }+    let filter = if library_name.is_empty() { symbol_name.to_string() } else { format!("{library_name} {symbol_name}") };+    let verify = Verify::Symbol { name: symbol_name, library: library_name };+    let mut attempts = 0;+    let mut last_trace = Value::Null;+    let mut last_mech = "post";+    for attempt in 1..=2 {+        attempts = attempt;+        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);+        last_trace = trace;+        last_mech = nav_mech;+        if ok {+            let title = title_of(frame);+            let mut v = json!({+                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,+                "output": format!("Symbol '{symbol_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),+                "navigation": last_trace, "pathwaysTried": pathways, "rescanned": rescanned,+                "editorCloseVia": if rescanned { json!(close_via) } else { Value::Null },+            });+            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+            return v;+        }+        if budget.out() {+            // Wiki #71: a navigation that ran against an editor that is up is not "starting".+            let title = title_of(frame);+            let mut v = json!({+                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,+                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "rescanned": rescanned, "mechanism": nav_mech,+                "navigation": last_trace,+                "error": format!("The Symbol Editor is open and one background navigation ran, but the title did not confirm '{symbol_name}' before this call's time budget ended (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: zero candidates means Enter reached no window (report it with the trace).",+            });+            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+            return v;+        }+    }+    // Two background attempts did not confirm. Is the symbol even in the editor's world?+    invalidate();+    if first_of_kind(WindowKind::SymbolEditor).is_none() {+        return json!({+            "success": false, "editorOpened": false, "symbolLoaded": false, "errorCode": "symbol_editor_not_open",+            "error": format!("No Symbol Editor is open, so '{symbol_name}' could not be loaded (it was open earlier in this call if rescan was requested)."),+            "_hint": "The editor is gone rather than confused: nothing to navigate. Call again without rescan to open a fresh one. If this followed rescan:true, the close succeeded and the reopen did not - that is a bridge bug worth reporting, not a library problem.",+        });+    }+    let title = title_of(frame);+    let stale = symbol_on_disk(info, symbol_name) && !rescan;+    if stale {+        let ctx = lib_ctx(info);+        let paths = libraries::adom_paths(&ctx);+        return json!({+            "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,+            "errorCode": "library_cache_stale", "mechanism": last_mech, "navigation": last_trace,+            "symbolInLibraryFile": true,+            "symLibTable": paths.as_ref().map(|(t, _)| norm(t)),+            "libraryFile": paths.as_ref().map(|(_, l)| norm(l)),+            "error": format!("'{symbol_name}' is in the Adom library file on disk, but two background navigations did not load it (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+            "_hint": "The verdict is only 'navigation failed while the symbol is in the Adom file'. Check the sym-lib-table row points at libraryFile (a OneDrive-redirected Documents folder is the classic split), then re-call with rescan:true to close and reopen the editor so it re-indexes; kicad_rescan_libraries force:true restarts KiCad when a reopen is not enough.",+        });+    }+    let mut v = json!({+        "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,+        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,+        "error": format!("Symbol Editor open but '{symbol_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+        "_hint": "Two background attempts did not confirm, so a third is unlikely to help - check that the symbol really exists in this library. The keystroke fallback (allowFocusSteal) is not in the native build; pass foreground:true with a foregroundReason to bring the editor to the user and let them load it.",+    });+    merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+    v+}++fn merge(into: &mut Value, extra: Value) {+    if let (Some(o), Some(e)) = (into.as_object_mut(), extra.as_object()) {+        for (k, v) in e {+            o.insert(k.clone(), v.clone());+        }+    }+}++// ── kicad_open_footprint_editor ───────────────────────────────────────────────++fn open_footprint_editor(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let spec = &FOOTPRINT_SPEC;+    let budget = Budget::new(args);+    let footprint_name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").to_string();+    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();+    let mut pathways: Vec<String> = Vec::new();+    invalidate();+    if !footprint_name.is_empty() {+        for w in windows_of_kind(WindowKind::FootprintEditor) {+            if wm::title_confirms_footprint(&w.title, &footprint_name, &library_name) {+                let _ = native().show_no_activate(w.hwnd);+                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Footprint '{footprint_name}' is already loaded in the Footprint Editor.")});+                merge(&mut v, wm::resolved_fields(&w.title, &library_name, "Footprint Editor"));+                return v;+            }+        }+    }+    if args.get("allowCursorClick").and_then(Value::as_bool).unwrap_or(false) {+        pathways.push("icon_click_not_in_native_build".into());+    } else {+        pathways.push("icon_click_skipped_background_mode".into());+    }+    let (frame, mechanism) = match open_editor_frame(&info, spec, &budget, &mut pathways) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] == json!(spec.error_code) {+                e["error"] = json!("Could not open the Footprint Editor via the background path.");+                e["_hint"] = json!("The background rungs (the host frame's native menu, then UI Automation) fired without a Footprint Editor appearing. Open a board first (kicad_open_board) so a PCB Editor hosts the launcher, check kicad_screenshot_all for a modal dialog, then retry. The mouse-moving launcher click (allowCursorClick) is not in the native build.");+            }+            return e;+        }+    };+    if mechanism != "existing" {+        sleep_ms(1500);+    }+    let _ = native().show_no_activate(frame);+    if footprint_name.is_empty() && library_name.is_empty() {+        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Footprint Editor is already open.".to_string() } else { format!("Footprint Editor opened successfully ({mechanism}).") }});+    }+    if footprint_name.is_empty() {+        let (_, trace, nav_mech) = navigate_tree(frame, &library_name, Verify::Never);+        return json!({+            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,+            "output": format!("Footprint Editor open with the '{library_name}' library filtered and selected. No footprint was requested, so none was loaded into the canvas."),+            "via": "win32-library-browse",+            "_hint": "Library browse mode: the tree is filtered to the library. To load a part, call again with footprintName too.",+        });+    }+    let filter = if library_name.is_empty() { footprint_name.clone() } else { format!("{library_name} {footprint_name}") };+    let verify = Verify::Footprint { name: &footprint_name, library: &library_name };+    let mut last_trace = Value::Null;+    let mut last_mech = "post";+    for attempt in 1..=2 {+        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);+        last_trace = trace;+        last_mech = nav_mech;+        if ok {+            let title = title_of(frame);+            let mut v = json!({+                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,+                "output": format!("Footprint '{footprint_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),+                "navigation": last_trace, "pathwaysTried": pathways,+            });+            merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+            return v;+        }+        if budget.out() {+            return json!({+                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,+                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "mechanism": nav_mech, "navigation": last_trace,+                "error": format!("The Footprint Editor is open and one background navigation ran, but the title did not confirm '{footprint_name}' before this call's time budget ended (title: {})", title_of(frame)),+                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds).",+            });+        }+    }+    let title = title_of(frame);+    let mut v = json!({+        "success": false, "editorOpened": true, "footprintLoaded": false, "attempts": 2, "hwnd": frame,+        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,+        "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+        "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",+    });+    merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+    v+}++// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────++fn poll_3d_window(rounds: usize) -> Option<Win> {+    for _ in 0..rounds.max(1) {+        sleep_ms(750);+        invalidate();+        if let Some(w) = windows_of_kind(WindowKind::Viewer3d).into_iter().next() {+            return Some(w);+        }+    }     None }++/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a+/// 3D Viewer window appeared.+fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {+    let mut unsupported_menu: Option<Value> = None;+    match menu_invoke_by_text(frame, "3d viewer") {+        Ok(Some((id, _))) => {+            pathways.push(format!("win32menu(id={id})"));+            if let Some(w) = poll_3d_window(12) {+                return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));+            }+        }+        Ok(None) => pathways.push("win32menu-not-found".into()),+        Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),+        Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),+    }+    match native().uia_invoke(frame, "3D Viewer") {+        Ok(_) => {+            pathways.push("uia".into());+            if let Some(w) = poll_3d_window(12) {+                return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));+            }+        }+        Err(e) if is_not_impl(&e) => {+            // Neither rung exists on this OS: say so instead of "did not open".+            if let Some(u) = unsupported_menu {+                return Err(u);+            }+            pathways.push("uia-unsupported".into());+        }+        Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),+    }+    Ok(None)+}++fn open_3d_viewer(args: &Value) -> Value {+    let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase();+    if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {+        return fail("bad_arg", format!("Unknown editor '{choice}'. Use 'auto', 'fp', or 'pcb'."), "editor selects which open editor's View menu opens the viewer.");+    }+    invalidate();+    let mut fp = first_of_kind(WindowKind::FootprintEditor);+    let mut pcb = first_of_kind(WindowKind::PcbEditor);+    // Explicit source editors (tour, 2026-09-03): a caller that knows its editor's hwnd+    // passes pcbHwnd / fpHwnd and we never guess among several PCB Editors.+    if let Some(h) = arg_any_u64(args, &["pcbHwnd", "pcb_hwnd"]) {+        pcb = Some(h);+    }+    if let Some(h) = arg_any_u64(args, &["fpHwnd", "fp_hwnd"]) {+        fp = Some(h);+    }+    match choice.as_str() {+        "fp" | "footprint" => {+            if fp.is_none() {+                return fail("footprint_editor_not_open", "Footprint Editor is not open.", "Call open_footprint_editor first or pass editor='pcb'.");+            }+            pcb = None;+        }+        "pcb" | "board" => {+            if pcb.is_none() {+                return fail("pcb_editor_not_open", "PCB Editor is not open.", "Call open_board first or pass editor='fp'.");+            }+            fp = None;+        }+        _ => {+            if pcb.is_some() && fp.is_some() {+                fp = None; // auto: prefer PCB when both are open (tour-like flow)+            }+        }+    }+    let mut pathways: Vec<String> = Vec::new();+    if let Some(f) = fp {+        match open_3d_from(f, "Footprint Editor", &mut pathways) {+            Ok(Some(v)) => return v,+            Err(e) => return e,+            Ok(None) => {}+        }+        if choice == "auto" && pcb.is_none() {+            pcb = first_of_kind(WindowKind::PcbEditor);+        }+        if pcb.is_none() {+            return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open from Footprint Editor.", "_hint": "Call open_footprint_editor with a footprintName first, OR pass editor='pcb' to target the board's 3D viewer instead."});+        }+    }+    if let Some(p) = pcb {+        match open_3d_from(p, "PCB Editor", &mut pathways) {+            Ok(Some(v)) => return v,+            Err(e) => return e,+            Ok(None) => {}+        }+        return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open via the background path.", "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry; kicad_screenshot_all shows whether a dialog is holding the editor."});+    }+    fail("no_source_editor", "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_board.", "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.")+}++fn arg_any_u64(args: &Value, keys: &[&str]) -> Option<u64> {+    keys.iter().find_map(|k| args.get(*k)).and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).filter(|h| *h != 0)+}++// ── kicad_open_editors ────────────────────────────────────────────────────────++fn open_editors() -> Value {+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut by_kind: BTreeMap<String, Vec<Value>> = BTreeMap::new();+    for w in &wins {+        let kind = w.kind();+        if !kind.is_editor() && kind != WindowKind::ProjectManager {+            continue;+        }+        let row = json!({"kind": kind.as_str(), "exeName": w.exe, "pid": w.pid, "frameClass": w.class_name, "frameTitle": w.title, "hwnd": w.hwnd, "isShown": !w.minimized});+        by_kind.entry(kind.as_str().to_string()).or_default().push(row.clone());+        editors.push(row);+    }+    let running = !wins.is_empty();+    let hint = if editors.is_empty() && !running {+        "0 editors and no KiCad window: KiCad is almost certainly NOT RUNNING. This verb only INVENTORIES editors inside a running KiCad (despite its name); it never launches anything. To cold-start KiCad use kicad_launch; kicad_open_board / kicad_open_symbol_editor also cold-launch when opening a specific target."+    } else {+        "Inventory from window enumeration (the native build has no in-process plugin): each KiCad top-level frame is classified by its title. kind is one of project_manager, schematic_editor, pcb_editor, symbol_editor, footprint_editor, 3d_viewer."+    };+    json!({+        "success": true, "editors": editors, "byKind": by_kind, "kicadRunning": running, "windowSource": source,+        "summary": {"totalEditors": editors.len(), "probedPlugins": 0, "failedPlugins": 0, "byKindCounts": by_kind.iter().map(|(k, v)| (k.clone(), json!(v.len()))).collect::<Map<String, Value>>()},+        "_hint": hint,+    })+}++// ── kicad_close* ──────────────────────────────────────────────────────────────++fn close_kind(kind: WindowKind, label: &str) -> Value {+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w.into_iter().filter(|w| w.kind() == kind).collect::<Vec<_>>(),+        Err(e) => return e,+    };+    if wins.is_empty() {+        return json!({"success": true, "output": format!("{label} is not open.")});+    }+    let mut closed = Vec::new();+    let mut failed = Vec::new();+    let mut hows = Vec::new();+    for w in &wins {+        let (gone, how) = close_and_wait(w.hwnd, &format!("Close the KiCad {label} the calling AI asked to close"), Duration::from_secs(5));+        hows.push(how);+        if gone { closed.push(w.title.clone()) } else { failed.push(w.title.clone()) }+    }+    if !failed.is_empty() {+        let prompt = find_save_prompt();+        let mut v = fail("close_failed", format!("Failed to close: {}", failed.join(", ")), "Call close_kicad with {\"force\": true} to force-kill the window, then retry.");+        if let Some(p) = prompt {+            v["errorCode"] = json!("unsaved_changes");+            v["unsavedChanges"] = json!([{"window": failed[0], "dialog": p.title, "body": p.body}]);+            v["_hint"] = json!("KiCad is asking whether to save changes. Ask the user, then save in KiCad or call kicad_close {\"discardChanges\": true} to press Discard on their behalf.");+        }+        return v;+    }+    json!({+        "success": true, "closeVia": hows,+        "output": if closed.len() == 1 { format!("{label} closed: {}", closed[0]) } else { format!("Closed {} {label} window(s).", closed.len()) },+    })+}++/// kicad_close {force:true}: the ONLY path that kills a process.+fn force_kill_kicad() -> Result<Vec<String>, Value> {+    let procs = plat("process enumeration (processes)", native().processes(wm::KILL_PROCESS_NAMES))?;+    let mut killed = Vec::new();+    for name in wm::KILL_PROCESS_NAMES {+        let mut any = false;+        for p in procs.iter().filter(|p| p.exe.eq_ignore_ascii_case(name) || p.exe.eq_ignore_ascii_case(name.trim_end_matches(".exe"))) {+            if native().kill_process(p.pid).is_ok() {+                any = true;+            }+        }+        if any {+            killed.push(name.to_string());+        }+    }+    invalidate();+    Ok(killed)+}++#[derive(Default)]+struct CloseState {+    discard: bool,+    closed: Vec<String>,+    failed: Vec<String>,+    unsaved: Vec<Value>,+    discard_log: Vec<Value>,+}++/// One window: WM_CLOSE and wait; on a save prompt press Discard (discardChanges) or+/// report unsavedChanges. Some(true) closed, Some(false) failed, None already recorded.+fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> {+    let (gone, _) = close_and_wait(w.hwnd, "Close KiCad as the calling AI asked", Duration::from_secs(8));+    if gone {+        return Some(true);+    }+    let Some(prompt) = find_save_prompt() else { return Some(false) };+    if st.discard {+        let mut attempts = Vec::new();+        let mut pressed = match native().click_button(prompt.hwnd, wm::DISCARD_LABELS) {+            Ok(m) => { attempts.push(json!({"win32Button": true, "method": m})); true }+            Err(e) => { attempts.push(json!({"win32Button": false, "error": e.chars().take(120).collect::<String>()})); false }+        };+        if !pressed {+            for label in wm::DISCARD_LABELS_UIA {+                match native().uia_invoke(prompt.hwnd, label) {+                    Ok(m) => { attempts.push(json!({"label": label, "uia": m})); pressed = true; break; }+                    Err(e) => attempts.push(json!({"label": label, "uia": e.chars().take(120).collect::<String>()})),+                }+            }+        }+        st.discard_log.push(json!({"window": w.title, "dialog": prompt.title, "attempts": attempts}));+        if pressed {+            let deadline = Instant::now() + Duration::from_secs(8);+            while Instant::now() < deadline {+                sleep_ms(300);+                invalidate();+                if !window_alive(w.hwnd) {+                    st.closed.push(format!("{} (changes discarded)", w.title));+                    return None;+                }+            }+        }+    }+    st.unsaved.push(json!({"window": w.title, "dialog": prompt.title, "body": prompt.body}));+    Some(false)+}++fn close_kicad(_state: &mut State, args: &Value) -> Value {+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);+    if force {+        return match force_kill_kicad() {+            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),+            Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),+            Err(e) => e,+        };+    }+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut manager: Option<Win> = None;+    for w in wins.into_iter().filter(|w| w.owner == 0) {+        if wm::is_manager_title(&w.title) {+            manager = Some(w);+        } else {+            editors.push(w);+        }+    }+    if editors.is_empty() && manager.is_none() {+        return json!({"success": true, "output": "KiCad is not running."});+    }+    // Issue #84: a dirty board answers WM_CLOSE with "Save changes?". Read it; with+    // discardChanges press Discard and stay graceful; otherwise leave the window open and+    // report unsavedChanges. Nothing is ever killed here.+    let mut st = CloseState::default();+    st.discard = discard;+    for w in &editors {+        match close_one(&mut st, w) {+            Some(true) => st.closed.push(w.title.clone()),+            Some(false) => st.failed.push(w.title.clone()),+            None => {}+        }+    }+    if let (Some(m), true) = (&manager, st.unsaved.is_empty()) {+        match close_one(&mut st, m) {+            Some(true) => st.closed.push(m.title.clone()),+            Some(false) => st.failed.push(m.title.clone()),+            None => {}+        }+    }+    let CloseState { closed, failed, unsaved, discard_log, .. } = st;+    if !unsaved.is_empty() {+        return json!({+            "success": false, "errorCode": "unsaved_changes", "unsavedChanges": unsaved,+            "error": format!("KiCad is asking whether to save changes; nothing was killed: {}", unsaved.iter().map(|u| format!("{}: {}", u["window"].as_str().unwrap_or(""), u["body"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect::<Vec<_>>().join("; ")),+            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+            "_hint": "The user's unsaved work is still open. Ask them, then either save in KiCad (Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close {\"discardChanges\": true} to press Discard on their behalf. force:true kills the process, which discards silently.",+            "discard": discard_log,+        });+    }+    if !failed.is_empty() {+        // No save prompt and no close: the native build never kills here (force:true is+        // the only kill path); say what did not close and how to force it.+        return json!({+            "success": false, "errorCode": "close_failed",+            "error": format!("Failed to close: {}", failed.join(", ")),+            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+            "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows (a kill discards unsaved work silently). kicad_screenshot_all shows whether a dialog is holding the window.",+        });+    }+    let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});+    if !discard_log.is_empty() {+        out["discard"] = json!(discard_log);+    }+    out+}++// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────++fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {+        let changed = force_software_canvas(&info.version);+        return json!({+            "success": true, "forcedSoftwareCanvas": changed,+            "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },+            "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",+        });+    }+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {+        let wins = scan_dialogs();+        let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();+        return json!({+            "success": true, "debug": true, "kicadPids": pids,+            "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),+            "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),+        });+    }+    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);+    let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);+    let res = sweep_dialogs(&info, all, screenshot);+    let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);+    let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);+    let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+    json!({+        "success": true,+        "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),+        "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,+        "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),+    })+}++fn window_info(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    let mut auto_dismissed = json!([]);+    let mut forced_sw = false;+    if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {+        let s = sweep_dialogs(&info, false, false);+        auto_dismissed = s["dismissed"].clone();+        forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+    }+    invalidate();+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut dialogs = Vec::new();+    let mut manager = Value::Null;+    for w in &wins {+        let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});+        if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {+            let mut d = base;+            d["ownerHwnd"] = json!(w.owner);+            d["body"] = json!(dialog_body_text(w.hwnd));+            dialogs.push(d);+        } else if wm::is_manager_title(&w.title) {+            manager = base;+        } else {+            editors.push(base);+        }+    }+    let mut data = json!({+        "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),+        "autoDismissed": auto_dismissed, "windowSource": source,+    });+    if forced_sw {+        data["forcedSoftwareCanvas"] = json!(true);+    }+    let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);+    if n_auto > 0 {+        data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));+    }+    if !dialogs.is_empty() {+        data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");+    }+    json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})+}++// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────++/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.+fn canvas_probe(hwnd: u64) -> Value {+    let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };+    let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));+    wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)+}++/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);+/// the canvas verdict is always ours.+fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {+    let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {+        Ok(shot) => shot.to_json(),+        Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}),+    };+    v["canvas"] = canvas_probe(hwnd);+    v+}++fn screenshot_all(state: &mut State) -> Value {+    let info = window_info(state, &json!({}));+    if info["success"] != json!(true) {+        return info;+    }+    let data = &info["data"];+    let mut windows: Vec<(u64, String, &str)> = Vec::new();+    for ed in data["editors"].as_array().into_iter().flatten() {+        windows.push((ed["hwnd"].as_u64().unwrap_or(0), ed["title"].as_str().unwrap_or("").into(), "editor"));+    }+    if let Some(h) = data["projectManager"]["hwnd"].as_u64() {+        windows.push((h, data["projectManager"]["title"].as_str().unwrap_or("").into(), "main"));+    }+    for d in data["modalDialogs"].as_array().into_iter().flatten() {+        windows.push((d["hwnd"].as_u64().unwrap_or(0), d["title"].as_str().unwrap_or("").into(), "dialog"));+    }+    if windows.is_empty() {+        return fail("no_windows", "No KiCad windows found.", "kicad_launch or an open_* verb first; kicad_window_info lists what is up.");+    }+    let mut shots = Vec::new();+    let mut ab_unavailable = false;+    for (hwnd, title, kind) in &windows {+        let label = wm::safe_label(title, kind);+        let r = screenshot_hwnd(*hwnd, &label, None);+        let mut entry = json!({"type": kind, "title": title, "hwnd": hwnd, "canvas": r["canvas"]});+        if r["success"] == json!(true) {+            for k in ["savedTo", "fullPath", "safePath", "localFullPath", "sizeKB", "capturedBy", "shotId", "coordMap", "ownedPopupCount", "popups", "screenshots"] {+                if let Some(v) = r.get(k) {+                    entry[k] = v.clone();+                }+            }+        } else {+            entry["error"] = json!(r["error"].as_str().unwrap_or("screenshot failed"));+            if r["abUnavailable"] == json!(true) {+                ab_unavailable = true;+            }+        }+        shots.push(entry);+    }+    let captured = shots.iter().filter(|s| s.get("fullPath").is_some()).count();+    let mut out = json!({+        "success": captured > 0 || !ab_unavailable,+        "output": format!("Captured {captured} of {} KiCad window(s)", shots.len()),+        "data": {"screenshots": shots},+        "_hint": "Each entry is a background PrintWindow capture by ab with its owned popups (popups[]) and a coordMap: click what you see with desktop_click {space:\"image\", shotId, x, y}. canvas.rendered false means the GL canvas has not painted yet (or there is no usable GPU).",+    });+    if ab_unavailable {+        out["abUnavailable"] = json!(true);+        out["errorCode"] = json!("ab_unavailable");+        out["_hint"] = json!("ab's direct API is unreachable, so no image was written (the native build has no local PNG path). ADOM_DIRECT_API_URL missing from the spawn env is the usual cause; ab's own desktop_screenshot_window from the CLI still works.");+    }+    out+}++// ── kicad_state ───────────────────────────────────────────────────────────────++fn b64_encode(bytes: &[u8]) -> String {+    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);+    for chunk in bytes.chunks(3) {+        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+        out.push(T[(n >> 18) as usize & 63] as char);+        out.push(T[(n >> 12) as usize & 63] as char);+        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });+    }+    out+}++fn kicad_state(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let include_images = args.get("includeImages").and_then(Value::as_bool).unwrap_or(true);+    let max_width = args.get("maxWidth").and_then(Value::as_u64).unwrap_or(1100) as u32;+    let mut shots = Vec::new();+    let mut ab_unavailable = false;+    if include_images {+        for w in &wins {+            let mut entry = json!({"title": w.title, "hwnd": w.hwnd, "className": w.class_name});+            let r = screenshot_hwnd(w.hwnd, "state", Some(max_width));+            entry["canvas"] = r["canvas"].clone();+            if r["success"] == json!(true) {+                let path = r["safePath"].as_str().filter(|p| !p.is_empty()).or_else(|| r["fullPath"].as_str()).unwrap_or("");+                match std::fs::read(path) {+                    Ok(bytes) if path.to_lowercase().ends_with(".png") => {+                        entry["image"] = json!(b64_encode(&bytes));+                        entry["path"] = json!(path);+                        if let Some(c) = r.get("coordMap") {+                            if let (Some(w), Some(h)) = (c["image"]["w"].as_u64(), c["image"]["h"].as_u64()) {+                                entry["width"] = json!(w);+                                entry["height"] = json!(h);+                            }+                        }+                        if let Some(s) = r.get("shotId") { entry["shotId"] = s.clone(); }+                        if let Some(p) = r.get("popups") { entry["popups"] = p.clone(); }+                    }+                    Ok(_) => { entry["error"] = json!("capture is not a png"); entry["path"] = json!(path); }+                    Err(e) => { entry["error"] = json!(format!("could not read {path}: {e}")); }+                }+            } else {+                entry["error"] = json!(r["error"].as_str().unwrap_or("capture failed"));+                if r["abUnavailable"] == json!(true) {+                    ab_unavailable = true;+                }+            }+            shots.push(entry);+        }+    }+    let editors = open_editors();+    let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();+    let events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().rev().take(40).rev().cloned().collect()).unwrap_or_default();+    let mut out = json!({+        "success": true, "running": !wins.is_empty(), "focusEvents": events,+        // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this+        // first and defers while the user is active (< 600 = touched in the last 10 min).+        "userIdleSeconds": user_idle_seconds(),+        "focusDebug": {"guardian": "not built in the native bridge (phase 4); one-shot check per verb only", "oneShotCheck": true},+        "activeVersion": info.version,+        "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(),+        "windowSource": source,+        "shots": shots,+        "editors": {"kicadRunning": editors["kicadRunning"], "list": editors["editors"], "summary": editors["summary"]},+        "modalDialogs": modal,+        "_hint": "focusEvents is the focus log: a self-raise (pushed-to-background) entry is a KiCad window that took the foreground and was pushed back once; user-foregrounded means the caller asked with a reason. The full state of the union: every KiCad-owned window is in shots[] as inline base64 PNG (render them all; one window is NOT the app). modalDialogs non-empty means something is blocking; editors.list is the window inventory by kind. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll.",+    });+    if ab_unavailable {+        out["abUnavailable"] = json!(true);+    }+    out+}++// ── kicad_send_key / kicad_click / kicad_fix_keyboard ─────────────────────────++fn foreground_kicad_hwnd() -> Option<u64> {+    if let Ok(fg) = native().foreground() {+        if fg != 0 && is_kicad_hwnd(fg) {+            return Some(fg);+        }+    }+    kicad_windows().first().map(|w| w.hwnd)+}++fn send_key(args: &Value) -> Value {+    let Some(key) = arg_str(args, "key").map(str::trim) else {+        return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'.");+    };+    let target = match arg_any_u64(args, &["hwnd"]) {+        Some(h) => h,+        None => match foreground_kicad_hwnd() {+            Some(h) => h,+            None => return fail("no_window", "No KiCad window found to send key to.", "kicad_window_info lists the windows; pass hwnd."),+        },+    };+    let title = title_of(target);+    // NEVER steal the user's screen: show without activating and post to THIS hwnd's queue.+    let _ = native().show_no_activate(target);+    sleep_ms(50);+    let allow_fg = args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false);+    if let Some((_mods, _base)) = wm::parse_modifier_combo(key) {+        // A CHORD usually IS a menu accelerator (v0.9.106): resolve it from the window's+        // menu bar and fire WM_COMMAND, fully background.+        match menu_invoke_by_accel(target, key) {+            Ok(Some((id, label))) => {+                return json!({+                    "success": true, "mechanism": "menu",+                    "output": format!("Chord '{key}' delivered as menu command '{label}' (background)"),+                    "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+                    "_hint": "Resolved via the window's menu-bar accelerator table and fired with WM_COMMAND; no focus was touched. This is the preferred path for every chord that maps to a menu item.",+                });+            }+            Err(e) if is_not_impl(&e) => return unsupported("native menu walk (menu_tree)"),+            _ => {}+        }+        if allow_fg {+            return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");+        }+        return json!({+            "success": false, "errorCode": "foreground_required",+            "error": format!("send the key chord '{key}' to KiCad needs the foreground, which would take over the user's screen: no menu item carries this accelerator, so only real (focused) keystrokes can deliver it"),+            "_hint": "Use ab's desktop_press_key (it owns focus-first chord delivery and asks the user), or find the menu item and call it by name. The native bridge never sends global keystrokes.",+        });+    }+    if allow_fg {+        return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");+    }+    let (vk, ch) = match wm::vk_for_key(key) {+        Some(vk) => (vk, None),+        None => {+            let mut chars = key.chars();+            match (chars.next(), chars.next()) {+                (Some(c), None) => (wm::vk_for_char(c).unwrap_or(0), Some(c)),+                _ => return fail("unknown_key", format!("Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."), "Chords resolve through the menu bar; anything else needs desktop_press_key."),+            }+        }+    };+    match native().post_key(target, vk, ch) {+        Ok(()) => json!({+            "success": true, "mechanism": "post",+            "output": format!("Posted '{key}' to '{title}' (background)"),+            "data": {"hwnd": target, "title": title, "method": "postmessage", "foreground": false, "vk": vk},+            "_hint": "Delivered without touching the user's focus. If KiCad ignored it, the control may need real input: prefer a menu command by name; only as a last resort ask the user and use desktop_press_key.",+        }),+        Err(e) if is_not_impl(&e) => unsupported("posted keys (post_key)"),+        Err(e) => fail("post_failed", format!("Could not post '{key}' to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),+    }+}++fn click(args: &Value) -> Value {+    let Some(hwnd) = arg_any_u64(args, &["hwnd"]) else {+        return fail("missing_arg", "Missing required arg: hwnd", "kicad_window_info lists the windows; pass hwnd.");+    };+    let (Some(x), Some(y)) = (args.get("x").and_then(Value::as_f64), args.get("y").and_then(Value::as_f64)) else {+        return fail("missing_arg", "Missing required args: x, y", "Pass x and y as fractions of the window (relative:true, default) or pixels (relative:false).");+    };+    let relative = args.get("relative").and_then(Value::as_bool).unwrap_or(true);+    if relative && !(0.0..=1.0).contains(&x) || relative && !(0.0..=1.0).contains(&y) {+        return json!({+            "success": false, "errorCode": "bad_arg",+            "error": format!("relative=True expects x/y as fractions 0.0-1.0 of the window, but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels."),+            "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ~ {\"x\":0.35,\"y\":0.09}. For exact pixel coords add \"relative\": false.",+        });+    }+    if args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false) {+        return unsupported("kicad_click allowForeground (a real cursor click via SendInput)");+    }+    let info = match native().window_info(hwnd) {+        Ok(Some(i)) => i,+        Ok(None) => return fail("bad_hwnd", format!("HWND {hwnd} is not a valid window."), "kicad_window_info lists the current handles."),+        Err(e) => return plat::<()>("window lookup (window_info)", Err(e)).err().unwrap(),+    };+    let (w, h) = (info.rect.2, info.rect.3);+    let _ = native().show_no_activate(hwnd);+    sleep_ms(50);+    let (cx, cy) = if relative { ((x * w as f64) as i32, (y * h as f64) as i32) } else { (x as i32, y as i32) };+    match native().post_click(hwnd, cx, cy, false) {+        Ok(()) => json!({+            "success": true, "mechanism": "post",+            "output": format!("Posted click at ({cx},{cy}) in '{}' (background)", info.title),+            "data": {"hwnd": hwnd, "title": info.title, "client": [cx, cy], "screen": [info.rect.0 + cx, info.rect.1 + cy], "method": "postmessage", "foreground": false, "relative": relative},+            "_hint": "Clicked without moving the user's mouse or taking focus. If the canvas ignored it, that control may need real input: ab's desktop_click {space:\"image\", shotId, x, y} on a kicad_screenshot_all coordMap asks the user first.",+        }),+        Err(e) if is_not_impl(&e) => unsupported("posted clicks (post_click)"),+        Err(e) => fail("post_failed", format!("Could not post the click to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),+    }+}++fn fix_keyboard() -> Value {+    json!({+        "success": true, "stuckKeys": [], "released": false,+        "output": "Nothing to fix: the native bridge never sends global keystrokes (no SendInput/keybd_event path), so it can never leave a modifier key held. Keys and clicks are posted to one window's queue, which touches no global keyboard state.",+        "_hint": "If keys ARE stuck on this desktop, another tool held them: ab's desktop_press_key path or a release of each modifier through it clears the OS state.",+    })+}++// ── kicad_place_footprint ─────────────────────────────────────────────────────++const BLANK_BOARD: &str = include_str!("../../../../templates/blank-board.kicad_pcb");++fn pseudo_uuid() -> String {+    // Not cryptographic: a KiCad item id only has to be unique within the board.+    let mut x: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15) ^ ((std::process::id() as u64) << 32);+    let mut bytes = [0u8; 16];+    for b in bytes.iter_mut() {+        x ^= x << 13;+        x ^= x >> 7;+        x ^= x << 17;+        *b = (x & 0xff) as u8;+    }+    bytes[6] = (bytes[6] & 0x0f) | 0x40;+    bytes[8] = (bytes[8] & 0x3f) | 0x80;+    let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();+    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])+}++/// Quote an unquoted top-level `(layer X)` token: `(layer F.Cu)` -> `(layer "F.Cu")`.+fn quote_first_layer(text: &str) -> String {+    let Some(i) = text.find("(layer ") else { return text.to_string() };+    let rest = &text[i + 7..];+    let trimmed = rest.trim_start();+    if trimmed.starts_with('"') {+        return text.to_string();+    }+    let end = trimmed.find(')').unwrap_or(0);+    let name = trimmed[..end].trim();+    if name.is_empty() || !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {+        return text.to_string();+    }+    format!("{}(layer \"{}\"){}", &text[..i], name, &trimmed[end + 1..])+}++/// Inject `(uuid ...)` and `(at x y)` right after the footprint's first quoted `(layer "...")`.+fn place_in_board(fp_text: &str, uid: &str, x: f64, y: f64) -> Option<String> {+    let i = fp_text.find("(layer \"")?;+    let close = fp_text[i..].find(')')? + i + 1;+    Some(format!("{}\n\t\t(uuid \"{uid}\")\n\t\t(at {x} {y}){}", &fp_text[..close], &fp_text[close..]))+}++fn place_footprint(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Install KiCad from https://www.kicad.org/download/ then retry.");+    }+    let file_name = arg_str(args, "fileName").unwrap_or("");+    let Some(content) = arg_str(args, "fileContent") else {+        return fail("missing_arg", "No fileContent (base64 .kicad_mod) specified", "Pass fileContent: the base64 of the .kicad_mod text.");+    };+    let mut fp_text = match kicad_core::install::b64_decode(content) {+        Ok(b) => String::from_utf8_lossy(&b).trim().to_string(),+        Err(e) => return fail("bad_content", format!("could not decode fileContent: {e}"), "fileContent must be standard base64."),+    };+    // Legacy `(module ...)` footprints (KiCad <= 5 / EAGLE imports): rename the wrapper+    // and quote the top-level layer token the splice relies on.+    if fp_text.starts_with("(module") {+        fp_text = format!("(footprint{}", &fp_text["(module".len()..]);+        fp_text = quote_first_layer(&fp_text);+    }+    if !fp_text.starts_with("(footprint") {+        return fail("bad_content", "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')", "Send the raw .kicad_mod text, base64-encoded.");+    }+    let fp_name = arg_str(args, "footprintName").map(str::to_string)+        .or_else(|| kicad_core::install::footprint_name_in(&fp_text))+        .or_else(|| Path::new(file_name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()))+        .unwrap_or_else(|| "footprint".into());+    let x = args.get("x").and_then(Value::as_f64).unwrap_or(148.0);+    let y = args.get("y").and_then(Value::as_f64).unwrap_or(105.0);+    let uid = pseudo_uuid();+    let Some(fp_board) = place_in_board(&fp_text, &uid, x, y) else {+        return fail("bad_content", "footprint has no (layer \"...\") token to anchor the placement", "A .kicad_mod carries (layer \"F.Cu\") near the top; this one does not parse.");+    };+    let skel = BLANK_BOARD.trim_end();+    let idx = skel.rfind(')').unwrap_or(skel.len());+    let combined = format!("{}\n{}\n)\n", skel[..idx].trim_end(), fp_board);+    let user_dir = info.primary().and_then(|i| native().user_dir(&i.version)).or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)).unwrap_or_else(|| PathBuf::from("."));+    let _ = std::fs::create_dir_all(&user_dir);+    let out_path = user_dir.join(format!("adom-place-{fp_name}.kicad_pcb"));+    if let Err(e) = std::fs::write(&out_path, combined) {+        return fail("write_failed", format!("could not write board: {e}"), "The preview board goes to Documents/KiCad/<version>; check it is writable.");+    }+    let board_path = norm(&out_path);+    let open_it = args.get("open").and_then(Value::as_bool).unwrap_or(true);+    let mut opened = false;+    let mut extra = Map::new();+    if open_it {+        match kicad_exe(&info, "pcbnew") {+            Some(exe) => match spawn_background(&exe, &[board_path.clone()], "pcbnew", None) {+                Ok((pid, _)) => {+                    opened = true;+                    extra.insert("pid".into(), json!(pid));+                    // Verify the frame by title with one deadline; the sweep runs after.+                    let stem = format!("adom-place-{fp_name}").to_lowercase();+                    let deadline = Instant::now() + Duration::from_secs(20);+                    while Instant::now() < deadline {+                        invalidate();+                        if let Some(w) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.to_lowercase().contains(&stem) || w.pid == pid) {+                            extra.insert("hwnd".into(), json!(w.hwnd));+                            extra.insert("title".into(), json!(w.title));+                            break;+                        }+                        sleep_ms(500);+                    }+                }+                Err(e) => {+                    if e["errorCode"] == json!("not_supported_on_platform") {+                        return e;+                    }+                    return json!({"success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": false, "output": format!("Board written but pcbnew launch failed: {}", e["error"].as_str().unwrap_or("")), "_hint": format!("Open it manually: {board_path}")});+                }+            },+            None => {}+        }+    }+    let mut v = json!({+        "success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": opened, "mechanism": if opened { "spawn" } else { "file" },+        "output": format!("Placed footprint '{fp_name}' at ({x}, {y}) mm on a preview board{}", if opened { " and opened it in pcbnew" } else { "" }),+    });+    merge(&mut v, Value::Object(extra));+    v+}++// ── kicad_enable_software_opengl ──────────────────────────────────────────────++const MESA_URL: &str = "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.60/mesa-llvmpipe-x64.zip";+const MESA_DLLS: &[&str] = &["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];+const MESA_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"];++fn enable_software_opengl(state: &mut State) -> Value {+    if native().capabilities().os != "windows" {+        return json!({"success": false, "error": "Windows-only", "errorCode": "unsupported_platform", "_hint": "Mesa llvmpipe DotLocal redirection only applies to KiCad's Windows build."});+    }+    let info = state.kicad_info();+    let mut bins: Vec<PathBuf> = Vec::new();+    for i in &info.installs {+        let mut b = PathBuf::from(if i.bin_dir.is_empty() { &i.base_dir } else { &i.bin_dir });+        if b.file_name().map(|n| n.to_string_lossy().to_lowercase() != "bin").unwrap_or(true) {+            b = b.join("bin");+        }+        if b.is_dir() && !bins.contains(&b) {+            bins.push(b);+        }+    }+    if bins.is_empty() {+        return json!({"success": false, "error": "No KiCad bin dir found; is KiCad installed?", "errorCode": "host_app_not_installed", "errorCodeLegacy": "kicad_not_installed", "hostApp": "KiCad", "_hint": "Run kicad_list_versions. Install KiCad first via kicad_upgrade."});+    }+    eprintln!("[kicad-bridge] enable_software_opengl: downloading Mesa llvmpipe...");+    let data = match pcm::http_fetch(MESA_URL, Duration::from_secs(180)) {+        Ok(d) => d,+        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),+    };+    let zip = match pcm::ZipFile::parse(&data) {+        Ok(z) => z,+        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),+    };+    let mut enabled = Vec::new();+    let mut errors = Vec::new();+    for bd in &bins {+        let mut ok = true;+        for dll in MESA_DLLS {+            let Some(entry) = zip.entries.iter().find(|e| e.name.rsplit('/').next() == Some(dll)) else {+                errors.push(format!("{}: {dll} missing from the Mesa zip", norm(bd)));+                ok = false;+                break;+            };+            let bytes = match zip.read(entry) {+                Ok(b) => b,+                Err(e) => { errors.push(format!("{}: {dll}: {e}", norm(bd))); ok = false; break; }+            };+            if let Err(e) = std::fs::write(bd.join(dll), bytes) {+                errors.push(if e.kind() == std::io::ErrorKind::PermissionDenied { format!("{}: permission denied (writing KiCad's bin needs elevation)", norm(bd)) } else { format!("{}: {e}", norm(bd)) });+                ok = false;+                break;+            }+        }+        if !ok {+            continue;+        }+        for exe in MESA_EXES {+            if bd.join(exe).exists() {+                let _ = std::fs::write(bd.join(format!("{exe}.local")), "");+            }+        }+        enabled.push(norm(bd));+        eprintln!("[kicad-bridge] enable_software_opengl: deployed to {}", norm(bd));+    }+    let ok = !enabled.is_empty();+    json!({+        "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",+        "_hint": if ok {+            "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."+        } else {+            "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."+        },+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn footprint_splice_matches_python() {+        let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";+        let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();+        assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));+        let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";+        let modern = format!("(footprint{}", &legacy["(module".len()..]);+        let q = quote_first_layer(&modern);+        assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));+        assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");+        assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());+        let u = pseudo_uuid();+        assert_eq!(u.len(), 36);+        assert_eq!(&u[14..15], "4");+    }++    #[test]+    fn base64_round_trip() {+        assert_eq!(b64_encode(b""), "");+        assert_eq!(b64_encode(b"f"), "Zg==");+        assert_eq!(b64_encode(b"fo"), "Zm8=");+        assert_eq!(b64_encode(b"foo"), "Zm9v");+        assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");+        let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();+        assert_eq!(back, b"\x89PNG\r\n\x1a\n");+    }++    #[test]+    fn stamp_and_foreground_args() {+        let s = utc_stamp();+        assert_eq!(s.len(), 15);+        assert_eq!(&s[8..9], "-");+        assert!(user_wants_foreground(&json!({"foreground": true})));+        assert!(user_wants_foreground(&json!({"showToUser": true})));+        assert!(user_wants_foreground(&json!({"bringToFront": true})));+        assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));+        assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));+        assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);+    }++    #[test]+    fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {+        let mut state = State::new();+        assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());+        assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());+        let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();+        assert_eq!(fk["success"], json!(true));+        assert_eq!(fk["stuckKeys"], json!([]));+        // Without window control (this host) the window verbs answer not_supported_on_platform+        // or an ab-unavailable error, never a hang and never a fake success.+        if !native().capabilities().window_control {+            std::env::remove_var("ADOM_DIRECT_API_URL");+            let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();+            assert_eq!(r["success"], json!(false));+            let code = r["errorCode"].as_str().unwrap_or("");+            assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");+            let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();+            assert_eq!(r["success"], json!(false));+            let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();+            assert_eq!(r["success"], json!(false));+        }+        let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();+        assert_eq!(r["errorCode"], json!("bad_arg"));+        let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();+        assert_eq!(r["errorCode"], json!("missing_arg"));+        let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();+        assert_eq!(r["errorCode"], json!("bad_arg"));+    }++    #[test]+    fn catalog_covers_the_python_window_group() {+        let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();+        for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {+            assert!(names.contains(&n), "{n} missing from the catalog");+        }+        assert!(!names.contains(&"kicad_errors"));+        for v in VERBS {+            assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);+        }+    }+}
rust/crates/kicad-core/src/ab.rs+723−1
@@ -1 +1,723 @@-//! Placeholder: ab callback client, filled in by phase 3.+//! The Adom Bridge (ab) callback client, ported from `handlers/ad_client.py` and+//! `caller_identity.py`.+//!+//! The bridge owns KiCad; ab owns Windows. Anything about windows themselves (a capture+//! with its owned popups and coordMap, the z-ordered window list, a graceful close with+//! ab's bookkeeping, a toast, a caption, a taskbar badge) is asked of ab over its loopback+//! direct API: `POST {ADOM_DIRECT_API_URL}/command`, falling back to the `host:port` in+//! `~/.adom/direct-api-port`. `X-Adom-Bridge-Token` (from `ADOM_BRIDGE_TOKEN`, re-read on+//! every call because a stale token 403s after an ab restart) is attribution only.+//!+//! Caller provenance: while serving an AI thread's verb the three `X-Adom-Caller-*`+//! headers are forwarded verbatim and `X-Adom-Caller-Delegate: kicad` is added, so the+//! user's Activity Log reads "chip-fetcher tab 3 (via kicad)". A call the bridge makes on+//! its own behalf (no caller known) sends `caller: {aiThread: "kicad bridge (self)"}` in+//! the args instead, never a stale identity and never nothing (ab refuses anonymous calls+//! with `caller_identity_required`).+//!+//! Every call carries its own timeout and NEVER hangs a verb: an unreachable ab yields+//! `AbError::Unavailable`, the verb reports `abUnavailable: true` and falls back to the+//! platform layer where one exists. After a transport failure the client backs off for a+//! few seconds so a verb that asks five times does not pay five connect timeouts.+//!+//! Reply shapes (wiki #121): the direct API hands a verb payload back either flat+//! (`{success, windows: [...]}`) or wrapped (`{success, output: "<json>"}` with the verb's+//! data under `output.data`). `AbReply::get` looks in every place a key can live.++use std::path::PathBuf;+use std::sync::Mutex;+use std::time::{Duration, Instant};++use serde_json::{json, Map, Value};++/// What we name ourselves when forwarding a caller (the verb prefix ab knows us by).+pub const DELEGATE: &str = "kicad";+/// The `caller` block for a call the bridge makes on its own behalf.+pub const SELF_THREAD: &str = "kicad bridge (self)";++/// Default per-call timeout (Python `_TIMEOUT`).+pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8);+/// TCP connect budget: ab is on loopback, so a connect that takes longer is a dead port.+#[cfg(feature = "net")]+const CONNECT_TIMEOUT: Duration = Duration::from_millis(1500);+/// After a transport failure, report Unavailable immediately for this long.+const BACKOFF: Duration = Duration::from_secs(3);++// ── Caller identity ───────────────────────────────────────────────────────────++/// Who asked for the verb we are serving. Self-asserted; attribution only, never a gate.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct Caller {+    pub thread: String,+    pub container: String,+    pub reason: String,+}++static CALLER: Mutex<Option<Caller>> = Mutex::new(None);++/// Record the caller of the verb being served (cleared with `clear_caller`).+pub fn set_caller(c: Caller) {+    if let Ok(mut g) = CALLER.lock() {+        *g = if c.thread.trim().is_empty() && c.container.trim().is_empty() && c.reason.trim().is_empty() { None } else { Some(c) };+    }+}++/// Build a caller from a verb's args (`args.caller.aiThread` / `containerName`, the shape+/// ab actually relays) with the request headers as the fallback, and record it. The+/// explicit args form wins when both are present (Python `set_from_request`).+pub fn caller_from(args: &Value, header_thread: &str, header_container: &str, header_reason: &str) -> Caller {+    let mut c = Caller { thread: header_thread.to_string(), container: header_container.to_string(), reason: header_reason.to_string() };+    if let Some(cb) = args.get("caller").and_then(Value::as_object) {+        if let Some(t) = cb.get("aiThread").and_then(Value::as_str).filter(|s| !s.trim().is_empty()) {+            c.thread = t.to_string();+        }+        if let Some(n) = cb.get("containerName").and_then(Value::as_str).filter(|s| !s.trim().is_empty()) {+            c.container = n.to_string();+        }+    }+    if c.reason.is_empty() {+        if let Some(r) = args.get("reason").and_then(Value::as_str) {+            c.reason = r.to_string();+        }+    }+    c+}++/// `caller_from` with no headers, then `set_caller`.+pub fn set_caller_from_args(args: &Value) -> Caller {+    let c = caller_from(args, "", "", "");+    set_caller(c.clone());+    c+}++pub fn clear_caller() {+    if let Ok(mut g) = CALLER.lock() {+        *g = None;+    }+}++pub fn current_caller() -> Option<Caller> {+    CALLER.lock().ok().and_then(|g| g.clone())+}++/// The caller's thread name, or "" when the request was anonymous.+pub fn thread_name() -> String {+    current_caller().map(|c| c.thread).unwrap_or_default()+}++/// Headers for a callback made on the caller's behalf (Python `forward_headers`): empty+/// when no thread is known, else the three echoed plus our Delegate.+pub fn forward_headers() -> Vec<(String, String)> {+    let Some(c) = current_caller() else { return Vec::new() };+    if c.thread.trim().is_empty() {+        return Vec::new();+    }+    vec![+        ("X-Adom-Caller-Thread".into(), c.thread),+        ("X-Adom-Caller-Container".into(), c.container),+        ("X-Adom-Caller-Reason".into(), c.reason),+        ("X-Adom-Caller-Delegate".into(), DELEGATE.into()),+    ]+}++pub fn self_caller() -> Value {+    json!({"aiThread": SELF_THREAD, "containerName": "local"})+}++// ── Transport ─────────────────────────────────────────────────────────────────++#[derive(Clone, Debug, PartialEq, Eq)]+pub enum AbError {+    /// No direct API (env var and port file both missing), connection refused, timeout,+    /// or the net feature is off in this build.+    Unavailable(String),+    /// ab answered, with success:false.+    Refused { code: String, error: String },+    /// ab answered something we could not parse.+    Shape(String),+}++impl AbError {+    pub fn is_unavailable(&self) -> bool {+        matches!(self, AbError::Unavailable(_))+    }+    pub fn message(&self) -> String {+        match self {+            AbError::Unavailable(m) => format!("ab unavailable: {m}"),+            AbError::Refused { code, error } => {+                if code.is_empty() { format!("ab refused: {error}") } else { format!("ab refused ({code}): {error}") }+            }+            AbError::Shape(m) => format!("ab reply unreadable: {m}"),+        }+    }+}++impl std::fmt::Display for AbError {+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+        f.write_str(&self.message())+    }+}++/// A parsed ab reply: the raw envelope and the unwrapped verb payload.+#[derive(Clone, Debug, Default)]+pub struct AbReply {+    pub raw: Value,+    pub body: Value,+}++impl AbReply {+    /// A key wherever ab put it: `body.data[key]`, `body[key]`, then `raw[key]`.+    pub fn get(&self, key: &str) -> Option<&Value> {+        if let Some(v) = self.body.get("data").and_then(|d| d.get(key)) {+            return Some(v);+        }+        if let Some(v) = self.body.get(key) {+            return Some(v);+        }+        self.raw.get(key)+    }+    pub fn get_str(&self, key: &str) -> Option<&str> {+        self.get(key).and_then(Value::as_str).filter(|s| !s.is_empty())+    }+    pub fn get_u64(&self, key: &str) -> Option<u64> {+        self.get(key).and_then(num_u64)+    }+    pub fn get_bool(&self, key: &str) -> Option<bool> {+        self.get(key).and_then(Value::as_bool)+    }+}++/// Python `_from_ad` unwrapping: `output` (a JSON string or an object) when present,+/// else the envelope itself.+pub fn unwrap_payload(raw: &Value) -> Value {+    match raw.get("output") {+        Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok().filter(Value::is_object).unwrap_or_else(|| raw.clone()),+        Some(Value::Object(_)) => raw["output"].clone(),+        _ => raw.clone(),+    }+}++fn num_u64(v: &Value) -> Option<u64> {+    match v {+        Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f as u64)),+        Value::String(s) => s.trim().parse().ok(),+        _ => None,+    }+}++fn num_i64(v: &Value) -> Option<i64> {+    match v {+        Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),+        Value::String(s) => s.trim().parse().ok(),+        _ => None,+    }+}++/// The loopback direct-API base: `ADOM_DIRECT_API_URL`, else `~/.adom/direct-api-port`.+pub fn base_url() -> Option<String> {+    if let Ok(u) = std::env::var("ADOM_DIRECT_API_URL") {+        let u = u.trim().trim_end_matches('/').to_string();+        if !u.is_empty() {+            return Some(u);+        }+    }+    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)?;+    let text = std::fs::read_to_string(home.join(".adom").join("direct-api-port")).ok()?;+    let t = text.trim();+    if t.is_empty() {+        return None;+    }+    if t.starts_with("http://") || t.starts_with("https://") {+        return Some(t.trim_end_matches('/').to_string());+    }+    if t.contains(':') {+        return Some(format!("http://{t}"));+    }+    t.parse::<u16>().ok().map(|p| format!("http://127.0.0.1:{p}"))+}++fn token() -> String {+    std::env::var("ADOM_BRIDGE_TOKEN").unwrap_or_default()+}++static LAST_FAILURE: Mutex<Option<Instant>> = Mutex::new(None);++fn in_backoff() -> bool {+    LAST_FAILURE.lock().ok().and_then(|g| *g).map(|t| t.elapsed() < BACKOFF).unwrap_or(false)+}++fn note_failure() {+    if let Ok(mut g) = LAST_FAILURE.lock() {+        *g = Some(Instant::now());+    }+}++fn note_success() {+    if let Ok(mut g) = LAST_FAILURE.lock() {+        *g = None;+    }+}++/// Cheap: is there a direct API to talk to at all (and are we not backing off)?+pub fn available() -> bool {+    base_url().is_some() && !in_backoff()+}++#[cfg(feature = "net")]+fn http_post(url: &str, headers: &[(String, String)], body: &str, timeout: Duration) -> Result<String, String> {+    let mut req = ureq::post(url)+        .config()+        .timeout_global(Some(timeout))+        .timeout_connect(Some(CONNECT_TIMEOUT))+        .http_status_as_error(false)+        .build()+        .header("Content-Type", "application/json")+        .header("Accept", "application/json");+    for (k, v) in headers {+        if !v.is_empty() {+            req = req.header(k.as_str(), v.as_str());+        }+    }+    let mut resp = req.send(body.as_bytes()).map_err(|e| e.to_string())?;+    resp.body_mut().with_config().limit(64 * 1024 * 1024).read_to_string().map_err(|e| e.to_string())+}++#[cfg(not(feature = "net"))]+fn http_post(_url: &str, _headers: &[(String, String)], _body: &str, _timeout: Duration) -> Result<String, String> {+    Err("ab client unavailable: the net feature is off in this build".into())+}++/// POST one verb to ab. `args` gains our own `caller` when no thread is being forwarded.+pub fn call(command: &str, args: Value, timeout: Duration) -> Result<AbReply, AbError> {+    let Some(base) = base_url() else {+        return Err(AbError::Unavailable("ADOM_DIRECT_API_URL is not set and ~/.adom/direct-api-port is missing (inbound-only spawn?)".into()));+    };+    if in_backoff() {+        return Err(AbError::Unavailable("direct API failed a moment ago; backing off".into()));+    }+    let mut args = match args {+        Value::Object(m) => m,+        _ => Map::new(),+    };+    let fwd = forward_headers();+    if fwd.is_empty() && !args.contains_key("caller") {+        args.insert("caller".into(), self_caller());+    }+    let mut headers = fwd;+    let tok = token();+    if !tok.is_empty() {+        headers.push(("X-Adom-Bridge-Token".into(), tok));+    }+    let body = json!({"command": command, "args": Value::Object(args)}).to_string();+    let text = match http_post(&format!("{base}/command"), &headers, &body, timeout) {+        Ok(t) => t,+        Err(e) => {+            note_failure();+            return Err(AbError::Unavailable(e));+        }+    };+    note_success();+    let raw: Value = serde_json::from_str(&text).map_err(|e| AbError::Shape(format!("{e}: {}", text.chars().take(200).collect::<String>())))?;+    let ok = raw.get("success").and_then(Value::as_bool).or_else(|| raw.get("ok").and_then(Value::as_bool)).unwrap_or(false);+    if !ok {+        let error = raw.get("error").and_then(Value::as_str).unwrap_or("no error text").to_string();+        let code = raw.get("errorCode").and_then(Value::as_str).unwrap_or("").to_string();+        return Err(AbError::Refused { code, error });+    }+    let body = unwrap_payload(&raw);+    Ok(AbReply { raw, body })+}++// ── Typed helpers for the window group ────────────────────────────────────────++/// One row of `desktop_list_windows`.+#[derive(Clone, Debug, Default)]+pub struct AbWindow {+    pub hwnd: u64,+    pub title: String,+    pub class_name: String,+    pub kind: String,+    /// z-order position (0 = front) when ab reports it.+    pub z: Option<i64>,+    /// x, y, width, height.+    pub rect: (i32, i32, i32, i32),+    /// Owner handle when ab reports it (v2.1.12+), else 0.+    pub owner: u64,+    pub pid: u32,+    /// Executable base name when ab reports it (v2.1.12 `image`), lower case.+    pub image: String,+    pub minimized: bool,+    pub maximized: bool,+    pub hung: bool,+}++#[derive(Clone, Debug, Default)]+pub struct WindowList {+    pub windows: Vec<AbWindow>,+    /// Zero rows with degraded:true means the enumeration could NOT answer.+    pub degraded: bool,+}++fn rect_of(v: Option<&Value>) -> (i32, i32, i32, i32) {+    let Some(r) = v.and_then(Value::as_object) else { return (0, 0, 0, 0) };+    let g = |keys: &[&str]| keys.iter().find_map(|k| r.get(*k).and_then(num_i64)).unwrap_or(0) as i32;+    let x = g(&["x", "left"]);+    let y = g(&["y", "top"]);+    let mut w = g(&["width", "w"]);+    let mut h = g(&["height", "h"]);+    if w == 0 && r.contains_key("right") {+        w = g(&["right"]) - x;+    }+    if h == 0 && r.contains_key("bottom") {+        h = g(&["bottom"]) - y;+    }+    (x, y, w, h)+}++/// Parse a `desktop_list_windows` payload (flat or wrapped).+pub fn parse_window_list(reply: &AbReply) -> Result<WindowList, AbError> {+    let rows = reply.get("windows").and_then(Value::as_array).ok_or_else(|| AbError::Shape("desktop_list_windows reply has no windows[]".into()))?;+    let degraded = reply.get_bool("degraded").unwrap_or(false);+    let mut windows = Vec::with_capacity(rows.len());+    for w in rows {+        let Some(hwnd) = w.get("hwnd").and_then(num_u64).filter(|h| *h != 0) else { continue };+        let s = |k: &str| w.get(k).and_then(Value::as_str).unwrap_or("").to_string();+        windows.push(AbWindow {+            hwnd,+            title: s("title"),+            class_name: s("className"),+            kind: s("kind"),+            z: w.get("z").and_then(num_i64),+            rect: rect_of(w.get("rect")),+            owner: w.get("owner").and_then(num_u64).unwrap_or(0),+            pid: w.get("pid").and_then(num_u64).unwrap_or(0) as u32,+            image: s("image").to_ascii_lowercase(),+            minimized: w.get("minimized").and_then(Value::as_bool).unwrap_or(false),+            maximized: w.get("maximized").and_then(Value::as_bool).unwrap_or(false),+            hung: w.get("hung").and_then(Value::as_bool).unwrap_or(false),+        });+    }+    Ok(WindowList { windows, degraded })+}++/// Every top-level window with z-order (Python `kicad_windows._from_ad`).+pub fn desktop_list_windows() -> Result<WindowList, AbError> {+    let r = call("desktop_list_windows", json!({}), Duration::from_secs(6))?;+    parse_window_list(&r)+}++/// One owned popup returned alongside a parent capture.+#[derive(Clone, Debug, Default)]+pub struct PopupShot {+    pub hwnd: u64,+    pub title: String,+    pub full_path: String,+    pub safe_path: String,+    pub shot_id: String,+    pub coord_map: Value,+    pub rect: Value,+}++/// A `desktop_screenshot_window` capture in its file form.+#[derive(Clone, Debug, Default)]+pub struct Shot {+    pub hwnd: u64,+    /// Host-side paths (the bridge runs on the desktop, so it can read them).+    pub full_path: String,+    pub safe_path: String,+    pub local_full_path: Option<String>,+    pub shot_id: String,+    pub coord_map: Value,+    pub full_width: u64,+    pub full_height: u64,+    pub safe_width: u64,+    pub safe_height: u64,+    pub full_bytes: u64,+    pub safe_bytes: u64,+    pub source: String,+    pub owned_popup_count: u64,+    pub popups: Vec<PopupShot>,+    /// The raw popup entries, for callers that want every field ab returned.+    pub screenshots: Value,+}++impl Shot {+    /// The Python `_ab_capture` shape plus the popups.+    pub fn to_json(&self) -> Value {+        let mut v = json!({+            "success": true,+            "savedTo": self.full_path,+            "fullPath": self.full_path,+            "capturedBy": "ad:desktop_screenshot_window",+        });+        if !self.safe_path.is_empty() {+            v["safePath"] = json!(self.safe_path);+        }+        if let Some(l) = &self.local_full_path {+            v["localFullPath"] = json!(l);+        }+        if !self.shot_id.is_empty() {+            v["shotId"] = json!(self.shot_id);+        }+        if !self.coord_map.is_null() {+            v["coordMap"] = self.coord_map.clone();+        }+        if self.full_width > 0 {+            v["fullWidth"] = json!(self.full_width);+            v["fullHeight"] = json!(self.full_height);+        }+        if self.safe_width > 0 {+            v["safeWidth"] = json!(self.safe_width);+            v["safeHeight"] = json!(self.safe_height);+        }+        let bytes = if self.safe_bytes > 0 { self.safe_bytes } else { self.full_bytes };+        if bytes > 0 {+            v["sizeKB"] = json!((bytes as f64 / 1024.0 * 10.0).round() / 10.0);+        }+        v["ownedPopupCount"] = json!(self.owned_popup_count);+        if !self.popups.is_empty() {+            v["screenshots"] = self.screenshots.clone();+            v["popups"] = json!(self.popups.iter().map(|p| json!({+                "hwnd": p.hwnd, "title": p.title, "fullPath": p.full_path, "safePath": p.safe_path,+                "shotId": p.shot_id, "coordMap": p.coord_map, "rect": p.rect,+            })).collect::<Vec<_>>());+        }+        v+    }+}++fn norm_path(s: &str) -> String {+    s.replace('\\', "/")+}++/// Parse a `desktop_screenshot_window` reply (either envelope form).+pub fn parse_shot(hwnd: u64, reply: &AbReply) -> Result<Shot, AbError> {+    let full = reply.get_str("fullPath").or_else(|| reply.get_str("fullPathHost")).or_else(|| reply.get_str("savedTo")).or_else(|| reply.get_str("path"));+    let Some(full) = full else {+        return Err(AbError::Shape("desktop_screenshot_window reply carries no fullPath/savedTo (base64-only form?)".into()));+    };+    let mut popups = Vec::new();+    let screenshots = reply.get("screenshots").cloned().unwrap_or(Value::Null);+    if let Some(arr) = screenshots.as_array() {+        for p in arr {+            let s = |k: &str| p.get(k).and_then(Value::as_str).unwrap_or("").to_string();+            popups.push(PopupShot {+                hwnd: p.get("hwnd").and_then(num_u64).unwrap_or(0),+                title: s("title"),+                full_path: norm_path(&s("fullPath")),+                safe_path: norm_path(&s("safePath")),+                shot_id: p.get("shotId").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| p.get("coordMap").and_then(|c| c.get("shotId")).and_then(Value::as_str).unwrap_or("").to_string()),+                coord_map: p.get("coordMap").cloned().unwrap_or(Value::Null),+                rect: p.get("rect").cloned().unwrap_or(Value::Null),+            });+        }+    }+    let coord_map = reply.get("coordMap").cloned().unwrap_or(Value::Null);+    let shot_id = reply.get_str("shotId").map(str::to_string).unwrap_or_else(|| coord_map.get("shotId").and_then(Value::as_str).unwrap_or("").to_string());+    Ok(Shot {+        hwnd,+        full_path: norm_path(full),+        safe_path: reply.get_str("safePath").map(norm_path).unwrap_or_default(),+        local_full_path: reply.get_str("localFullPath").map(str::to_string),+        shot_id,+        coord_map,+        full_width: reply.get_u64("fullWidth").unwrap_or(0),+        full_height: reply.get_u64("fullHeight").unwrap_or(0),+        safe_width: reply.get_u64("safeWidth").unwrap_or(0),+        safe_height: reply.get_u64("safeHeight").unwrap_or(0),+        full_bytes: reply.get_u64("fullBytes").unwrap_or(0),+        safe_bytes: reply.get_u64("safeBytes").unwrap_or(0),+        source: reply.get_str("source").unwrap_or("").to_string(),+        owned_popup_count: reply.get_u64("ownedPopupCount").unwrap_or(popups.len() as u64),+        popups,+        screenshots,+    })+}++/// Capture one window plus the popups it owns, in the background (PrintWindow).+/// `resize_max` caps the safe image width (ab default 1568).+pub fn desktop_screenshot_window(hwnd: u64, reason: &str, resize_max: Option<u32>) -> Result<Shot, AbError> {+    let mut args = json!({"hwnd": hwnd, "reason": reason});+    if let Some(m) = resize_max {+        args["resizeMax"] = json!(m);+    }+    let r = call("desktop_screenshot_window", args, Duration::from_secs(30))?;+    parse_shot(hwnd, &r)+}++/// Stamp the owning AI thread on a KiCad window the bridge raised (wiki #12.3).+pub fn desktop_set_window_identity(hwnd: u64, app_name: &str, tooltip: &str) -> Result<Value, AbError> {+    let r = call("desktop_set_window_identity", json!({"hwnd": hwnd, "appName": app_name, "tooltip": tooltip}), Duration::from_secs(8))?;+    Ok(r.body)+}++/// `desktop_taskbar {hwnd, progress?, overlay?, flash?, thumbnailTooltip?}`.+pub fn desktop_taskbar(hwnd: u64, fields: Value) -> Result<Value, AbError> {+    let mut args = json!({"hwnd": hwnd});+    if let Some(m) = fields.as_object() {+        for (k, v) in m {+            args[k] = v.clone();+        }+    }+    let r = call("desktop_taskbar", args, Duration::from_secs(8))?;+    Ok(r.body)+}++/// A short always-on-top caption (reason is required by ab).+pub fn desktop_caption(id: &str, text: &str, duration_ms: u64, reason: &str) -> Result<Value, AbError> {+    let r = call("desktop_caption", json!({"id": id, "text": text, "duration": duration_ms, "size": "medium", "position": "bottom-left", "reason": reason}), Duration::from_secs(6))?;+    Ok(r.body)+}++/// A native toast, attributed to the forwarded caller by ab itself.+pub fn notify_user(title: &str, body: &str, level: &str) -> Result<Value, AbError> {+    let r = call("notify_user", json!({"title": title, "body": body, "message": body, "level": level}), Duration::from_secs(8))?;+    Ok(r.body)+}++#[derive(Clone, Debug, Default)]+pub struct CloseOutcome {+    pub closed: bool,+    pub forced: bool,+}++/// Graceful WM_CLOSE with ab's bookkeeping; never forceAfterMs (a save prompt must stay up).+pub fn desktop_close_window(hwnd: u64, reason: &str) -> Result<CloseOutcome, AbError> {+    let r = call("desktop_close_window", json!({"hwnd": hwnd, "reason": reason}), Duration::from_secs(15))?;+    Ok(CloseOutcome { closed: r.get_bool("closed").unwrap_or(false), forced: r.get_bool("forced").unwrap_or(false) })+}++/// The one sanctioned foreground, through ab (used when the platform has no bring_to_front).+pub fn desktop_bring_to_front(hwnd: u64, reason: &str) -> Result<Value, AbError> {+    let r = call("desktop_bring_to_front", json!({"hwnd": hwnd, "state": "restore", "reason": reason}), Duration::from_secs(10))?;+    Ok(r.body)+}++#[derive(Clone, Debug, Default)]+pub struct WaitOutcome {+    /// The requested condition was met (with state "absent" this means the window is GONE).+    pub appeared: bool,+    pub waited_ms: u64,+    pub window: Option<AbWindow>,+}++/// Block (bounded) until a window with `title_contains` appears, or with+/// `state: Some("absent")` until it goes away.+pub fn desktop_wait_for_window(title_contains: &str, state: Option<&str>, timeout_ms: u64) -> Result<WaitOutcome, AbError> {+    let mut args = json!({"titleContains": title_contains, "timeoutMs": timeout_ms});+    if let Some(s) = state {+        args["state"] = json!(s);+    }+    let r = call("desktop_wait_for_window", args, Duration::from_millis(timeout_ms + 5000))?;+    let window = r.get("window").and_then(Value::as_object).and_then(|w| {+        let hwnd = w.get("hwnd").and_then(num_u64)?;+        Some(AbWindow {+            hwnd,+            title: w.get("title").and_then(Value::as_str).unwrap_or("").into(),+            class_name: w.get("className").and_then(Value::as_str).unwrap_or("").into(),+            rect: rect_of(w.get("rect")),+            pid: w.get("pid").and_then(num_u64).unwrap_or(0) as u32,+            ..Default::default()+        })+    });+    Ok(WaitOutcome { appeared: r.get_bool("appeared").unwrap_or(false), waited_ms: r.get_u64("waitedMs").unwrap_or(0), window })+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn unwraps_flat_wrapped_and_string_output() {+        let flat = json!({"success": true, "windows": [{"hwnd": 1}]});+        assert_eq!(unwrap_payload(&flat)["windows"][0]["hwnd"], json!(1));+        let wrapped = json!({"success": true, "output": {"data": {"windows": []}}});+        assert!(unwrap_payload(&wrapped)["data"]["windows"].is_array());+        let stringy = json!({"success": true, "output": "{\"data\":{\"windows\":[{\"hwnd\":7,\"title\":\"x\"}]}}"});+        let r = AbReply { body: unwrap_payload(&stringy), raw: stringy.clone() };+        assert_eq!(r.get("windows").unwrap()[0]["hwnd"], json!(7));+        let plain = json!({"success": true, "output": "not json", "closed": true});+        let r = AbReply { body: unwrap_payload(&plain), raw: plain.clone() };+        assert_eq!(r.get_bool("closed"), Some(true));+    }++    #[test]+    fn window_list_reads_both_rect_spellings_and_degraded() {+        let raw = json!({"success": true, "degraded": false, "windows": [+            {"hwnd": 10, "title": "KiCad 10.0", "className": "wxWindowNR", "kind": "app", "z": 3,+             "rect": {"left": 5, "top": 6, "width": 700, "height": 500}, "pid": 42, "image": "KiCad.exe", "owner": 0},+            {"hwnd": 11, "title": "Error", "className": "#32770", "rect": {"x": 1, "y": 2, "w": 3, "h": 4}, "owner": 10, "minimized": true},+            {"hwnd": 0, "title": "skipped"},+            {"hwnd": "12", "title": "stringy", "rect": {"left": 0, "top": 0, "right": 50, "bottom": 40}}+        ]});+        let r = AbReply { body: unwrap_payload(&raw), raw: raw.clone() };+        let l = parse_window_list(&r).unwrap();+        assert!(!l.degraded);+        assert_eq!(l.windows.len(), 3);+        assert_eq!(l.windows[0].rect, (5, 6, 700, 500));+        assert_eq!(l.windows[0].image, "kicad.exe");+        assert_eq!(l.windows[0].z, Some(3));+        assert_eq!(l.windows[1].rect, (1, 2, 3, 4));+        assert_eq!(l.windows[1].owner, 10);+        assert!(l.windows[1].minimized);+        assert_eq!(l.windows[2].hwnd, 12);+        assert_eq!(l.windows[2].rect, (0, 0, 50, 40));+        let bad = json!({"success": true});+        let r = AbReply { body: bad.clone(), raw: bad };+        assert!(matches!(parse_window_list(&r), Err(AbError::Shape(_))));+    }++    #[test]+    fn shot_parses_popups_and_coord_map() {+        let raw = json!({"success": true, "fullPath": "C:\\shots\\w.full.png", "safePath": "C:\\shots\\w.safe.png",+            "fullWidth": 3840, "fullHeight": 2160, "safeWidth": 1568, "safeHeight": 882, "safeBytes": 88104,+            "coordMap": {"shotId": "shot_a", "image": {"w": 1568, "h": 882}}, "source": "printwindow",+            "ownedPopupCount": 1,+            "screenshots": [{"hwnd": 265518, "title": "Error", "kind": "owned_popup", "fullPath": "C:\\shots\\p.png", "safePath": "C:\\shots\\p.png", "coordMap": {"shotId": "shot_pop1"}}]});+        let r = AbReply { body: unwrap_payload(&raw), raw: raw.clone() };+        let s = parse_shot(99, &r).unwrap();+        assert_eq!(s.full_path, "C:/shots/w.full.png");+        assert_eq!(s.shot_id, "shot_a");+        assert_eq!(s.owned_popup_count, 1);+        assert_eq!(s.popups[0].shot_id, "shot_pop1");+        assert_eq!(s.popups[0].title, "Error");+        let j = s.to_json();+        assert_eq!(j["capturedBy"], json!("ad:desktop_screenshot_window"));+        assert_eq!(j["sizeKB"], json!(86.0));+        assert_eq!(j["popups"][0]["hwnd"], json!(265518));+        let b64only = json!({"success": true, "data": {"image": "iVBOR..."}});+        let r = AbReply { body: b64only.clone(), raw: b64only };+        assert!(matches!(parse_shot(1, &r), Err(AbError::Shape(_))));+    }++    #[test]+    fn caller_args_win_over_headers_and_delegate_is_added() {+        let args = json!({"caller": {"aiThread": "chip-fetcher tab 3", "containerName": "c1"}, "reason": "look"});+        let c = caller_from(&args, "hdr-thread", "hdr-container", "");+        assert_eq!(c.thread, "chip-fetcher tab 3");+        assert_eq!(c.container, "c1");+        assert_eq!(c.reason, "look");+        set_caller(c);+        let h = forward_headers();+        assert!(h.iter().any(|(k, v)| k == "X-Adom-Caller-Delegate" && v == DELEGATE));+        assert!(h.iter().any(|(k, v)| k == "X-Adom-Caller-Thread" && v == "chip-fetcher tab 3"));+        clear_caller();+        assert!(forward_headers().is_empty());+        assert_eq!(thread_name(), "");+        let c = caller_from(&json!({}), "", "", "");+        set_caller(c);+        assert!(current_caller().is_none());+    }++    #[test]+    fn unavailable_without_env_never_hangs() {+        std::env::remove_var("ADOM_DIRECT_API_URL");+        // A port file may exist on a dev box; only assert the no-config path when it does not.+        if base_url().is_none() {+            let t = Instant::now();+            let r = call("ping", json!({}), Duration::from_secs(1));+            assert!(matches!(r, Err(AbError::Unavailable(_))));+            assert!(t.elapsed() < Duration::from_secs(1));+        }+    }+}
rust/crates/kicad-core/src/windows_model.rs+712−1
@@ -1 +1,712 @@-//! Placeholder: KiCad window model (title kinds, dialog patterns), filled in by phase 3.+//! The KiCad window model: what a KiCad window IS (title keywords, executable set, kind),+//! which one is the project manager, and the dialog pattern tables plus the pure decisions+//! (benign versus progress versus save prompt) ported from `handlers/close_windows.py`,+//! `handlers/kicad_ui.py`, `handlers/kicad_windows.py`, `handlers/win_focus.py` and the+//! title parsers of `open_symbol_editor.py` / `open_footprint_editor.py`.+//!+//! Everything here is pure: no OS call, no ab call. The verb group hands in titles, class+//! names, owners and body texts and gets a decision back, so every rule is unit-tested+//! without a desktop.++use serde_json::{json, Value};++// ── What is a KiCad window ────────────────────────────────────────────────────++/// Title keywords (kicad_ui.KICAD_TITLE_KEYWORDS). A title hit must ALSO belong to a+/// KiCad process (#23: a Chrome tab named "KiCad - ..." once matched).+pub const TITLE_KEYWORDS: &[&str] = &["KiCad", "Symbol Editor", "Footprint Editor", "3D Viewer", "eeschema", "pcbnew", "Schematic Editor", "PCB Editor"];++/// Executable base names, lower case, with and without the Windows suffix+/// (win_focus._KICAD_EXES plus gerbview, which the loose PID scan includes).+pub const KICAD_EXES: &[&str] = &[+    "kicad.exe", "eeschema.exe", "pcbnew.exe", "kicad-cli.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe", "gerbview.exe",+    "kicad", "eeschema", "pcbnew", "kicad-cli", "pl_editor", "bitmap2component", "pcb_calculator", "gerbview",+];++/// Processes `kicad_close {force:true}` kills, in order (close_windows.WIN_KICAD_PROCESS_NAMES).+pub const KILL_PROCESS_NAMES: &[&str] = &["eeschema.exe", "pcbnew.exe", "bitmap2component.exe", "pcb_calculator.exe", "pl_editor.exe", "kicad.exe"];++/// The Win32 dialog window class.+pub const DIALOG_CLASS: &str = "#32770";++pub fn is_kicad_exe(exe: &str) -> bool {+    let e = exe.trim().to_ascii_lowercase();+    let base = e.rsplit(['/', '\\']).next().unwrap_or(&e);+    KICAD_EXES.contains(&base)+}++pub fn title_looks_kicad(title: &str) -> bool {+    let low = title.to_lowercase();+    TITLE_KEYWORDS.iter().any(|k| low.contains(&k.to_lowercase()))+}++/// What a KiCad top-level window is, by title (and ownership for dialogs).+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum WindowKind {+    ProjectManager,+    SchematicEditor,+    PcbEditor,+    SymbolEditor,+    FootprintEditor,+    Viewer3d,+    Dialog,+    Other,+}++impl WindowKind {+    /// The plugin inventory's kind names (bridge_client.handle_open_editors byKind).+    pub fn as_str(self) -> &'static str {+        match self {+            WindowKind::ProjectManager => "project_manager",+            WindowKind::SchematicEditor => "schematic_editor",+            WindowKind::PcbEditor => "pcb_editor",+            WindowKind::SymbolEditor => "symbol_editor",+            WindowKind::FootprintEditor => "footprint_editor",+            WindowKind::Viewer3d => "3d_viewer",+            WindowKind::Dialog => "dialog",+            WindowKind::Other => "other",+        }+    }+    /// The title fragment that names this kind (for window labelling and finders).+    pub fn title_word(self) -> &'static str {+        match self {+            WindowKind::ProjectManager => "KiCad",+            WindowKind::SchematicEditor => "Schematic Editor",+            WindowKind::PcbEditor => "PCB Editor",+            WindowKind::SymbolEditor => "Symbol Editor",+            WindowKind::FootprintEditor => "Footprint Editor",+            WindowKind::Viewer3d => "3D Viewer",+            WindowKind::Dialog => "",+            WindowKind::Other => "",+        }+    }+    pub fn is_editor(self) -> bool {+        matches!(self, WindowKind::SchematicEditor | WindowKind::PcbEditor | WindowKind::SymbolEditor | WindowKind::FootprintEditor | WindowKind::Viewer3d)+    }+}++/// A dialog is the Win32 dialog class or any owned top-level (close_windows: `cls ==+/// "#32770" or owner != 0`).+pub fn is_dialog(class_name: &str, owner: u64) -> bool {+    class_name == DIALOG_CLASS || owner != 0+}++/// close_windows._is_manager_window: the title starts with "KiCad" and names no editor or viewer.+pub fn is_manager_title(title: &str) -> bool {+    let low = title.trim().to_lowercase();+    low.starts_with("kicad") && !low.contains("editor") && !low.contains("viewer")+}++/// kicad_windows.find_project_manager: says KiCad, names no sub-tool, and is unowned.+pub fn is_project_manager(title: &str, owner: u64) -> bool {+    let low = title.to_lowercase();+    if !low.contains("kicad") {+        return false;+    }+    if ["editor", "viewer", "calculator"].iter().any(|k| low.contains(k)) {+        return false;+    }+    owner == 0+}++/// Classify a KiCad-owned top-level window.+pub fn classify(title: &str, class_name: &str, owner: u64) -> WindowKind {+    let low = title.to_lowercase();+    if low.contains("symbol editor") {+        return WindowKind::SymbolEditor;+    }+    if low.contains("footprint editor") {+        return WindowKind::FootprintEditor;+    }+    if low.contains("3d viewer") {+        return WindowKind::Viewer3d;+    }+    if low.contains("schematic editor") || low.contains("eeschema") {+        return WindowKind::SchematicEditor;+    }+    if low.contains("pcb editor") || low.contains("pcbnew") {+        return WindowKind::PcbEditor;+    }+    if is_dialog(class_name, owner) {+        return WindowKind::Dialog;+    }+    if is_manager_title(title) {+        return WindowKind::ProjectManager;+    }+    WindowKind::Other+}++// ── Dialog pattern tables (close_windows.py) ──────────────────────────────────++/// Titles that read like a KiCad error/notice box even when the owning process is not+/// in the PID set (close_windows._DIALOG_TITLE_HINTS).+pub const DIALOG_TITLE_HINTS: &[&str] = &[+    "kicad", "error", "warning", "confirmation", "information", "cannot", "denied", "failed", "not found", "opengl", "setup", "save", "unsaved",+    "annotate", "rescue", "migrate",+];++/// The "Save changes?" box KiCad raises on WM_CLOSE of a dirty editor.+pub const SAVE_PROMPT_PATTERNS: &[&str] = &["save changes", "save the changes", "unsaved", "changes to", "before closing"];++/// Info dialogs that are HARMLESS and safe to auto-expire (KiCad keeps working).+pub const BENIGN_DIALOG_PATTERNS: &[&str] = &[+    // GPU-less OpenGL notice (VMs / RDP / servers): stalls every editor + 3D open.+    "could not use opengl",+    "falling back to software rendering",+    "opengl 2.1 or higher is required",+    "using fallback",+    // KiCad first-run "Setup" / welcome wizard: dismissing it accepts the defaults.+    "welcome to kicad",+    "starting for the first time",+    "kicad setup",+    "configure some basic settings",+    "default settings",+    // "This file was created by an older version of KiCad" conversion info bar.+    "created by an older version of kicad",+    "converted to the new format",+];++/// v0.9.80: progress dialogs (wxProgressDialog) show ONE button and it is CANCEL.+/// "Dismissing" one aborts the operation the verb just started. Never touched.+pub const PROGRESS_BODY_PATTERNS: &[&str] = &["elapsed time", "remaining time", "estimated time"];+pub const PROGRESS_TITLE_PATTERNS: &[&str] = &["load pcb", "loading", "progress", "plotting", "rendering", "importing", "exporting", "generating"];++/// Button captions that make a benign dialog go away, in PRIORITY order: affirmative+/// first (info boxes only have OK), then wizard/skip buttons (the first-run wizard has+/// no OK, only Back/Next/Cancel). "yes" also clears the wizard's "cancel setup?" box.+pub const DISMISS_BUTTON_TIERS: &[&[&str]] = &[+    &["ok", "close", "continue", "got it", "dismiss", "yes"],+    &["finish", "cancel", "skip", "no thanks", "later", "quit"],+];++/// Labels of the Discard button on KiCad's save prompt, tried in order+/// (close_windows.handle_close_kicad).+pub const DISCARD_LABELS: &[&str] = &["discard changes", "discard", "don't save", "do not save", "no"];++/// Labels of the Discard button for the UIA fallback (case as KiCad renders them).+pub const DISCARD_LABELS_UIA: &[&str] = &["Discard Changes", "Discard", "Don't Save"];++pub fn is_benign_dialog(title: &str, body: &str) -> bool {+    let t = format!("{title} {body}").to_lowercase();+    BENIGN_DIALOG_PATTERNS.iter().any(|p| t.contains(p))+}++pub fn is_progress_dialog(title: &str, body: &str) -> bool {+    let t = title.trim().to_lowercase();+    let b = body.to_lowercase();+    if PROGRESS_BODY_PATTERNS.iter().any(|p| b.contains(p)) {+        return true;+    }+    PROGRESS_TITLE_PATTERNS.iter().any(|p| t.starts_with(p) || t == *p)+}++pub fn is_save_prompt(title: &str, body: &str) -> bool {+    let t = format!("{title} {body}").to_lowercase();+    SAVE_PROMPT_PATTERNS.iter().any(|p| t.contains(p))+}++/// close_windows._win_find_global_dialogs predicate: a #32770 with a KiCad-ish title or+/// any title at all (the plain editor frames are wxWindowNR and never match).+pub fn looks_like_global_dialog(title: &str, class_name: &str) -> bool {+    if class_name != DIALOG_CLASS {+        return false;+    }+    let low = title.to_lowercase();+    let looks_kicad = DIALOG_TITLE_HINTS.iter().any(|h| low.contains(h));+    looks_kicad || !title.is_empty()+}++/// What the sweep does with one dialog.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum DialogAction {+    /// The verb is WORKING (load/plot/render). Report it, leave it, not an error.+    LeaveProgress,+    /// Click OK/Cancel/Escape/WM_CLOSE. `benign` says whether it counts as an error.+    Dismiss { benign: bool },+    /// Not benign and the sweep is benign-only: leave it up and report it.+    Leave,+}++/// close_windows._win_sweep_dialogs decision for one dialog.+pub fn sweep_decision(title: &str, body: &str, dismiss_all: bool) -> DialogAction {+    if is_progress_dialog(title, body) {+        return DialogAction::LeaveProgress;+    }+    let benign = is_benign_dialog(title, body);+    if benign || dismiss_all {+        DialogAction::Dismiss { benign }+    } else {+        DialogAction::Leave+    }+}++/// auto_sweep_dialogs decision: every non-progress dialog is expired; a non-benign one+/// flips hadError.+pub fn auto_sweep_decision(title: &str, body: &str) -> DialogAction {+    if is_progress_dialog(title, body) {+        DialogAction::LeaveProgress+    } else {+        DialogAction::Dismiss { benign: is_benign_dialog(title, body) }+    }+}++/// A button label as the sweep compares it: mnemonic ampersands removed, trimmed, lower case.+pub fn normalize_button_label(label: &str) -> String {+    label.replace('&', "").trim().to_lowercase()+}++/// Which dismiss tier a button label belongs to (0 = affirmative, 1 = wizard/skip), if any.+pub fn dismiss_tier(label: &str) -> Option<usize> {+    let n = normalize_button_label(label);+    DISMISS_BUTTON_TIERS.iter().position(|tier| tier.contains(&n.as_str()))+}++pub fn is_discard_label(label: &str) -> bool {+    DISCARD_LABELS.contains(&normalize_button_label(label).as_str())+}++/// Body text of a dialog from its Static children (close_windows._win_get_dialog_body_text):+/// printable strings only, consecutive duplicates collapsed, joined by two spaces.+pub fn dialog_body(static_texts: &[String]) -> String {+    let mut out: Vec<&str> = Vec::new();+    for t in static_texts {+        let t = t.trim();+        if t.is_empty() || !t.chars().any(|c| c as u32 >= 32) {+            continue;+        }+        if out.last() != Some(&t) {+            out.push(t);+        }+    }+    out.join("  ").trim().to_string()+}++/// close_windows._win_force_software_canvas on one kicad_common.json document:+/// graphics.canvas_type 1 (OpenGL) becomes 2 (Cairo). Returns true when it changed.+pub fn force_software_canvas_doc(doc: &mut Value) -> bool {+    if !doc.is_object() {+        *doc = json!({});+    }+    let g = doc.as_object_mut().unwrap().entry("graphics").or_insert_with(|| json!({}));+    if !g.is_object() {+        *g = json!({});+    }+    if g.get("canvas_type") == Some(&json!(2)) {+        return false;+    }+    g["canvas_type"] = json!(2);+    true+}++// ── Editor title parsing (open_symbol_editor.py / open_footprint_editor.py) ───++/// Split "<library>:<name> \u{2014} Symbol Editor" (KiCad puts an em dash there; "- Footprint+/// Editor" is the hyphen form) into (library, name).+/// A leading '*' (unsaved) is dropped; the read-only marker KiCad appends to stock+/// libraries is stripped from the name (#49). ("", "") when no part is loaded.+pub fn parse_editor_title(title: &str, editor_word: &str) -> (String, String) {+    let mut t = title.trim().to_string();+    for suffix in [format!("\u{2014} {editor_word}"), format!("- {editor_word}"), editor_word.to_string()] {+        if t.ends_with(&suffix) {+            t = t[..t.len() - suffix.len()].trim().to_string();+            break;+        }+    }+    let t = t.trim_start_matches('*').trim().to_string();+    let Some((lib, name)) = t.split_once(':') else {+        return (String::new(), String::new());+    };+    let mut name = name.trim().to_string();+    for marker in ["[Read Only Library]", "[Read Only]", "(Read Only)"] {+        if name.ends_with(marker) {+            name = name[..name.len() - marker.len()].trim().to_string();+        }+    }+    (lib.trim().to_string(), name)+}++pub fn title_readonly(title: &str) -> bool {+    title.to_lowercase().contains("read only")+}++fn is_unit_suffix(sym: &str, requested: &str) -> bool {+    // NAME_<unit>_<style> (#54): the sub-node of the requested symbol is the requested part.+    let Some(rest) = sym.get(..requested.len()).filter(|p| p.eq_ignore_ascii_case(requested)).map(|_| &sym[requested.len()..]) else {+        return false;+    };+    let parts: Vec<&str> = rest.split('_').collect();+    parts.len() == 3 && parts[0].is_empty() && parts[1..].iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))+}++/// Does this Symbol Editor title PROVE the requested part (and library, when named) is+/// loaded? Exact library:symbol comparison, never a substring (0.9.194, #49, #54).+pub fn title_confirms_symbol(title: &str, symbol: &str, library: &str) -> bool {+    let (lib, sym) = parse_editor_title(title, "Symbol Editor");+    if sym.is_empty() || title.to_lowercase().contains("no symbol loaded") {+        return false;+    }+    if !sym.eq_ignore_ascii_case(symbol) && !is_unit_suffix(&sym, symbol) {+        return false;+    }+    if !library.is_empty() && !lib.eq_ignore_ascii_case(library) {+        return false;+    }+    true+}++/// Same for the Footprint Editor (0.9.207, wiki #42).+pub fn title_confirms_footprint(title: &str, footprint: &str, library: &str) -> bool {+    let (lib, fp) = parse_editor_title(title, "Footprint Editor");+    if fp.is_empty() || title.to_lowercase().contains("no footprint loaded") {+        return false;+    }+    if !fp.eq_ignore_ascii_case(footprint) {+        return false;+    }+    if !library.is_empty() && !lib.eq_ignore_ascii_case(library) {+        return false;+    }+    true+}++/// What the editor ACTUALLY landed on (wiki #49 ask 2): resolvedLibrary, resolvedSymbol,+/// resolvedFrom ("requested" | "fallback"), resolvedReadOnly.+pub fn resolved_fields(title: &str, requested_lib: &str, editor_word: &str) -> Value {+    let (lib, sym) = parse_editor_title(title, editor_word);+    if sym.is_empty() {+        return json!({});+    }+    let same = requested_lib.is_empty() || lib.eq_ignore_ascii_case(requested_lib);+    let mut out = json!({+        "resolvedLibrary": if lib.is_empty() { Value::Null } else { json!(lib) },+        "resolvedSymbol": sym,+        "resolvedFrom": if same { "requested" } else { "fallback" },+    });+    if title_readonly(title) {+        out["resolvedReadOnly"] = json!(true);+    }+    out+}++// ── Keys (kicad_ui.py) ────────────────────────────────────────────────────────++pub const VK_RETURN: u16 = 0x0D;+pub const VK_ESCAPE: u16 = 0x1B;+pub const VK_DOWN: u16 = 0x28;++/// kicad_ui.VK_MAP: named keys to virtual-key codes.+pub fn vk_for_key(name: &str) -> Option<u16> {+    Some(match name.trim().to_lowercase().as_str() {+        "enter" | "return" => 0x0D,+        "escape" | "esc" => 0x1B,+        "tab" => 0x09,+        "space" => 0x20,+        "up" => 0x26,+        "down" => 0x28,+        "left" => 0x25,+        "right" => 0x27,+        "backspace" => 0x08,+        "delete" => 0x2E,+        "home" => 0x24,+        "end" => 0x23,+        "pageup" => 0x21,+        "pagedown" => 0x22,+        "f1" => 0x70,+        "f2" => 0x71,+        "f3" => 0x72,+        "f4" => 0x73,+        "f5" => 0x74,+        "f6" => 0x75,+        "f7" => 0x76,+        "f8" => 0x77,+        "f9" => 0x78,+        "f10" => 0x79,+        "f11" => 0x7A,+        "f12" => 0x7B,+        _ => return None,+    })+}++/// A virtual key for one printable ASCII character (the letter/digit VKs equal the+/// upper-case ASCII code; anything else is delivered by WM_CHAR alone).+pub fn vk_for_char(ch: char) -> Option<u16> {+    if ch.is_ascii_alphanumeric() {+        return Some(ch.to_ascii_uppercase() as u16);+    }+    if ch == ' ' {+        return Some(0x20);+    }+    None+}++/// kicad_ui._MODIFIER_VK.+pub fn modifier_vk(name: &str) -> Option<u16> {+    Some(match name {+        "ctrl" | "control" => 0x11,+        "alt" | "menu" => 0x12,+        "shift" => 0x10,+        "win" | "super" | "lwin" => 0x5B,+        _ => return None,+    })+}++/// kicad_ui._parse_modifier_combo: "ctrl+shift+f10" -> ([0x11, 0x10], "f10"); None when+/// the key is not a combo or names an unknown modifier.+pub fn parse_modifier_combo(key: &str) -> Option<(Vec<u16>, String)> {+    if !key.contains('+') {+        return None;+    }+    let tokens: Vec<String> = key.split('+').map(|t| t.trim().to_lowercase()).filter(|t| !t.is_empty()).collect();+    if tokens.len() < 2 {+        return None;+    }+    let (base, mods) = tokens.split_last().unwrap();+    let mut vks = Vec::new();+    for m in mods {+        vks.push(modifier_vk(m)?);+    }+    Some((vks, base.clone()))+}++/// win_menu._norm_accel: "Ctrl+Shift+S" -> "ctrl+shift+s".+pub fn norm_accel(text: &str) -> String {+    text.to_lowercase().split_whitespace().collect::<String>()+}++// ── Canvas-painted probe (kicad_ui._canvas_uniformity) ────────────────────────++/// Is the editor's drawing CANVAS actually rendered, or an empty rectangle? Sample the+/// canvas region of a top-down RGB8 buffer and count distinct colours: a drawn canvas+/// carries grid dots, axes and the part; an unrendered one is one flat colour.+/// `rect` is (left, top, right, bottom) in window pixels (the wxGLCanvas), inset 4% so+/// border pixels never count; without it a coarse guess of the window is sampled and+/// `region` says "approx".+pub fn canvas_uniformity(rgb: &[u8], width: u32, height: u32, rect: Option<(i32, i32, i32, i32)>) -> Value {+    let (w, h) = (width as i64, height as i64);+    if w <= 0 || h <= 0 || rgb.len() < (w * h * 3) as usize {+        return json!({"checked": false});+    }+    let (x0, x1, y0, y1) = match rect {+        Some((l, t, r, b)) => {+            let (l, t, r, b) = (l as i64, t as i64, r as i64, b as i64);+            let dx = (r - l) * 4 / 100;+            let dy = (b - t) * 4 / 100;+            (l + dx, r - dx, t + dy, b - dy)+        }+        None => (w * 35 / 100, w * 95 / 100, h * 15 / 100, h * 85 / 100),+    };+    let (x0, y0) = (x0.max(0), y0.max(0));+    let (x1, y1) = (x1.min(w), y1.min(h));+    if x1 <= x0 || y1 <= y0 {+        return json!({"checked": false});+    }+    let region = if rect.is_some() { "glCanvas" } else { "approx" };+    let step_x = ((x1 - x0) / 60).max(1);+    let step_y = ((y1 - y0) / 60).max(1);+    let mut colors: std::collections::HashSet<[u8; 3]> = std::collections::HashSet::new();+    let mut samples = 0u64;+    let mut y = y0;+    while y < y1 {+        let mut x = x0;+        while x < x1 {+            let i = ((y * w + x) * 3) as usize;+            colors.insert([rgb[i], rgb[i + 1], rgb[i + 2]]);+            samples += 1;+            if colors.len() > 12 {+                return json!({"checked": true, "distinctColors": colors.len(), "rendered": true, "samples": samples, "region": region});+            }+            x += step_x;+        }+        y += step_y;+    }+    json!({"checked": true, "distinctColors": colors.len(), "rendered": colors.len() > 3, "samples": samples, "region": region})+}++/// A filename-safe label for a screenshot (kicad_ui.handle_screenshot_all).+pub fn safe_label(title: &str, prefix: &str) -> String {+    let safe: String = title.chars().filter(|c| c.is_alphanumeric() || "-_ ".contains(*c)).take(40).collect();+    let safe = safe.trim();+    if safe.is_empty() { prefix.to_string() } else { format!("{prefix}-{safe}") }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn exe_set_and_title_keywords() {+        assert!(is_kicad_exe("pcbnew.exe"));+        assert!(is_kicad_exe("C:/Program Files/KiCad/10.0/bin/KiCad.exe"));+        assert!(is_kicad_exe("eeschema"));+        assert!(!is_kicad_exe("chrome.exe"));+        assert!(title_looks_kicad("Adom:R \u{2014} Symbol Editor"));+        assert!(title_looks_kicad("KiCad 10.0"));+        assert!(!title_looks_kicad("Untitled - Notepad"));+    }++    #[test]+    fn classification_and_project_manager() {+        assert_eq!(classify("KiCad 10.0", "wxWindowNR", 0), WindowKind::ProjectManager);+        assert_eq!(classify("board.kicad_pcb \u{2014} PCB Editor", "wxWindowNR", 0), WindowKind::PcbEditor);+        assert_eq!(classify("Adom:R \u{2014} Symbol Editor", "wxWindowNR", 0), WindowKind::SymbolEditor);+        assert_eq!(classify("Adom:X - Footprint Editor", "wxWindowNR", 0), WindowKind::FootprintEditor);+        assert_eq!(classify("3D Viewer \u{2014} board.kicad_pcb", "wxWindowNR", 0), WindowKind::Viewer3d);+        assert_eq!(classify("sch.kicad_sch \u{2014} Schematic Editor", "wxWindowNR", 0), WindowKind::SchematicEditor);+        assert_eq!(classify("Error", "#32770", 0), WindowKind::Dialog);+        assert_eq!(classify("Confirmation", "wxWindowNR", 12345), WindowKind::Dialog);+        assert_eq!(classify("Something", "wxWindowNR", 0), WindowKind::Other);+        assert!(is_project_manager("KiCad 10.0.5", 0));+        assert!(!is_project_manager("KiCad 10.0.5", 77));+        assert!(!is_project_manager("KiCad PCB Editor", 0));+        assert!(!is_project_manager("PCB Calculator - KiCad", 0));+        assert!(is_manager_title("KiCad"));+        assert!(!is_manager_title("KiCad 3D Viewer"));+        assert!(WindowKind::PcbEditor.is_editor());+        assert!(!WindowKind::ProjectManager.is_editor());+        assert_eq!(WindowKind::Viewer3d.as_str(), "3d_viewer");+    }++    #[test]+    fn benign_progress_and_save_prompts() {+        assert!(is_benign_dialog("KiCad", "Could not use OpenGL, falling back to software rendering"));+        assert!(is_benign_dialog("KiCad Setup", ""));+        assert!(is_benign_dialog("Information", "This file was created by an older version of KiCad"));+        assert!(!is_benign_dialog("Error", "Cannot enumerate C:/Templates (Access denied)"));+        assert!(is_progress_dialog("Load PCB", ""));+        assert!(is_progress_dialog("Loading Symbol Libraries", ""));+        assert!(is_progress_dialog("Anything", "Elapsed time: 00:00:03"));+        assert!(!is_progress_dialog("Reloading failed", ""));+        assert!(is_save_prompt("Save Changes?", "Save changes to board.kicad_pcb before closing?"));+        assert!(is_save_prompt("Confirmation", "You have unsaved changes"));+        assert!(!is_save_prompt("Error", "File not found"));+    }++    #[test]+    fn sweep_decisions_never_touch_progress() {+        assert_eq!(sweep_decision("Load PCB", "Elapsed time 0:01", true), DialogAction::LeaveProgress);+        assert_eq!(sweep_decision("KiCad Setup", "", false), DialogAction::Dismiss { benign: true });+        assert_eq!(sweep_decision("Error", "Cannot open", false), DialogAction::Leave);+        assert_eq!(sweep_decision("Error", "Cannot open", true), DialogAction::Dismiss { benign: false });+        assert_eq!(auto_sweep_decision("Error", "Cannot open"), DialogAction::Dismiss { benign: false });+        assert_eq!(auto_sweep_decision("Rendering", ""), DialogAction::LeaveProgress);+        assert_eq!(auto_sweep_decision("Information", "converted to the new format"), DialogAction::Dismiss { benign: true });+    }++    #[test]+    fn global_dialog_predicate_and_buttons() {+        assert!(looks_like_global_dialog("KiCad PCB Editor Error", "#32770"));+        assert!(looks_like_global_dialog("Anything", "#32770"));+        assert!(!looks_like_global_dialog("", "#32770"));+        assert!(!looks_like_global_dialog("Error", "wxWindowNR"));+        assert_eq!(dismiss_tier("&OK"), Some(0));+        assert_eq!(dismiss_tier(" Yes "), Some(0));+        assert_eq!(dismiss_tier("&Cancel"), Some(1));+        assert_eq!(dismiss_tier("Finish"), Some(1));+        assert_eq!(dismiss_tier("Retry"), None);+        assert!(is_discard_label("&Discard Changes"));+        assert!(is_discard_label("Don't Save"));+        assert!(!is_discard_label("Save"));+        assert_eq!(normalize_button_label("&Got it"), "got it");+    }++    #[test]+    fn dialog_body_collapses_duplicates() {+        let texts = vec!["".into(), "Insufficient permissions".into(), "Insufficient permissions".into(), "to save".into(), "\u{1}\u{2}".into()];+        assert_eq!(dialog_body(&texts), "Insufficient permissions  to save");+        assert_eq!(dialog_body(&[]), "");+    }++    #[test]+    fn software_canvas_doc_edit() {+        let mut d = json!({"graphics": {"canvas_type": 1}, "api": {"enable_server": true}});+        assert!(force_software_canvas_doc(&mut d));+        assert_eq!(d["graphics"]["canvas_type"], json!(2));+        assert_eq!(d["api"]["enable_server"], json!(true));+        assert!(!force_software_canvas_doc(&mut d));+        let mut empty = Value::Null;+        assert!(force_software_canvas_doc(&mut empty));+        assert_eq!(empty["graphics"]["canvas_type"], json!(2));+    }++    #[test]+    fn editor_title_parsing() {+        assert_eq!(parse_editor_title("Adom:RP2040 \u{2014} Symbol Editor", "Symbol Editor"), ("Adom".into(), "RP2040".into()));+        assert_eq!(parse_editor_title("*Adom:RP2040 - Symbol Editor", "Symbol Editor"), ("Adom".into(), "RP2040".into()));+        assert_eq!(parse_editor_title("Device:R [Read Only Library] \u{2014} Symbol Editor", "Symbol Editor"), ("Device".into(), "R".into()));+        assert_eq!(parse_editor_title("[no symbol loaded] \u{2014} Symbol Editor", "Symbol Editor"), ("".into(), "".into()));+        assert_eq!(parse_editor_title("Adom:SOT-23 - Footprint Editor", "Footprint Editor"), ("Adom".into(), "SOT-23".into()));+        assert!(title_readonly("Device:R [Read Only Library] \u{2014} Symbol Editor"));+    }++    #[test]+    fn title_confirms_exactly_not_substring() {+        // 0.9.194: "AdomRP2040:ADOM_MECHANICAL_PIN" contains "rp2040" but is not it.+        assert!(!title_confirms_symbol("AdomRP2040:ADOM_MECHANICAL_PIN \u{2014} Symbol Editor", "RP2040", "Adom"));+        assert!(title_confirms_symbol("Adom:RP2040 \u{2014} Symbol Editor", "rp2040", ""));+        assert!(title_confirms_symbol("Adom:RP2040 \u{2014} Symbol Editor", "RP2040", "adom"));+        // #49: a same-named part in a different library is not a match when a library was named.+        assert!(!title_confirms_symbol("LED:WS2812B-2020 [Read Only Library] \u{2014} Symbol Editor", "WS2812B-2020", "Adom"));+        assert!(title_confirms_symbol("LED:WS2812B-2020 [Read Only Library] \u{2014} Symbol Editor", "WS2812B-2020", ""));+        // #54: the NAME_<unit>_<style> sub-node IS the requested part.+        assert!(title_confirms_symbol("Adom:STM32H563RGTx_1_1 \u{2014} Symbol Editor", "STM32H563RGTx", "Adom"));+        assert!(!title_confirms_symbol("Adom:STM32H563RGTx_1_1_extra \u{2014} Symbol Editor", "STM32H563RGTx", "Adom"));+        assert!(!title_confirms_symbol("[no symbol loaded] \u{2014} Symbol Editor", "R", ""));+        assert!(title_confirms_footprint("Adom:s10b-ph-sm4-tb - Footprint Editor", "S10B-PH-SM4-TB", "Adom"));+        assert!(!title_confirms_footprint("Adom:s10b-ph-sm4-tb - Footprint Editor", "S10B", "Adom"));+        assert!(!title_confirms_footprint("[no footprint loaded] - Footprint Editor", "X", ""));+    }++    #[test]+    fn resolved_fields_report_fallback_and_readonly() {+        let r = resolved_fields("LED:WS2812B-2020 [Read Only Library] \u{2014} Symbol Editor", "Adom", "Symbol Editor");+        assert_eq!(r["resolvedLibrary"], json!("LED"));+        assert_eq!(r["resolvedSymbol"], json!("WS2812B-2020"));+        assert_eq!(r["resolvedFrom"], json!("fallback"));+        assert_eq!(r["resolvedReadOnly"], json!(true));+        let r = resolved_fields("Adom:R \u{2014} Symbol Editor", "", "Symbol Editor");+        assert_eq!(r["resolvedFrom"], json!("requested"));+        assert!(r.get("resolvedReadOnly").is_none());+        assert_eq!(resolved_fields("[no symbol loaded] \u{2014} Symbol Editor", "", "Symbol Editor"), json!({}));+    }++    #[test]+    fn keys_and_combos() {+        assert_eq!(vk_for_key("Enter"), Some(0x0D));+        assert_eq!(vk_for_key("esc"), Some(0x1B));+        assert_eq!(vk_for_key("F12"), Some(0x7B));+        assert_eq!(vk_for_key("bogus"), None);+        assert_eq!(vk_for_char('a'), Some(0x41));+        assert_eq!(vk_for_char('7'), Some(0x37));+        assert_eq!(vk_for_char('!'), None);+        assert_eq!(parse_modifier_combo("ctrl+shift+f10"), Some((vec![0x11, 0x10], "f10".into())));+        assert_eq!(parse_modifier_combo("Alt+3"), Some((vec![0x12], "3".into())));+        assert_eq!(parse_modifier_combo("enter"), None);+        assert_eq!(parse_modifier_combo("bogus+x"), None);+        assert_eq!(norm_accel("Ctrl+Shift+ S"), "ctrl+shift+s");+    }++    #[test]+    fn canvas_probe_flat_versus_drawn() {+        let (w, h) = (200u32, 100u32);+        let flat = vec![40u8; (w * h * 3) as usize];+        let v = canvas_uniformity(&flat, w, h, Some((10, 10, 190, 90)));+        assert_eq!(v["checked"], json!(true));+        assert_eq!(v["rendered"], json!(false));+        assert_eq!(v["region"], json!("glCanvas"));+        let mut drawn = flat.clone();+        for i in 0..drawn.len() {+            drawn[i] = (i % 251) as u8;+        }+        let v = canvas_uniformity(&drawn, w, h, None);+        assert_eq!(v["rendered"], json!(true));+        assert_eq!(v["region"], json!("approx"));+        assert_eq!(canvas_uniformity(&flat, w, h, Some((0, 0, 0, 0)))["checked"], json!(false));+        assert_eq!(canvas_uniformity(&[], w, h, None)["checked"], json!(false));+    }++    #[test]+    fn safe_labels() {+        assert_eq!(safe_label("board.kicad_pcb \u{2014} PCB Editor", "editor"), "editor-boardkicad_pcb  PCB Editor");+        assert_eq!(safe_label("", "dialog"), "dialog");+    }+}
rust/crates/kicad-platform/src/lib.rs+2−1
@@ -141,7 +141,8 @@ pub trait Platform: Sync + Send {     fn list_windows(&self) -> Result<Vec<WindowInfo>, String> { nope("list_windows") }     /// One window by handle (visible or not).     fn window_info(&self, _hwnd: u64) -> Result<Option<WindowInfo>, String> { nope("window_info") }-    /// Direct children of a window (class name and text), for dialog buttons and edit boxes.+    /// Every descendant of a window (class name and text) with `owner` set to its direct parent,+    /// for dialog buttons and the library-tree filter box (join on class and parent title).     fn child_windows(&self, _hwnd: u64) -> Result<Vec<WindowInfo>, String> { nope("child_windows") }     /// Running processes whose executable lives under `bin_dir` (or any KiCad exe when None).     fn processes(&self, _exe_names: &[&str]) -> Result<Vec<ProcessInfo>, String> { nope("processes") }