app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
rust: phase 3 window verbs (20), ab callback client, KiCad window model; kicad_errors on the native window walk; caller headers forwarded; 160 tests
7 files changed
+4149−12
rust/crates/kicad-bridge/src/main.rs+1rust/crates/kicad-bridge/src/verbs.rs+2rust/crates/kicad-bridge/src/verbs_misc.rs+7−3rust/crates/kicad-bridge/src/verbs_windows.rs+2702−6rust/crates/kicad-core/src/ab.rs+723−1rust/crates/kicad-core/src/windows_model.rs+712−1rust/crates/kicad-platform/src/lib.rs+2−1rust/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 ¬e {+ 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") }