KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
kicad-lane: 1.0.66, teardrops and V-score panelize #27
From kicad-lane. KiCad bridge 1.0.66.
teardrops: adds or removes teardrops with KiCad's own Edit > Edit Teardrops dialog (KiCad's API has no teardrop call), on PTH pads, SMD pads and vias, optionally track to track or the selection only. It counts the teardrops before and after and returns the board's new revision. The board's default teardrop shapes are used.
panelize: steps a rectangular board into a V-score panel as a new board file:
- rows x cols copies, each with its nets renamed B-;
- one outline with rails, V-cut lines on Cmts.User, and three tooling holes and three fiducials;
- KiCad's DRC on the panel, compared with the board's own: newErrors counts only what the panel adds.
A shaped board is refused, because it needs mouse bites.
Tests:
cargo test --workspace: 531 pass; the 7 knownschematic_edittests fail because their fixture was never pushed.- Full verb battery on DESKTOP-BAR6S0F (KiCad 10.0.7): 267 pass, 0 fail, 1 skip; coverage complete; 0 foreground grabs.
- Teardrops on a scratch board: 0 to 4 and back to 0.
- A 2 x 3 panel of the route fixture: DRC adds nothing beyond its six copies.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,379 +1,382 @@⋯ 273 unchanged lines ⋯ "kicad_compile", "kicad_diff_pairs", "kicad_list_installed_libraries",- "kicad_replicate_layout"+ "kicad_replicate_layout",+ "kicad_teardrops",+ "kicad_panelize" ], "statusVerb": "kicad_status", "timeouts": {⋯ 96 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,1297 +1,1298 @@⋯ 309 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.65"+version = "1.0.66" dependencies = [ "kicad-core", "kicad-platform",⋯ 4 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.65"+version = "1.0.66" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.65"+version = "1.0.66" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.65"+version = "1.0.66" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,500 +1,574 @@⋯ 89 unchanged lines ⋯ related: &["kicad_export_fab_package", "kicad_validate_for_fab"], pitfalls: &["prices change; the rates in assumptions are the bridge's, not the fab's live price list"], },+ Verb {+ name: "kicad_panelize",+ summary: "Step a rectangular board into a manufacturing panel on V-score: rows x cols copies abutting, each copy's nets renamed B<n>-<net>, one panel outline with rails above and below, V-cut lines on Cmts.User along every seam, three tooling holes and three fiducials on the rails; written as a new board and checked with KiCad's DRC.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+ input: "{\"pcbPath\": \"C:/.../x.kicad_pcb\" (or filePath), \"panelPath\"?: \"C:/.../x-panel.kicad_pcb\", \"rows\"?: 1, \"cols\"?: 2, \"separation\"?: \"vscore\", \"gapMm\"?: 0, \"railMm\"?: 5, \"toolingHoles\"?: true, \"fiducials\"?: true, \"validate\"?: true, \"overwrite\"?: false}",+ example: "kicad_panelize {\"pcbPath\":\"C:/Users/john/proj/board.kicad_pcb\",\"rows\":2,\"cols\":3}",+ hint: "The panel is a new .kicad_pcb (the board itself is untouched) with the project's rules copied beside it. drc is KiCad's DRC of the panel with its zones refilled; open it with kicad_open_board and export with kicad_export_manufacturing. Tell the fab the V-cut layer is Cmts.User.",+ related: &["kicad_export_manufacturing", "kicad_run_drc", "kicad_open_board", "kicad_validate_for_fab"],+ pitfalls: &["V-score only, and only for a rectangular board: a board with curves or notches is refused (it needs tabs and mouse bites; KiKit makes those).", "Zone fills are not copied: the panel's zones are refilled when KiCad opens it (kicad_refill_zones) and in the DRC here.", "Designators repeat across the copies, as on most panels; the BOM and placement files of the panel are per board, not per panel."],+ }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 6 unchanged lines ⋯ "kicad_export_fab_package" => fab_package(state, args), "kicad_validate_for_fab" => validate(state, args), "kicad_estimate_cost" => estimate(args),+ "kicad_panelize" => panelize(state, args), _ => return None, }) }⋯ 309 unchanged lines ⋯ }) } ++/// `kicad_panelize`: a V-score panel of a rectangular board, as a new board file.+fn panelize(state: &mut State, args: &Value) -> Value {+ let Some(src) = arg_any(args, &["pcbPath", "filePath"]).map(std::path::PathBuf::from) else {+ return fail("missing_arg", "pcbPath is required", "The board to panelise (.kicad_pcb).");+ };+ if args.get("separation").and_then(Value::as_str).map(|s| s != "vscore").unwrap_or(false) || args.get("gapMm").and_then(Value::as_f64).map(|g| g > 0.0).unwrap_or(false) {+ return fail("not_supported", "Only V-score panels (boards abutting, gapMm 0) are made here", "Mouse bites need tabs cut into each outline: use KiKit for those, or separation:\"vscore\".");+ }+ let text = match std::fs::read_to_string(&src) {+ Ok(t) => t,+ Err(e) => return fail("board_not_found", format!("cannot read {}: {e}", norm(&src)), "Pass the board's absolute path."),+ };+ let stem = src.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());+ let out = arg_str(args, "panelPath").map(std::path::PathBuf::from).unwrap_or_else(|| src.with_file_name(format!("{stem}-panel.kicad_pcb")));+ if out.exists() && !args.get("overwrite").and_then(Value::as_bool).unwrap_or(false) {+ return fail("file_exists", format!("{} already exists", norm(&out)), "Pass overwrite:true to replace it, or another panelPath.");+ }+ let num = |k: &str, d: u64| args.get(k).and_then(Value::as_u64).unwrap_or(d) as usize;+ let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(true);+ let o = kicad_core::panel::Opts { rows: num("rows", 1), cols: num("cols", 2), rail_mm: args.get("railMm").and_then(Value::as_f64).unwrap_or(5.0), holes: flag("toolingHoles"), fiducials: flag("fiducials") };+ let (panel, mut report) = match kicad_core::panel::panelize(&text, &o) {+ Ok(x) => x,+ Err(e) => return fail(if e.contains("not a rectangle") || e.contains("curves") || e.contains("mouse bites") { "not_rectangular" } else { "panel_failed" }, e, "V-score suits rectangular boards; a shaped board needs tabs and mouse bites (KiKit)."),+ };+ if let Some(dir) = out.parent() {+ let _ = std::fs::create_dir_all(dir);+ }+ if let Err(e) = kicad_core::libraries::write_atomic(&out, panel.as_bytes()) {+ return fail("write_failed", e, "Check the folder is writable.");+ }+ // the project's rules and libraries travel with the panel+ let mut copied = Vec::new();+ for ext in ["kicad_pro", "kicad_dru"] {+ let (from, to) = (src.with_extension(ext), out.with_extension(ext));+ if from.is_file() && !to.exists() && std::fs::copy(&from, &to).is_ok() {+ copied.push(norm(&to));+ }+ }+ report["success"] = json!(true);+ report["panel"] = json!(norm(&out));+ report["copied"] = json!(copied);+ report["output"] = json!(format!("{} boards ({} x {}) on a {} x {} mm V-score panel: {}", report["boards"], o.rows, o.cols, report["panelSizeMm"][0], report["panelSizeMm"][1], norm(&out)));+ if args.get("validate").and_then(Value::as_bool).unwrap_or(true) {+ // the panel against its copies: only what the panel adds beyond boards x the board's own findings is new+ let drc = crate::verbs::call(state, "kicad_run_drc", &json!({"filePath": norm(&out), "refillZones": true}));+ let base = crate::verbs::call(state, "kicad_run_drc", &json!({"filePath": norm(&src), "refillZones": true}));+ let n = |v: &Value, k: &str| v.pointer(&format!("/summary/{k}")).and_then(Value::as_i64).unwrap_or(0);+ let boards = (o.rows * o.cols) as i64;+ report["drc"] = if drc["success"] == true && base["success"] == true {+ let new_errors = n(&drc, "errors") - boards * n(&base, "errors");+ let new_unconnected = n(&drc, "unconnected") - boards * n(&base, "unconnected");+ json!({"panelErrors": n(&drc, "errors"), "boardErrors": n(&base, "errors"), "newErrors": new_errors, "newUnconnected": new_unconnected,+ "clean": new_errors <= 0 && new_unconnected <= 0, "first": drc.pointer("/summary/first").cloned().unwrap_or(Value::Null)})+ } else {+ json!({"ran": false, "why": if drc["success"] == true { base["error"].clone() } else { drc["error"].clone() }})+ };+ }+ report["_hint"] = json!("Open the panel with kicad_open_board (its zones refill there), check kicad_run_drc, then kicad_export_manufacturing. V-cut lines are on Cmts.User: name that layer to the fab.");+ report+}+ #[cfg(test)] mod tests { use super::*;⋯ 75 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&d); } }+@@ -1,3542 +1,3661 @@⋯ 234 unchanged lines ⋯ 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"],- },- Verb {- name: "kicad_rescan_libraries",- summary: "Make newly installed parts visible ONCE: reload every open editor's library tree in place (seconds, nothing closed), then verify each listed footprint by loading it in the Footprint Editor. force:true is the process restart, the last resort.",- mechanism: Mechanism::Window, risk: "write", timeout_sec: 200,- input: "{\"footprints\"?: [\"Lib:Name\", ...], \"force\"?: bool, \"confirmRestart\"?: bool}",- example: "kicad_rescan_libraries {\"footprints\":[\"Adom:LQFP-48_STM32G431\"]}",- hint: "Wiki #51: install N parts, call this once, then show them all. A restart closes KiCad gracefully first (so it saves its own geometry) and puts every window back on the monitor the user had it on (wiki #48; closeHow and placementRestored report both). Pass footprints so the reply can say what KiCad can load now; without the list nothing is verified. A restart (force) refuses while any KiCad runs unless confirmRestart:true, because the native build cannot yet tell a user-launched KiCad from its own (the ownership ledger arrives with phase 4).",- related: &["kicad_install_footprint", "kicad_show_footprint", "kicad_list_footprints"],- pitfalls: &["KiCad 10 caches footprint libraries per process; the tree refresh reveals parts installed after the editor started (measured 2026-09-07), a full restart is only for the cases it does not."],- },-];--/// 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",- // loads a footprint into the editor, which KiCad may raise once the load finishes (a late raise on a slow box: winvm)- "kicad_rescan_libraries"];--/// 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 };- if gui {- // Phase 4: arm the guard and snapshot the park baseline for the etiquette loop.- native().etiquette_begin_verb(command, SPAWNS_WINDOW.contains(&command));- }- let mut out = match command {- "kicad_launch" => launch(state, args),- "kicad_rescan_libraries" => rescan_libraries(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_3d_view" => view_3d(args),- "kicad_refresh_board_views" => refresh_board_views(args),- "kicad_menu_items" => menu_items(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);- }- if gui {- native().etiquette_end_verb(command);- }- 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.-/// Set when KiCad's own "Could not use OpenGL" box was seen; cleared by kicad_enable_software_opengl.-pub(crate) static NO_OPENGL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);--fn is_3d_verb(command: &str) -> bool {- matches!(command, "kicad_open_3d_viewer" | "kicad_show_3d_board" | "kicad_show_3d_chip" | "kicad_3d_view")-}--pub(crate) 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 };- // Wiki #120: a read-only verb never presses anything. It still reports what it saw.- let read_only = crate::catalog::find(command).map(|v| v.risk == "read").unwrap_or(false);- if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600), !read_only) {- 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!(if read_only {- format!("KiCad has {} dialog(s) open (non-benign: {}). This verb is read-only, so it pressed nothing: read each body in _autoDialogs and decide (kicad_dismiss_dialogs, or answer it yourself).", swept["count"], titles.join(", "))- } else {- 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(", "))- });- }- // KiCad says this once per process start on a machine without usable OpenGL (a VM with no- // GPU, RDP): 2D falls back to software, the 3D viewer stays BLACK. Remember it, so the 3D- // verbs stop answering ok over a black canvas (wiki #100, seen on winvm).- if swept["dialogs"].as_array().is_some_and(|a| a.iter().any(|d| d["body"].as_str().unwrap_or("").contains("Could not use OpenGL"))) {- NO_OPENGL.store(true, std::sync::atomic::Ordering::Relaxed);- }- out["_autoDialogs"] = swept;- }- if is_3d_verb(command) && NO_OPENGL.load(std::sync::atomic::Ordering::Relaxed) && out.get("success").and_then(Value::as_bool).unwrap_or(true) {- out["canvasRendered"] = json!(false);- out["renderVerified"] = json!(false);- out["_renderHint"] = json!("KiCad reported \"Could not use OpenGL\" on this machine (no GPU: a VM or a remote session). The PCB and schematic canvases fall back to software, but the 3D viewer needs OpenGL 1.5 and shows a BLACK canvas: do not present its frame as proof. Fix: kicad_close, then kicad_enable_software_opengl (Mesa on the CPU, slow but correct, for GPU-less hosts only), then open again.");- }- // 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)) {- // With the etiquette loop running (Windows) the one-shot push would be a second action- // on a window the loop already handled once; keep it only where no loop exists.- // Exception (gate on 2026-09-12, ConfRoomROG and arav-rog): a freshly spawned editor- // re-raises itself when its board finishes loading WITHOUT a foreground transition- // (it already was the foreground while loading), so the loop sees nothing to bounce- // and the user's window stays covered. For spawn verbs the one-shot check runs- // regardless, twice: now and after a short settle for the late re-raise.- let loop_running = native().etiquette_debug() != Value::Null;- if let Some(ev) = if loop_running && !spawns { None } else { one_shot_focus_check(fg_before) } {- events.push(ev);- }- if spawns {- // Wait for the editor to finish loading (its "Load PCB" / "Loading" progress- // dialog gone), because that is the moment it raises itself; then check twice.- for _ in 0..16 {- let loading = scan_dialogs().iter().any(|d| wm::is_progress_dialog(&d.title, &d.body));- if !loading {- break;- }- sleep_ms(500);- }- sleep_ms(700);- if let Some(ev) = one_shot_focus_check(fg_before) {- events.push(ev);- }- sleep_ms(700);- if let Some(ev) = one_shot_focus_check(fg_before) {- events.push(ev);- }- }- }- // kicad_state already carries the loop's ring buffer; never clobber it with the- // per-verb list (which is empty while the loop runs).- if out.get("focusEvents").map(|v| v.as_array().map(|a| a.is_empty()).unwrap_or(true)).unwrap_or(true) {- 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> {- skip_setup_wizard();- 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()))?;- native().etiquette_register_pid(pid, None);- invalidate();- Ok((pid, log))-}--/// Is `name` a row of the user's own fp-lib-table (not only reachable through a nested table)?-fn user_table_row(ctx: &LibCtx, name: &str) -> bool {- ctx.fp_lib_table()- .and_then(|p| kicad_core::libraries::LibTable::parse_file(&p).ok())- .is_some_and(|t| t.entries.iter().any(|e| e.name == name && !e.lib_type.eq_ignore_ascii_case("Table")))-}--/// Before a KiCad process starts, and only while none runs: mark KiCad's setup wizard as done so-/// it never pops up in front of the user (see kicad_common::mark_first_run_done).-fn skip_setup_wizard() {- if !native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default().is_empty() {- return;- }- let versions: Vec<String> = kicad_core::detect::detect().installs.iter().map(|i| i.version.clone()).collect();- for v in versions {- if let Some(cfg) = native().config_dir(&v) {- match kicad_common::mark_first_run_done(&cfg) {- Ok(true) => eprintln!("[kicad-bridge] KiCad {v}: setup wizard marked done (kicad.json first_run_shown), it will not pop up again"),- Ok(false) => {}- Err(e) => eprintln!("[kicad-bridge] KiCad {v}: could not mark the setup wizard done: {e}"),- }- }- }-}--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)]-pub(crate) struct Win {- pub(crate) hwnd: u64,- pub(crate) 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})- }- /// `z` is the window's position in the OS enumeration, which EnumWindows returns front- /// first, so a caller can tell which of two windows of a kind the user was looking at- /// (wiki #48). ab's inventory carries its own z; this is the native fallback's.- fn from_info(w: &WindowInfo, z: usize) -> Win {- Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: Some(z as i64), 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.-pub(crate) 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).enumerate().map(|(z, w)| Win::from_info(w, z)).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))-}--pub(crate) fn kicad_windows() -> Vec<Win> {- find_windows(false, false).map(|(w, _)| w).unwrap_or_default()-}--pub(crate) 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)-}--/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can-/// put it back. Called before anything the bridge does that destroys geometry (a restart, a-/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach-/// nothing, so they are skipped.-pub(crate) fn remember_placement() -> usize {- let now = utc_stamp();- let mut mem = window_memory::load();- let mut learned = 0;- // Front to back, so that when two windows share a kind (two PCB Editors, say) the one the- // user was actually looking at is the one whose place is remembered for that kind.- let mut windows = kicad_windows();- windows.sort_by_key(|w| w.z.unwrap_or(i64::MAX));- let mut seen: Vec<WindowKind> = Vec::new();- for w in windows {- let kind = w.kind();- if matches!(kind, WindowKind::Dialog | WindowKind::Other) || seen.contains(&kind) {- continue;- }- seen.push(kind);- // placement() reports the NORMAL rect plus the maximized flag, which is what has to- // come back; the enumerated rect is the fallback where that call is unavailable.- let place = native().placement(w.hwnd).unwrap_or(Placement {- x: w.rect.0, y: w.rect.1, width: w.rect.2, height: w.rect.3, maximized: w.maximized,- });- if window_memory::observe(&mut mem, kind.as_str(), &place, w.minimized, &now) {- learned += 1;- }- }- if learned > 0 {- if let Err(e) = window_memory::save(&mem) {- eprintln!("[placement] could not save the placement memory: {e}");- }- }- learned-}--/// Wiki #48: put each window back where the user had it, without activating it. A fresh-/// KiCad process places every window on the PRIMARY display, which is how a multi-monitor-/// user's PCB Editor kept coming back to the laptop panel. Returns one row per window so the-/// caller's reply can say what moved and what was left alone.-pub(crate) fn restore_placement() -> Vec<Value> {- let mem = window_memory::load();- if mem.is_empty() {- return Vec::new();- }- kicad_windows().iter().filter_map(|w| restore_one(&mem, w.kind(), w.hwnd)).collect()-}--/// Wiki #48: one freshly opened window put back on the monitor the user keeps that kind of-/// window on. Called when a verb OPENS an editor, not only when it restarts KiCad, because-/// "the bridge opens or restarts KiCad and it comes back on the laptop screen" is one-/// complaint with two halves. Returns None when nothing is remembered for that kind.-pub(crate) fn restore_placement_one(kind: WindowKind, hwnd: u64) -> Option<Value> {- restore_one(&window_memory::load(), kind, hwnd)-}--fn restore_one(mem: &window_memory::Memory, kind: WindowKind, hwnd: u64) -> Option<Value> {- let remembered = mem.get(kind.as_str())?;- let r = &remembered.place;- let current = match native().placement(hwnd) {- Ok(p) => p,- Err(e) => return Some(json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e})),- };- let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);- Some(match window_memory::decide(r, ¤t, on_monitor) {- Restore::Skip(why) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "why": why}),- Restore::Move(p) => match native().set_placement(hwnd, &p) {- Ok(()) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": true,- "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},- "rememberedAt": remembered.at}),- Err(e) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e}),- },- })-}--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.-pub(crate) 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()-}--pub(crate) 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> {- // Part of the background guard: with the guard OFF the bridge moves no KiCad window at all.- // Before 1.0.48 this per-verb check ignored the switch, so "guard off" still pushed KiCad back- // after every verb and the A/B comparison showed nothing (AdomLapper, 2026-09-21).- if !native().etiquette_enabled() {- return None;- }- let before = fg_before?;- if is_kicad_hwnd(before) || native().is_shell_window(before) {- return None;- }- // What covers the user's window is the top of the Z order, not only the activated- // window: a KiCad editor raises itself when its board finishes loading without a- // foreground transition (gate on 2026-09-12).- let fg = native().foreground().ok().filter(|h| *h != 0);- let top = native().z_top().ok().filter(|h| *h != 0);- let after = match (fg, top) {- (Some(f), _) if is_kicad_hwnd(f) => f,- (_, Some(t)) if is_kicad_hwnd(t) => t,- _ => return None,- };- if after == before {- return None;- }- let title = title_of(after);- let pushed = native().push_to_background(after).is_ok();- // A Z-order push alone does not hold: the editor still owns the activation and raises- // itself again on its next paint. Give the user's window its activation back (this- // restores what the user had before the verb; it is not a foreground of ours).- // ASK for the user's window back, never force it (no thread-input attach): the forcing- // bring_to_front here was the same call that crashed Explorer when aimed at the taskbar.- let restored = native().request_foreground(before);- let action = match (pushed, restored) {- (true, true) => "pushed-to-background, user window restored",- (true, false) => "pushed-to-background",- (false, true) => "user window restored",- (false, false) => "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;- }- // The toast is debounced (settings.foregroundToastMinutes, 0 = never): ab mirrors it to- // the user's other boxes, and one per verb from a busy thread was worse than the- // foreground itself. The caption is local to the driven box and always shown.- let toast = foreground_toast_due();- if toast {- 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- native().etiquette_sanction(hwnd, 20.0); // the loop must not bounce a foreground the AI asked for- 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, "toast": toast, "at": kicad_common::now_utc_iso()}));- ok-}--static LAST_FOREGROUND_TOAST: Mutex<Option<Instant>> = Mutex::new(None);--/// One foreground toast per `foregroundToastMinutes`; 0 means never. Records the time-/// when it says yes.-fn foreground_toast_due() -> bool {- let minutes = kicad_core::settings::get_settings().get("foregroundToastMinutes").and_then(Value::as_u64).unwrap_or(10);- if minutes == 0 {- return false;- }- let mut last = match LAST_FOREGROUND_TOAST.lock() {- Ok(g) => g,- Err(p) => p.into_inner(),- };- let due = last.map(|t| t.elapsed() >= Duration::from_secs(minutes * 60)).unwrap_or(true);- if due {- *last = Some(Instant::now());- }- due-}--// ── Dialogs (close_windows.py) ────────────────────────────────────────────────--#[derive(Clone, Debug, Default)]-pub(crate) struct Dialog {- pub(crate) hwnd: u64,- pub(crate) title: String,- class_name: String,- owner: u64,- pub(crate) 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.-pub(crate) 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;- }- // Wiki #120: only a dialog OWNED BY A KICAD PROCESS (an orphaned pcbnew/eeschema- // included) is ever ours. Before 1.0.56 any titled #32770 matched, so a read-only- // kicad_state pressed a button on an unrelated Remote Desktop Connection dialog.- if !w.is_kicad() {- 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, dismiss: bool) -> 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 !dismiss => {- if !benign {- had_error = true;- }- dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": "left-open (read-only verb)"}));- }- 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();- // an exact label wins over a longer one that contains it: "Zoom to Selection" must never- // fire "Zoom to Selection Area" (an interactive tool that parks the editor waiting for a drag)- let clean = |l: &str| l.replace('&', "").split('\t').next().unwrap_or("").trim().to_lowercase();- if let Some(it) = items.iter().find(|it| it.id != 0 && it.path.last().map(|l| clean(l) == want).unwrap_or(false)) {- native().menu_invoke(hwnd, it.id)?;- invalidate();- return Ok(Some((it.id, it.path.last().unwrap().replace('&', ""))));- }- // Wiki #121: a substring that matches more than one command is refused with the choices,- // never resolved to whichever item comes first.- let mut hits: Vec<(u32, String)> = Vec::new();- for it in &items {- let Some(label) = it.path.last() else { continue };- if it.id != 0 && clean(label).contains(&want) && !hits.iter().any(|(id, _)| *id == it.id) {- hits.push((it.id, label.replace('&', "").split('\t').next().unwrap_or("").trim().to_string()));- }- }- match hits.len() {- 0 => Ok(None),- 1 => {- let (id, label) = hits.remove(0);- native().menu_invoke(hwnd, id)?;- invalidate();- Ok(Some((id, label)))- }- _ => Err(format!(- "ambiguous menu label '{needle}': it matches {}. Pass the exact label of the one you mean.",- hits.iter().map(|(_, l)| format!("\"{l}\"")).collect::<Vec<_>>().join(", ")- )),- }-}--/// 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);- let mut set = set_filter_text(edit.hwnd, filter);- if let Err(e) = &set {- // A slow box (2 cores, software rendering) spends many seconds loading every library the- // first time an editor opens, and its UI thread answers nothing meanwhile: the text timed out- // at 1.5 s and the verb gave up on a symbol that exists (winvm). Wait for the editor to answer- // again, then set the text once more.- steps.push(format!("filter text not accepted yet ({e}); waiting for the editor to become responsive"));- let mut waited = 0;- while waited < 40 && !native().responsive(frame, 500).unwrap_or(true) {- sleep_ms(500);- waited += 1;- }- sleep_ms(800);- set = set_filter_text(edit.hwnd, filter);- }- match set {- 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> {- let out = open_editor_frame_inner(info, spec, budget, pathways);- // Wiki #48: a frame the bridge just opened is placed by Windows defaults, which means the- // primary display. An existing window is the user's own and is never moved.- if let Ok((hwnd, mechanism)) = &out {- if *mechanism != "existing" {- if let Some(row) = restore_placement_one(spec.kind, *hwnd) {- if row["moved"] == json!(true) {- pathways.push(format!("placement-restored:{}", spec.kind.as_str()));- }- }- }- }- out-}--fn open_editor_frame_inner(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 ──────────────────────────────────────────────────────────────--pub(crate) 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() {- // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor- // the user last used, not on whatever Windows defaults to.- sleep_ms(1200);- let restored = restore_placement();- 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, "placementRestored": restored,- "_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 ───────────────────────────────────--pub(crate) 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)) {- // Wiki #122: a title match is only a basename match. For a board, ask KiCad which- // file it really has open before reusing the window.- let mut path_verified = false;- if kind == WindowKind::PcbEditor {- if let Ok(open) = open_board_paths(Duration::from_secs(3)) {- let wanted = norm(&path);- if open.iter().any(|p| same_board_path(p, &wanted)) {- path_verified = true;- } else if !open.is_empty() {- return json!({- "success": false, "errorCode": "different_board_same_name", "hwnd": w.hwnd, "title": w.title,- "error": format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")),- "openBoards": open, "requested": wanted,- "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close that board (save it first if it has changes), or open a copy of yours under a unique file name.",- });- }- }- }- return json!({- "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",- "pathVerified": path_verified,- "output": format!("{label} editor already has {name} open"),- "_hint": if path_verified { "The existing window was reused, not reloaded (KiCad confirmed the exact path). kicad_close first if you need a fresh load from disk." } else { "The existing window was reused on a file-name match that could not be confirmed against the full path (no KiCad API answer). If a same-named file from another folder could be open, kicad_close it first." },- });- }- 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);- if !native().capabilities().window_control {- return open_file_without_windows(kind, &path, pid, log, deadline, wait_s, exe_name, label, lint_verb);- }- 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);- // Wiki #48: this window was spawned by us, so Windows placed it on the primary- // display; put it back on the monitor the user keeps this editor on.- if let Some(row) = restore_placement_one(kind, w.hwnd) {- out["placementRestored"] = row;- }- }- 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-}--/// Wiki #127: no window list on this OS (Linux for now). A board is confirmed through KiCad's-/// own API (the editor reports the exact path it has open); a schematic by the process staying up.-#[allow(clippy::too_many_arguments)]-fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Option<PathBuf>, deadline: Instant, wait_s: f64, exe_name: &str, label: &str, lint_verb: &str) -> Value {- let wanted = norm(path);- let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();- 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}"),- "windowControl": false,- });- let mut confirmed = false;- let mut last_open: Vec<String> = Vec::new();- while Instant::now() < deadline {- if native().process_alive(pid) == Some(false) {- out["success"] = json!(false);- out["errorCode"] = json!("editor_exited");- out["error"] = json!(format!("{exe_name} exited before the {label} loaded"));- out["_hint"] = json!(format!("The launch log (logPath) carries KiCad's own message; {lint_verb} says what is wrong with the file."));- return out;- }- if kind == WindowKind::PcbEditor {- if let Ok(open) = open_board_paths(Duration::from_secs(2)) {- if open.iter().any(|p| same_board_path(p, &wanted)) {- confirmed = true;- break;- }- last_open = open;- }- } else if Instant::now() + Duration::from_secs_f64(wait_s) > deadline + Duration::from_secs(5) {- // A schematic: no API answer to wait for on KiCad 10; five seconds alive is the signal.- confirmed = true;- break;- }- sleep_ms(500);- }- out["loaded"] = json!(confirmed);- out["_hint"] = json!(if confirmed {- if kind == WindowKind::PcbEditor { "KiCad's API confirms the board is open, so the live verbs (routing, placement, zones) can use it now. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_render_board or kicad_export_svg for a picture." } else { "The schematic editor is running. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_export_svg or kicad_export_pdf for a picture." }- } else if last_open.is_empty() {- "The editor is running but KiCad's API did not report the board in time. A cold start can take longer: poll kicad_routing_state with filePath. If it answers api_server_off, run kicad_ipc_api {\"enable\":true} with KiCad closed."- } else {- "The editor is running, but KiCad's API reports a different board (openBoards). KiCad serves one API per machine: the first KiCad that started owns it."- });- if !last_open.is_empty() && !confirmed {- out["openBoards"] = json!(last_open);- }- 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)-}--pub(crate) 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);- // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the- // libraries after the library file was last written, so its cache cannot be stale. Saying- // library_cache_stale there sends the caller to rescan:true, which only closes and reopens- // an already fresh editor and burns the budget again. The verdict is navigation_failed.- let on_disk = symbol_on_disk(info, symbol_name);- let editor_is_ours = mechanism != "existing";- let stale = on_disk && !rescan && !editor_is_ours;- 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.",- });- if on_disk && editor_is_ours {- v["symbolInLibraryFile"] = json!(true);- v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));- v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");- }- 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 ───────────────────────────────────────────────--pub(crate) 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.",- });- // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running- // KiCad may not have: no such library row, no such footprint in the library, or both are there and- // this KiCad simply started before the library was registered.- if !library_name.is_empty() {- let ctx = lib_ctx(&state.kicad_info());- let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));- match libs.first() {- None => {- v["errorCode"] = json!("library_not_registered");- v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));- v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");- }- Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {- v["errorCode"] = json!("footprint_not_in_library");- v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));- v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());- v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");- }- // Only a row in the user's OWN table can have been added after KiCad started. A stock- // library (reached through KiCad's nested template table) is loaded by every KiCad at- // start, so for those the failure is the navigation itself: keep navigation_failed- // (1.0.37 blamed Resistor_SMD on "registered after launch", AdomLapper 2026-09-21).- Some(_) if !user_table_row(&ctx, &library_name) => {}- Some((_, info)) => {- v["errorCode"] = json!("library_not_loaded_by_running_kicad");- v["libraryOnDisk"] = json!(info.path);- v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));- v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));- }- }- }- merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));- v-}--// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────--static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());-fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{- let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();- let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();- if exact.len()==1{return exact.into_iter().next()}- if exact.len()>1{return None}- if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}- let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();- if created.len()==1{return created.into_iter().next()}- if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}- None-}-fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{- for _ in 0..rounds.max(1){- sleep_ms(750);invalidate();- let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();- let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));- if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){- if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}- 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 source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {- Some(w) => w.pid,- None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),- };- let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect();- 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(frame, source_pid, &before, 12) {- return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "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(frame, source_pid, &before, 12) {- return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "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)-}---/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.--/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.-fn is_view_refresh(path: &[String]) -> bool {- let clean = |s: &str| {- s.replace('&', "")- .split('\t')- .next()- .unwrap_or("")- .trim()- .to_lowercase()- };- path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"-}--pub(crate) fn refresh_board_views(args: &Value) -> Value {- let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {- return fail(- "source_editor_required",- "Pass the exact pcbHwnd.",- "Read kicad_open_editors first.",- );- };- invalidate();- let windows = kicad_windows();- let Some(source) = windows- .iter()- .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)- else {- return fail(- "source_editor_missing",- "Requested PCB editor is not open.",- "No substitute was used.",- );- };- let viewers = windows_of_kind(WindowKind::Viewer3d);- let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();- let count = windows- .iter()- .filter(|w| {- w.pid == source.pid- && matches!(- w.kind(),- WindowKind::PcbEditor | WindowKind::FootprintEditor- )- })- .count();- let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {- m.get(&frame)- .filter(|(pid, _)| *pid == source.pid)- .map(|(_, h)| *h)- });- let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {- return fail(- "viewer_binding_ambiguous",- "No uniquely linked open 3D viewer.",- "Open the viewer from this exact PCB editor first; nothing was refreshed.",- );- };- let mut commands = Vec::new();- for hwnd in [frame, viewer.hwnd] {- let items = match native().menu_tree(hwnd) {- Ok(items) => items,- Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),- };- let matches: Vec<_> = items- .iter()- .filter(|m| m.id != 0 && is_view_refresh(&m.path))- .collect();- if matches.len() != 1 {- return fail(- "refresh_action_missing",- "Exact View > Refresh menu not uniquely available.",- "No fallback menu or keyboard action was invoked.",- );- }- commands.push((hwnd, matches[0].id));- }- let mut invoked = Vec::new();- for (hwnd, id) in commands {- if let Err(e) = native().menu_invoke(hwnd, id) {- return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});- }- invoked.push(hwnd);- }- invalidate();- json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})-}--pub(crate) fn menu_items(args: &Value) -> Value {- let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {- Some(h) => h,- None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),- };- let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();- match native().menu_tree(hwnd) {- Ok(items) => {- let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();- json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})- }- Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),- Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),- }-}--pub(crate) fn view_3d(args: &Value) -> Value {- const ACTIONS: &[(&str, &str)] = &[- ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),- ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"),- ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),- ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),- ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),- ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),- ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),- ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),- ];- let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();- if action == "actions" || action.is_empty() {- return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});- }- invalidate();- let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {- Some(h) => h,- None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),- };- let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);- let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;- let steps: Vec<(String, usize)> = if action == "tour" {- match args.get("steps").and_then(Value::as_array) {- Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),- None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),- }- } else {- vec![(action.clone(), count)]- };- let mut done: Vec<Value> = Vec::new();- let started = Instant::now();- for (act, n) in &steps {- let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {- return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");- };- for _ in 0..*n {- match menu_invoke_by_text(hwnd, label) {- Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }- Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),- Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),- }- std::thread::sleep(Duration::from_millis(pause));- }- }- json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,- "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})-}--pub(crate) 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);- // Wiki #48: a close is the last moment these windows exist, so learn where they are- // before the geometry is gone. A kill in particular denies KiCad its own save.- remember_placement();- 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.- // Wiki #109 ask 3: a dirty Symbol Editor refused to go and the reply named no cause.- // Report every dialog that is up (the unrecognised save prompt is among them) and- // the editor's own close verb.- let seen: Vec<Value> = scan_dialogs().iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body.chars().take(240).collect::<String>()})).collect();- let editor_verbs: Vec<&str> = [("symbol editor", "kicad_close_symbol_editor"), ("footprint editor", "kicad_close_footprint_editor"), ("3d viewer", "kicad_close_3d_viewer")]- .iter().filter(|(word, _)| failed.iter().any(|t| t.to_lowercase().contains(word))).map(|(_, verb)| *verb).collect();- return json!({- "success": false, "errorCode": "close_failed", "dialogsSeen": seen, "editorCloseVerbs": editor_verbs,- "error": format!("Failed to close: {}", failed.join(", ")),- "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },- "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.",- });- }- 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.-pub(crate) 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 ───────────────────────────────────────────────────────────────--pub(crate) 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 loop_events = native().etiquette_events();- let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();- events.extend(loop_events);- let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();- let focus_debug = match native().etiquette_debug() {- Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),- v => v,- };- 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": focus_debug,- "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 {- // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the- // target window's menu bar, fully background. The way the PCM dialog is opened.- if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {- 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 the menu command to.", "kicad_window_info lists the windows; pass hwnd."),- },- };- let title = title_of(target);- return match menu_invoke_by_text(target, needle) {- Ok(Some((id, label))) => json!({- "success": true, "mechanism": "menu",- "output": format!("Menu item '{label}' fired on '{title}' (background)"),- "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},- "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",- }),- Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),- Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),- Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),- };- }- 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();- if !enabled.is_empty() {- NO_OPENGL.store(false, std::sync::atomic::Ordering::Relaxed); // the next start renders through Mesa- }- 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 viewer_selection_is_bound_to_source_and_refuses_ambiguity(){- let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};- assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);- assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());- assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());- assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);- assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());- assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);- }--- #[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);- }- }-}---/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.-fn rescan_libraries(state: &mut State, args: &Value) -> Value {- let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);- if !force {- let mut refreshed: Vec<u64> = Vec::new();- for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {- if let Some(h) = first_of_kind(kind) {- if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {- refreshed.push(h);- }- }- }- if !refreshed.is_empty() {- // The tree reloads asynchronously; the Python waited 3 s before probing.- std::thread::sleep(std::time::Duration::from_secs(3));- invalidate();- let mut wanted: Vec<(String, String)> = Vec::new();- for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {- match spec {- Value::String(s) if s.contains(':') => {- let (lib, name) = s.split_once(':').unwrap();- wanted.push((lib.to_string(), name.to_string()));- }- Value::Object(o) => {- if let Some(name) = o.get("name").and_then(Value::as_str) {- wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));- }- }- _ => {}- }- }- let mut probed: Vec<Value> = Vec::new();- if !wanted.is_empty() {- let fp = first_of_kind(WindowKind::FootprintEditor);- for (lib, name) in &wanted {- let (ok, trace, _mech) = match fp {- Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),- None => (false, json!({}), "none"),- };- probed.push(json!({- "library": lib, "name": name, "visible": ok,- "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },- "navigation": trace,- }));- }- }- let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));- let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});- out["_hint"] = json!(if !wanted.is_empty() && !verified {- format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",- probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))- } else if !wanted.is_empty() {- "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()- } else {- "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()- });- post_verb(state, "kicad_rescan_libraries", args, &mut out, None);- return out;- }- }- // Restart path: the native build has no ownership ledger yet (phase 4), so a running- // KiCad is treated as the user's unless the caller confirms.- let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();- let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);- if !running.is_empty() && !confirm {- return json!({"success": false, "errorCode": "rescan_refused",- "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",- "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),- "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});- }- // Wiki #48: a fresh process places every window on the primary display, so the user's- // monitor choice is captured here, while the windows still exist, and restored below.- let remembered = remember_placement();- let mut close_how = "nothing was running";- let mut graceful_failure = Value::Null;- if !running.is_empty() {- // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;- // the kill is the fallback for a window that will not go.- let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));- close_how = "graceful";- if !closed["success"].as_bool().unwrap_or(false) {- if closed["errorCode"] == json!("unsaved_changes") {- return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,- "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});- }- // Keep WHY the graceful close failed: otherwise the kill overwrites the only- // evidence and the next person sees "force-kill" with no cause.- graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});- closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));- close_how = "force-kill after the graceful close failed";- if !closed["success"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});- }- }- std::thread::sleep(std::time::Duration::from_secs(4));- }- let launched = launch(state, &json!({}));- if !launched["success"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});- }- for _ in 0..80 {- std::thread::sleep(std::time::Duration::from_millis(750));- invalidate();- if !kicad_windows().is_empty() {- sleep_ms(1200); // let the fresh windows settle before moving them- let restored = restore_placement();- let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();- return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",- "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,- "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});- }- }- json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",- "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})-}---#[cfg(test)]-mod linked_refresh_tests {- #[test]- fn exact_view_refresh_excludes_plugins_and_partial_labels() {- let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();- assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));- for a in [- vec!["Tools", "Refresh Plugins"],- vec!["View", "Refresh All"],- vec!["Refresh"],- ] {- assert!(!super::is_view_refresh(&path(&a)));- }- }-}--/// The boards KiCad's API says are open; without the ipc feature (the macOS type-check build) there is-/// no API to ask, and the callers fall back to the window title.-#[cfg(feature = "ipc")]-fn open_board_paths(timeout: Duration) -> Result<Vec<String>, String> {- kicad_core::ipc::open_board_paths(timeout)-}--#[cfg(not(feature = "ipc"))]-fn open_board_paths(_timeout: Duration) -> Result<Vec<String>, String> {- Err("this build has the ipc feature off".into())-}--#[cfg(feature = "ipc")]-fn same_board_path(a: &str, b: &str) -> bool {- kicad_core::ipc::same_board_path(a, b)-}--#[cfg(not(feature = "ipc"))]-fn same_board_path(a: &str, b: &str) -> bool {- a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/"))-}+ name: "kicad_teardrops",+ summary: "Add (or remove) teardrops with KiCad's own Edit > Edit Teardrops on the board open in the PCB editor: on vias, PTH pads and SMD pads, optionally track to track, for the whole board or the selection; teardrops counted before and after.",+ mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"vias\"?: true, \"pthPads\"?: true, \"smdPads\"?: true, \"tJunctions\"?: false (track to track), \"selectedOnly\"?: false, \"remove\"?: false (removes ALL teardrops)}",+ example: "kicad_teardrops {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"smdPads\":false}",+ hint: "Uses the board's default teardrop shapes (Board Setup > Teardrops). before and after count the teardrop areas; KiCad adds them as one Undo step. Save with kicad_save_board, then check with kicad_run_drc.",+ related: &["kicad_save_board", "kicad_run_drc", "kicad_refill_zones", "kicad_open_board"],+ pitfalls: &["The board must be open in the PCB editor (kicad_open_board); KiCad's API has no teardrop call, so the bridge drives KiCad's own dialog, which shows for a moment.", "Counts need the IPC API on (kicad_ipc_api); without it before and after are null and only the dialog's result is known.", "remove:true removes every teardrop on the board, whatever the scope says."],+ },+ 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"],+ },+ Verb {+ name: "kicad_rescan_libraries",+ summary: "Make newly installed parts visible ONCE: reload every open editor's library tree in place (seconds, nothing closed), then verify each listed footprint by loading it in the Footprint Editor. force:true is the process restart, the last resort.",+ mechanism: Mechanism::Window, risk: "write", timeout_sec: 200,+ input: "{\"footprints\"?: [\"Lib:Name\", ...], \"force\"?: bool, \"confirmRestart\"?: bool}",+ example: "kicad_rescan_libraries {\"footprints\":[\"Adom:LQFP-48_STM32G431\"]}",+ hint: "Wiki #51: install N parts, call this once, then show them all. A restart closes KiCad gracefully first (so it saves its own geometry) and puts every window back on the monitor the user had it on (wiki #48; closeHow and placementRestored report both). Pass footprints so the reply can say what KiCad can load now; without the list nothing is verified. A restart (force) refuses while any KiCad runs unless confirmRestart:true, because the native build cannot yet tell a user-launched KiCad from its own (the ownership ledger arrives with phase 4).",+ related: &["kicad_install_footprint", "kicad_show_footprint", "kicad_list_footprints"],+ pitfalls: &["KiCad 10 caches footprint libraries per process; the tree refresh reveals parts installed after the editor started (measured 2026-09-07), a full restart is only for the cases it does not."],+ },+];++/// 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",+ // loads a footprint into the editor, which KiCad may raise once the load finishes (a late raise on a slow box: winvm)+ "kicad_rescan_libraries"];++/// 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 };+ if gui {+ // Phase 4: arm the guard and snapshot the park baseline for the etiquette loop.+ native().etiquette_begin_verb(command, SPAWNS_WINDOW.contains(&command));+ }+ let mut out = match command {+ "kicad_launch" => launch(state, args),+ "kicad_rescan_libraries" => rescan_libraries(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_3d_view" => view_3d(args),+ "kicad_refresh_board_views" => refresh_board_views(args),+ "kicad_menu_items" => menu_items(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_teardrops" => teardrops(state, 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);+ }+ if gui {+ native().etiquette_end_verb(command);+ }+ 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.+/// Set when KiCad's own "Could not use OpenGL" box was seen; cleared by kicad_enable_software_opengl.+pub(crate) static NO_OPENGL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);++fn is_3d_verb(command: &str) -> bool {+ matches!(command, "kicad_open_3d_viewer" | "kicad_show_3d_board" | "kicad_show_3d_chip" | "kicad_3d_view")+}++pub(crate) 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 };+ // Wiki #120: a read-only verb never presses anything. It still reports what it saw.+ let read_only = crate::catalog::find(command).map(|v| v.risk == "read").unwrap_or(false);+ if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600), !read_only) {+ 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!(if read_only {+ format!("KiCad has {} dialog(s) open (non-benign: {}). This verb is read-only, so it pressed nothing: read each body in _autoDialogs and decide (kicad_dismiss_dialogs, or answer it yourself).", swept["count"], titles.join(", "))+ } else {+ 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(", "))+ });+ }+ // KiCad says this once per process start on a machine without usable OpenGL (a VM with no+ // GPU, RDP): 2D falls back to software, the 3D viewer stays BLACK. Remember it, so the 3D+ // verbs stop answering ok over a black canvas (wiki #100, seen on winvm).+ if swept["dialogs"].as_array().is_some_and(|a| a.iter().any(|d| d["body"].as_str().unwrap_or("").contains("Could not use OpenGL"))) {+ NO_OPENGL.store(true, std::sync::atomic::Ordering::Relaxed);+ }+ out["_autoDialogs"] = swept;+ }+ if is_3d_verb(command) && NO_OPENGL.load(std::sync::atomic::Ordering::Relaxed) && out.get("success").and_then(Value::as_bool).unwrap_or(true) {+ out["canvasRendered"] = json!(false);+ out["renderVerified"] = json!(false);+ out["_renderHint"] = json!("KiCad reported \"Could not use OpenGL\" on this machine (no GPU: a VM or a remote session). The PCB and schematic canvases fall back to software, but the 3D viewer needs OpenGL 1.5 and shows a BLACK canvas: do not present its frame as proof. Fix: kicad_close, then kicad_enable_software_opengl (Mesa on the CPU, slow but correct, for GPU-less hosts only), then open again.");+ }+ // 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)) {+ // With the etiquette loop running (Windows) the one-shot push would be a second action+ // on a window the loop already handled once; keep it only where no loop exists.+ // Exception (gate on 2026-09-12, ConfRoomROG and arav-rog): a freshly spawned editor+ // re-raises itself when its board finishes loading WITHOUT a foreground transition+ // (it already was the foreground while loading), so the loop sees nothing to bounce+ // and the user's window stays covered. For spawn verbs the one-shot check runs+ // regardless, twice: now and after a short settle for the late re-raise.+ let loop_running = native().etiquette_debug() != Value::Null;+ if let Some(ev) = if loop_running && !spawns { None } else { one_shot_focus_check(fg_before) } {+ events.push(ev);+ }+ if spawns {+ // Wait for the editor to finish loading (its "Load PCB" / "Loading" progress+ // dialog gone), because that is the moment it raises itself; then check twice.+ for _ in 0..16 {+ let loading = scan_dialogs().iter().any(|d| wm::is_progress_dialog(&d.title, &d.body));+ if !loading {+ break;+ }+ sleep_ms(500);+ }+ sleep_ms(700);+ if let Some(ev) = one_shot_focus_check(fg_before) {+ events.push(ev);+ }+ sleep_ms(700);+ if let Some(ev) = one_shot_focus_check(fg_before) {+ events.push(ev);+ }+ }+ }+ // kicad_state already carries the loop's ring buffer; never clobber it with the+ // per-verb list (which is empty while the loop runs).+ if out.get("focusEvents").map(|v| v.as_array().map(|a| a.is_empty()).unwrap_or(true)).unwrap_or(true) {+ 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> {+ skip_setup_wizard();+ 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()))?;+ native().etiquette_register_pid(pid, None);+ invalidate();+ Ok((pid, log))+}++/// Is `name` a row of the user's own fp-lib-table (not only reachable through a nested table)?+fn user_table_row(ctx: &LibCtx, name: &str) -> bool {+ ctx.fp_lib_table()+ .and_then(|p| kicad_core::libraries::LibTable::parse_file(&p).ok())+ .is_some_and(|t| t.entries.iter().any(|e| e.name == name && !e.lib_type.eq_ignore_ascii_case("Table")))+}++/// Before a KiCad process starts, and only while none runs: mark KiCad's setup wizard as done so+/// it never pops up in front of the user (see kicad_common::mark_first_run_done).+fn skip_setup_wizard() {+ if !native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default().is_empty() {+ return;+ }+ let versions: Vec<String> = kicad_core::detect::detect().installs.iter().map(|i| i.version.clone()).collect();+ for v in versions {+ if let Some(cfg) = native().config_dir(&v) {+ match kicad_common::mark_first_run_done(&cfg) {+ Ok(true) => eprintln!("[kicad-bridge] KiCad {v}: setup wizard marked done (kicad.json first_run_shown), it will not pop up again"),+ Ok(false) => {}+ Err(e) => eprintln!("[kicad-bridge] KiCad {v}: could not mark the setup wizard done: {e}"),+ }+ }+ }+}++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)]+pub(crate) struct Win {+ pub(crate) hwnd: u64,+ pub(crate) 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})+ }+ /// `z` is the window's position in the OS enumeration, which EnumWindows returns front+ /// first, so a caller can tell which of two windows of a kind the user was looking at+ /// (wiki #48). ab's inventory carries its own z; this is the native fallback's.+ fn from_info(w: &WindowInfo, z: usize) -> Win {+ Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: Some(z as i64), 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.+pub(crate) 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).enumerate().map(|(z, w)| Win::from_info(w, z)).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))+}++pub(crate) fn kicad_windows() -> Vec<Win> {+ find_windows(false, false).map(|(w, _)| w).unwrap_or_default()+}++pub(crate) 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)+}++/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can+/// put it back. Called before anything the bridge does that destroys geometry (a restart, a+/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach+/// nothing, so they are skipped.+pub(crate) fn remember_placement() -> usize {+ let now = utc_stamp();+ let mut mem = window_memory::load();+ let mut learned = 0;+ // Front to back, so that when two windows share a kind (two PCB Editors, say) the one the+ // user was actually looking at is the one whose place is remembered for that kind.+ let mut windows = kicad_windows();+ windows.sort_by_key(|w| w.z.unwrap_or(i64::MAX));+ let mut seen: Vec<WindowKind> = Vec::new();+ for w in windows {+ let kind = w.kind();+ if matches!(kind, WindowKind::Dialog | WindowKind::Other) || seen.contains(&kind) {+ continue;+ }+ seen.push(kind);+ // placement() reports the NORMAL rect plus the maximized flag, which is what has to+ // come back; the enumerated rect is the fallback where that call is unavailable.+ let place = native().placement(w.hwnd).unwrap_or(Placement {+ x: w.rect.0, y: w.rect.1, width: w.rect.2, height: w.rect.3, maximized: w.maximized,+ });+ if window_memory::observe(&mut mem, kind.as_str(), &place, w.minimized, &now) {+ learned += 1;+ }+ }+ if learned > 0 {+ if let Err(e) = window_memory::save(&mem) {+ eprintln!("[placement] could not save the placement memory: {e}");+ }+ }+ learned+}++/// Wiki #48: put each window back where the user had it, without activating it. A fresh+/// KiCad process places every window on the PRIMARY display, which is how a multi-monitor+/// user's PCB Editor kept coming back to the laptop panel. Returns one row per window so the+/// caller's reply can say what moved and what was left alone.+pub(crate) fn restore_placement() -> Vec<Value> {+ let mem = window_memory::load();+ if mem.is_empty() {+ return Vec::new();+ }+ kicad_windows().iter().filter_map(|w| restore_one(&mem, w.kind(), w.hwnd)).collect()+}++/// Wiki #48: one freshly opened window put back on the monitor the user keeps that kind of+/// window on. Called when a verb OPENS an editor, not only when it restarts KiCad, because+/// "the bridge opens or restarts KiCad and it comes back on the laptop screen" is one+/// complaint with two halves. Returns None when nothing is remembered for that kind.+pub(crate) fn restore_placement_one(kind: WindowKind, hwnd: u64) -> Option<Value> {+ restore_one(&window_memory::load(), kind, hwnd)+}++fn restore_one(mem: &window_memory::Memory, kind: WindowKind, hwnd: u64) -> Option<Value> {+ let remembered = mem.get(kind.as_str())?;+ let r = &remembered.place;+ let current = match native().placement(hwnd) {+ Ok(p) => p,+ Err(e) => return Some(json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e})),+ };+ let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);+ Some(match window_memory::decide(r, ¤t, on_monitor) {+ Restore::Skip(why) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "why": why}),+ Restore::Move(p) => match native().set_placement(hwnd, &p) {+ Ok(()) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": true,+ "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},+ "rememberedAt": remembered.at}),+ Err(e) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e}),+ },+ })+}++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.+pub(crate) 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()+}++pub(crate) 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> {+ // Part of the background guard: with the guard OFF the bridge moves no KiCad window at all.+ // Before 1.0.48 this per-verb check ignored the switch, so "guard off" still pushed KiCad back+ // after every verb and the A/B comparison showed nothing (AdomLapper, 2026-09-21).+ if !native().etiquette_enabled() {+ return None;+ }+ let before = fg_before?;+ if is_kicad_hwnd(before) || native().is_shell_window(before) {+ return None;+ }+ // What covers the user's window is the top of the Z order, not only the activated+ // window: a KiCad editor raises itself when its board finishes loading without a+ // foreground transition (gate on 2026-09-12).+ let fg = native().foreground().ok().filter(|h| *h != 0);+ let top = native().z_top().ok().filter(|h| *h != 0);+ let after = match (fg, top) {+ (Some(f), _) if is_kicad_hwnd(f) => f,+ (_, Some(t)) if is_kicad_hwnd(t) => t,+ _ => return None,+ };+ if after == before {+ return None;+ }+ let title = title_of(after);+ let pushed = native().push_to_background(after).is_ok();+ // A Z-order push alone does not hold: the editor still owns the activation and raises+ // itself again on its next paint. Give the user's window its activation back (this+ // restores what the user had before the verb; it is not a foreground of ours).+ // ASK for the user's window back, never force it (no thread-input attach): the forcing+ // bring_to_front here was the same call that crashed Explorer when aimed at the taskbar.+ let restored = native().request_foreground(before);+ let action = match (pushed, restored) {+ (true, true) => "pushed-to-background, user window restored",+ (true, false) => "pushed-to-background",+ (false, true) => "user window restored",+ (false, false) => "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;+ }+ // The toast is debounced (settings.foregroundToastMinutes, 0 = never): ab mirrors it to+ // the user's other boxes, and one per verb from a busy thread was worse than the+ // foreground itself. The caption is local to the driven box and always shown.+ let toast = foreground_toast_due();+ if toast {+ 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+ native().etiquette_sanction(hwnd, 20.0); // the loop must not bounce a foreground the AI asked for+ 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, "toast": toast, "at": kicad_common::now_utc_iso()}));+ ok+}++static LAST_FOREGROUND_TOAST: Mutex<Option<Instant>> = Mutex::new(None);++/// One foreground toast per `foregroundToastMinutes`; 0 means never. Records the time+/// when it says yes.+fn foreground_toast_due() -> bool {+ let minutes = kicad_core::settings::get_settings().get("foregroundToastMinutes").and_then(Value::as_u64).unwrap_or(10);+ if minutes == 0 {+ return false;+ }+ let mut last = match LAST_FOREGROUND_TOAST.lock() {+ Ok(g) => g,+ Err(p) => p.into_inner(),+ };+ let due = last.map(|t| t.elapsed() >= Duration::from_secs(minutes * 60)).unwrap_or(true);+ if due {+ *last = Some(Instant::now());+ }+ due+}++// ── Dialogs (close_windows.py) ────────────────────────────────────────────────++#[derive(Clone, Debug, Default)]+pub(crate) struct Dialog {+ pub(crate) hwnd: u64,+ pub(crate) title: String,+ class_name: String,+ owner: u64,+ pub(crate) 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.+pub(crate) 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;+ }+ // Wiki #120: only a dialog OWNED BY A KICAD PROCESS (an orphaned pcbnew/eeschema+ // included) is ever ours. Before 1.0.56 any titled #32770 matched, so a read-only+ // kicad_state pressed a button on an unrelated Remote Desktop Connection dialog.+ if !w.is_kicad() {+ 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, dismiss: bool) -> 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 !dismiss => {+ if !benign {+ had_error = true;+ }+ dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": "left-open (read-only verb)"}));+ }+ 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();+ // an exact label wins over a longer one that contains it: "Zoom to Selection" must never+ // fire "Zoom to Selection Area" (an interactive tool that parks the editor waiting for a drag)+ let clean = |l: &str| l.replace('&', "").split('\t').next().unwrap_or("").trim().to_lowercase();+ if let Some(it) = items.iter().find(|it| it.id != 0 && it.path.last().map(|l| clean(l) == want).unwrap_or(false)) {+ native().menu_invoke(hwnd, it.id)?;+ invalidate();+ return Ok(Some((it.id, it.path.last().unwrap().replace('&', ""))));+ }+ // Wiki #121: a substring that matches more than one command is refused with the choices,+ // never resolved to whichever item comes first.+ let mut hits: Vec<(u32, String)> = Vec::new();+ for it in &items {+ let Some(label) = it.path.last() else { continue };+ if it.id != 0 && clean(label).contains(&want) && !hits.iter().any(|(id, _)| *id == it.id) {+ hits.push((it.id, label.replace('&', "").split('\t').next().unwrap_or("").trim().to_string()));+ }+ }+ match hits.len() {+ 0 => Ok(None),+ 1 => {+ let (id, label) = hits.remove(0);+ native().menu_invoke(hwnd, id)?;+ invalidate();+ Ok(Some((id, label)))+ }+ _ => Err(format!(+ "ambiguous menu label '{needle}': it matches {}. Pass the exact label of the one you mean.",+ hits.iter().map(|(_, l)| format!("\"{l}\"")).collect::<Vec<_>>().join(", ")+ )),+ }+}++/// 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);+ let mut set = set_filter_text(edit.hwnd, filter);+ if let Err(e) = &set {+ // A slow box (2 cores, software rendering) spends many seconds loading every library the+ // first time an editor opens, and its UI thread answers nothing meanwhile: the text timed out+ // at 1.5 s and the verb gave up on a symbol that exists (winvm). Wait for the editor to answer+ // again, then set the text once more.+ steps.push(format!("filter text not accepted yet ({e}); waiting for the editor to become responsive"));+ let mut waited = 0;+ while waited < 40 && !native().responsive(frame, 500).unwrap_or(true) {+ sleep_ms(500);+ waited += 1;+ }+ sleep_ms(800);+ set = set_filter_text(edit.hwnd, filter);+ }+ match set {+ 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> {+ let out = open_editor_frame_inner(info, spec, budget, pathways);+ // Wiki #48: a frame the bridge just opened is placed by Windows defaults, which means the+ // primary display. An existing window is the user's own and is never moved.+ if let Ok((hwnd, mechanism)) = &out {+ if *mechanism != "existing" {+ if let Some(row) = restore_placement_one(spec.kind, *hwnd) {+ if row["moved"] == json!(true) {+ pathways.push(format!("placement-restored:{}", spec.kind.as_str()));+ }+ }+ }+ }+ out+}++fn open_editor_frame_inner(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 ──────────────────────────────────────────────────────────────++pub(crate) 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() {+ // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor+ // the user last used, not on whatever Windows defaults to.+ sleep_ms(1200);+ let restored = restore_placement();+ 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, "placementRestored": restored,+ "_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 ───────────────────────────────────++pub(crate) 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)) {+ // Wiki #122: a title match is only a basename match. For a board, ask KiCad which+ // file it really has open before reusing the window.+ let mut path_verified = false;+ if kind == WindowKind::PcbEditor {+ if let Ok(open) = open_board_paths(Duration::from_secs(3)) {+ let wanted = norm(&path);+ if open.iter().any(|p| same_board_path(p, &wanted)) {+ path_verified = true;+ } else if !open.is_empty() {+ return json!({+ "success": false, "errorCode": "different_board_same_name", "hwnd": w.hwnd, "title": w.title,+ "error": format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")),+ "openBoards": open, "requested": wanted,+ "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close that board (save it first if it has changes), or open a copy of yours under a unique file name.",+ });+ }+ }+ }+ return json!({+ "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",+ "pathVerified": path_verified,+ "output": format!("{label} editor already has {name} open"),+ "_hint": if path_verified { "The existing window was reused, not reloaded (KiCad confirmed the exact path). kicad_close first if you need a fresh load from disk." } else { "The existing window was reused on a file-name match that could not be confirmed against the full path (no KiCad API answer). If a same-named file from another folder could be open, kicad_close it first." },+ });+ }+ 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);+ if !native().capabilities().window_control {+ return open_file_without_windows(kind, &path, pid, log, deadline, wait_s, exe_name, label, lint_verb);+ }+ 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);+ // Wiki #48: this window was spawned by us, so Windows placed it on the primary+ // display; put it back on the monitor the user keeps this editor on.+ if let Some(row) = restore_placement_one(kind, w.hwnd) {+ out["placementRestored"] = row;+ }+ }+ 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+}++/// Wiki #127: no window list on this OS (Linux for now). A board is confirmed through KiCad's+/// own API (the editor reports the exact path it has open); a schematic by the process staying up.+#[allow(clippy::too_many_arguments)]+fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Option<PathBuf>, deadline: Instant, wait_s: f64, exe_name: &str, label: &str, lint_verb: &str) -> Value {+ let wanted = norm(path);+ let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ 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}"),+ "windowControl": false,+ });+ let mut confirmed = false;+ let mut last_open: Vec<String> = Vec::new();+ while Instant::now() < deadline {+ if native().process_alive(pid) == Some(false) {+ out["success"] = json!(false);+ out["errorCode"] = json!("editor_exited");+ out["error"] = json!(format!("{exe_name} exited before the {label} loaded"));+ out["_hint"] = json!(format!("The launch log (logPath) carries KiCad's own message; {lint_verb} says what is wrong with the file."));+ return out;+ }+ if kind == WindowKind::PcbEditor {+ if let Ok(open) = open_board_paths(Duration::from_secs(2)) {+ if open.iter().any(|p| same_board_path(p, &wanted)) {+ confirmed = true;+ break;+ }+ last_open = open;+ }+ } else if Instant::now() + Duration::from_secs_f64(wait_s) > deadline + Duration::from_secs(5) {+ // A schematic: no API answer to wait for on KiCad 10; five seconds alive is the signal.+ confirmed = true;+ break;+ }+ sleep_ms(500);+ }+ out["loaded"] = json!(confirmed);+ out["_hint"] = json!(if confirmed {+ if kind == WindowKind::PcbEditor { "KiCad's API confirms the board is open, so the live verbs (routing, placement, zones) can use it now. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_render_board or kicad_export_svg for a picture." } else { "The schematic editor is running. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_export_svg or kicad_export_pdf for a picture." }+ } else if last_open.is_empty() {+ "The editor is running but KiCad's API did not report the board in time. A cold start can take longer: poll kicad_routing_state with filePath. If it answers api_server_off, run kicad_ipc_api {\"enable\":true} with KiCad closed."+ } else {+ "The editor is running, but KiCad's API reports a different board (openBoards). KiCad serves one API per machine: the first KiCad that started owns it."+ });+ if !last_open.is_empty() && !confirmed {+ out["openBoards"] = json!(last_open);+ }+ 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)+}++pub(crate) 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);+ // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the+ // libraries after the library file was last written, so its cache cannot be stale. Saying+ // library_cache_stale there sends the caller to rescan:true, which only closes and reopens+ // an already fresh editor and burns the budget again. The verdict is navigation_failed.+ let on_disk = symbol_on_disk(info, symbol_name);+ let editor_is_ours = mechanism != "existing";+ let stale = on_disk && !rescan && !editor_is_ours;+ 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.",+ });+ if on_disk && editor_is_ours {+ v["symbolInLibraryFile"] = json!(true);+ v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));+ v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");+ }+ 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 ───────────────────────────────────────────────++pub(crate) 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.",+ });+ // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running+ // KiCad may not have: no such library row, no such footprint in the library, or both are there and+ // this KiCad simply started before the library was registered.+ if !library_name.is_empty() {+ let ctx = lib_ctx(&state.kicad_info());+ let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));+ match libs.first() {+ None => {+ v["errorCode"] = json!("library_not_registered");+ v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));+ v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");+ }+ Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {+ v["errorCode"] = json!("footprint_not_in_library");+ v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));+ v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());+ v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");+ }+ // Only a row in the user's OWN table can have been added after KiCad started. A stock+ // library (reached through KiCad's nested template table) is loaded by every KiCad at+ // start, so for those the failure is the navigation itself: keep navigation_failed+ // (1.0.37 blamed Resistor_SMD on "registered after launch", AdomLapper 2026-09-21).+ Some(_) if !user_table_row(&ctx, &library_name) => {}+ Some((_, info)) => {+ v["errorCode"] = json!("library_not_loaded_by_running_kicad");+ v["libraryOnDisk"] = json!(info.path);+ v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));+ v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));+ }+ }+ }+ merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+ v+}++// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────++static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());+fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{+ let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();+ let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();+ if exact.len()==1{return exact.into_iter().next()}+ if exact.len()>1{return None}+ if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}+ let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();+ if created.len()==1{return created.into_iter().next()}+ if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}+ None+}+fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{+ for _ in 0..rounds.max(1){+ sleep_ms(750);invalidate();+ let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();+ let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));+ if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){+ if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}+ 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 source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {+ Some(w) => w.pid,+ None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),+ };+ let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect();+ 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(frame, source_pid, &before, 12) {+ return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "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(frame, source_pid, &before, 12) {+ return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "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)+}+++/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.++/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.+fn is_view_refresh(path: &[String]) -> bool {+ let clean = |s: &str| {+ s.replace('&', "")+ .split('\t')+ .next()+ .unwrap_or("")+ .trim()+ .to_lowercase()+ };+ path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"+}++pub(crate) fn refresh_board_views(args: &Value) -> Value {+ let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {+ return fail(+ "source_editor_required",+ "Pass the exact pcbHwnd.",+ "Read kicad_open_editors first.",+ );+ };+ invalidate();+ let windows = kicad_windows();+ let Some(source) = windows+ .iter()+ .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)+ else {+ return fail(+ "source_editor_missing",+ "Requested PCB editor is not open.",+ "No substitute was used.",+ );+ };+ let viewers = windows_of_kind(WindowKind::Viewer3d);+ let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();+ let count = windows+ .iter()+ .filter(|w| {+ w.pid == source.pid+ && matches!(+ w.kind(),+ WindowKind::PcbEditor | WindowKind::FootprintEditor+ )+ })+ .count();+ let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {+ m.get(&frame)+ .filter(|(pid, _)| *pid == source.pid)+ .map(|(_, h)| *h)+ });+ let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {+ return fail(+ "viewer_binding_ambiguous",+ "No uniquely linked open 3D viewer.",+ "Open the viewer from this exact PCB editor first; nothing was refreshed.",+ );+ };+ let mut commands = Vec::new();+ for hwnd in [frame, viewer.hwnd] {+ let items = match native().menu_tree(hwnd) {+ Ok(items) => items,+ Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),+ };+ let matches: Vec<_> = items+ .iter()+ .filter(|m| m.id != 0 && is_view_refresh(&m.path))+ .collect();+ if matches.len() != 1 {+ return fail(+ "refresh_action_missing",+ "Exact View > Refresh menu not uniquely available.",+ "No fallback menu or keyboard action was invoked.",+ );+ }+ commands.push((hwnd, matches[0].id));+ }+ let mut invoked = Vec::new();+ for (hwnd, id) in commands {+ if let Err(e) = native().menu_invoke(hwnd, id) {+ return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});+ }+ invoked.push(hwnd);+ }+ invalidate();+ json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})+}++pub(crate) fn menu_items(args: &Value) -> Value {+ let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {+ Some(h) => h,+ None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),+ };+ let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();+ match native().menu_tree(hwnd) {+ Ok(items) => {+ let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();+ json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})+ }+ Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+ Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),+ }+}++pub(crate) fn view_3d(args: &Value) -> Value {+ const ACTIONS: &[(&str, &str)] = &[+ ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),+ ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"),+ ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),+ ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),+ ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),+ ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),+ ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),+ ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),+ ];+ let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();+ if action == "actions" || action.is_empty() {+ return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});+ }+ invalidate();+ let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {+ Some(h) => h,+ None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),+ };+ let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);+ let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;+ let steps: Vec<(String, usize)> = if action == "tour" {+ match args.get("steps").and_then(Value::as_array) {+ Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),+ None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),+ }+ } else {+ vec![(action.clone(), count)]+ };+ let mut done: Vec<Value> = Vec::new();+ let started = Instant::now();+ for (act, n) in &steps {+ let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {+ return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");+ };+ for _ in 0..*n {+ match menu_invoke_by_text(hwnd, label) {+ Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }+ Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),+ Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),+ }+ std::thread::sleep(Duration::from_millis(pause));+ }+ }+ json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,+ "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})+}++pub(crate) 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);+ // Wiki #48: a close is the last moment these windows exist, so learn where they are+ // before the geometry is gone. A kill in particular denies KiCad its own save.+ remember_placement();+ 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.+ // Wiki #109 ask 3: a dirty Symbol Editor refused to go and the reply named no cause.+ // Report every dialog that is up (the unrecognised save prompt is among them) and+ // the editor's own close verb.+ let seen: Vec<Value> = scan_dialogs().iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body.chars().take(240).collect::<String>()})).collect();+ let editor_verbs: Vec<&str> = [("symbol editor", "kicad_close_symbol_editor"), ("footprint editor", "kicad_close_footprint_editor"), ("3d viewer", "kicad_close_3d_viewer")]+ .iter().filter(|(word, _)| failed.iter().any(|t| t.to_lowercase().contains(word))).map(|(_, verb)| *verb).collect();+ return json!({+ "success": false, "errorCode": "close_failed", "dialogsSeen": seen, "editorCloseVerbs": editor_verbs,+ "error": format!("Failed to close: {}", failed.join(", ")),+ "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+ "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.",+ });+ }+ 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.+pub(crate) 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 ───────────────────────────────────────────────────────────────++pub(crate) 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 loop_events = native().etiquette_events();+ let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();+ events.extend(loop_events);+ let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();+ let focus_debug = match native().etiquette_debug() {+ Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),+ v => v,+ };+ 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": focus_debug,+ "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 {+ // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the+ // target window's menu bar, fully background. The way the PCM dialog is opened.+ if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {+ 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 the menu command to.", "kicad_window_info lists the windows; pass hwnd."),+ },+ };+ let title = title_of(target);+ return match menu_invoke_by_text(target, needle) {+ Ok(Some((id, label))) => json!({+ "success": true, "mechanism": "menu",+ "output": format!("Menu item '{label}' fired on '{title}' (background)"),+ "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+ "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",+ }),+ Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),+ Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+ Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),+ };+ }+ 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();+ if !enabled.is_empty() {+ NO_OPENGL.store(false, std::sync::atomic::Ordering::Relaxed); // the next start renders through Mesa+ }+ 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 viewer_selection_is_bound_to_source_and_refuses_ambiguity(){+ let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};+ assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);+ assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());+ assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());+ assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);+ assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());+ assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);+ }+++ #[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);+ }+ }+}+++/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.+fn rescan_libraries(state: &mut State, args: &Value) -> Value {+ let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+ if !force {+ let mut refreshed: Vec<u64> = Vec::new();+ for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {+ if let Some(h) = first_of_kind(kind) {+ if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {+ refreshed.push(h);+ }+ }+ }+ if !refreshed.is_empty() {+ // The tree reloads asynchronously; the Python waited 3 s before probing.+ std::thread::sleep(std::time::Duration::from_secs(3));+ invalidate();+ let mut wanted: Vec<(String, String)> = Vec::new();+ for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {+ match spec {+ Value::String(s) if s.contains(':') => {+ let (lib, name) = s.split_once(':').unwrap();+ wanted.push((lib.to_string(), name.to_string()));+ }+ Value::Object(o) => {+ if let Some(name) = o.get("name").and_then(Value::as_str) {+ wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));+ }+ }+ _ => {}+ }+ }+ let mut probed: Vec<Value> = Vec::new();+ if !wanted.is_empty() {+ let fp = first_of_kind(WindowKind::FootprintEditor);+ for (lib, name) in &wanted {+ let (ok, trace, _mech) = match fp {+ Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),+ None => (false, json!({}), "none"),+ };+ probed.push(json!({+ "library": lib, "name": name, "visible": ok,+ "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },+ "navigation": trace,+ }));+ }+ }+ let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));+ let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});+ out["_hint"] = json!(if !wanted.is_empty() && !verified {+ format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",+ probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))+ } else if !wanted.is_empty() {+ "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()+ } else {+ "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()+ });+ post_verb(state, "kicad_rescan_libraries", args, &mut out, None);+ return out;+ }+ }+ // Restart path: the native build has no ownership ledger yet (phase 4), so a running+ // KiCad is treated as the user's unless the caller confirms.+ let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();+ let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);+ if !running.is_empty() && !confirm {+ return json!({"success": false, "errorCode": "rescan_refused",+ "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",+ "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),+ "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});+ }+ // Wiki #48: a fresh process places every window on the primary display, so the user's+ // monitor choice is captured here, while the windows still exist, and restored below.+ let remembered = remember_placement();+ let mut close_how = "nothing was running";+ let mut graceful_failure = Value::Null;+ if !running.is_empty() {+ // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;+ // the kill is the fallback for a window that will not go.+ let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));+ close_how = "graceful";+ if !closed["success"].as_bool().unwrap_or(false) {+ if closed["errorCode"] == json!("unsaved_changes") {+ return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,+ "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});+ }+ // Keep WHY the graceful close failed: otherwise the kill overwrites the only+ // evidence and the next person sees "force-kill" with no cause.+ graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});+ closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));+ close_how = "force-kill after the graceful close failed";+ if !closed["success"].as_bool().unwrap_or(false) {+ return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});+ }+ }+ std::thread::sleep(std::time::Duration::from_secs(4));+ }+ let launched = launch(state, &json!({}));+ if !launched["success"].as_bool().unwrap_or(false) {+ return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});+ }+ for _ in 0..80 {+ std::thread::sleep(std::time::Duration::from_millis(750));+ invalidate();+ if !kicad_windows().is_empty() {+ sleep_ms(1200); // let the fresh windows settle before moving them+ let restored = restore_placement();+ let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();+ return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",+ "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,+ "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});+ }+ }+ json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",+ "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})+}+++#[cfg(test)]+mod linked_refresh_tests {+ #[test]+ fn exact_view_refresh_excludes_plugins_and_partial_labels() {+ let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();+ assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));+ for a in [+ vec!["Tools", "Refresh Plugins"],+ vec!["View", "Refresh All"],+ vec!["Refresh"],+ ] {+ assert!(!super::is_view_refresh(&path(&a)));+ }+ }+}++/// The boards KiCad's API says are open; without the ipc feature (the macOS type-check build) there is+/// no API to ask, and the callers fall back to the window title.+#[cfg(feature = "ipc")]+fn open_board_paths(timeout: Duration) -> Result<Vec<String>, String> {+ kicad_core::ipc::open_board_paths(timeout)+}++#[cfg(not(feature = "ipc"))]+fn open_board_paths(_timeout: Duration) -> Result<Vec<String>, String> {+ Err("this build has the ipc feature off".into())+}++#[cfg(feature = "ipc")]+fn same_board_path(a: &str, b: &str) -> bool {+ kicad_core::ipc::same_board_path(a, b)+}++#[cfg(not(feature = "ipc"))]+fn same_board_path(a: &str, b: &str) -> bool {+ a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/"))+}++// ---------------------------------------------------------------------------+// kicad_teardrops: KiCad's own Edit > Edit Teardrops+// ---------------------------------------------------------------------------++/// Teardrop areas on the board the editor serves (zones carrying `(teardrop (type ..))`), or+/// None without the IPC API. Read straight from KiCad, not through another verb: this runs+/// under GUI_LOCK, which verbs::call would try to take again.+/// (teardrop areas, the board's revision) for the board the editor serves; None without IPC.+#[cfg(feature = "ipc")]+fn teardrop_count(state: &mut State, args: &Value) -> Option<(usize, String)> {+ use kicad_core::{detect, ipc};+ let info = state.kicad_info();+ let ctx = ipc::Ctx {+ kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+ config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+ };+ let (text, _, _) = ipc::board_text(&ctx, args).ok()?;+ Some((text.match_indices("(teardrop").filter(|(i, _)| !text[i + 9..].starts_with('s')).count(), ipc::revision_of(&text)))+}++#[cfg(not(feature = "ipc"))]+fn teardrop_count(_state: &mut State, _args: &Value) -> Option<(usize, String)> {+ None+}++fn teardrops(state: &mut State, args: &Value) -> Value {+ let Some(path) = arg_str(args, "filePath") else {+ return fail("missing_arg", "filePath is required", "The .kicad_pcb open in the PCB editor.");+ };+ let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ let Some(editor) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) else {+ return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor.");+ };+ let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);+ let remove = flag("remove", false);+ let before = teardrop_count(state, args).map(|(n, _)| n);+ match menu_invoke_by_text(editor.hwnd, "Edit Teardrops...") {+ Ok(Some(_)) => {}+ Ok(None) => return fail("menu_not_found", "the PCB editor has no Edit Teardrops... command", "KiCad 8 or newer has it under Edit."),+ Err(e) => return fail("menu_failed", e, "Check the editor with kicad_window_info."),+ }+ let mut dialog = None;+ for _ in 0..60 {+ if let Some(d) = scan_dialogs().into_iter().find(|d| d.title.trim() == "Set Teardrops") {+ dialog = Some(d.hwnd);+ break;+ }+ sleep_ms(250);+ }+ let Some(dlg) = dialog else {+ return fail("dialog_not_shown", "KiCad did not show the Set Teardrops dialog within 15 s", "kicad_window_info lists what KiCad shows; another dialog may be in the way.");+ };+ let mut steps = Vec::new();+ let mut set = |label: &str, on: bool| -> Result<(), String> { native().uia_set_checked(dlg, label, on).map(|r| steps.push(r)) };+ let options = [("PTH pads", flag("pthPads", true)), ("SMD pads", flag("smdPads", true)), ("Vias", flag("vias", true)), ("Track to track", flag("tJunctions", false)), ("Selected items only", flag("selectedOnly", false))];+ let mut failed = None;+ for (label, on) in options {+ if let Err(e) = set(label, on) {+ failed = Some(e);+ break;+ }+ }+ if failed.is_none() {+ let action = if remove { "Remove all teardrops" } else { "Add teardrops with default values for shape" };+ match native().uia_invoke(dlg, action) {+ Ok(r) => steps.push(r),+ Err(e) => failed = Some(e),+ }+ }+ if let Some(e) = failed {+ let _ = native().click_button(dlg, &["close"]);+ return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed. kicad_screenshot_all shows KiCad's windows.");+ }+ if let Err(e) = native().click_button(dlg, &["apply and close"]) {+ let _ = native().click_button(dlg, &["close"]);+ return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed.");+ }+ steps.push("apply and close".into());+ let started = Instant::now();+ while window_alive(dlg) && started.elapsed() < Duration::from_secs(150) {+ sleep_ms(250);+ }+ if window_alive(dlg) {+ return fail("dialog_still_open", "Set Teardrops is still open after 150 s", "KiCad may still be working; check with kicad_window_info before calling again.");+ }+ let after_read = teardrop_count(state, args);+ let after = after_read.as_ref().map(|(n, _)| *n);+ let revision = after_read.map(|(_, r)| r);+ let delta = match (before, after) {+ (Some(b), Some(a)) => Some(a as i64 - b as i64),+ _ => None,+ };+ json!({+ "success": true,+ "output": match delta {+ Some(d) => format!("Teardrops {}: {} before, {} after", if remove { "removed" } else { "applied" }, before.unwrap_or(0), after.unwrap_or(0)) + if d == 0 && !remove { " (nothing new: every item in scope already had one, or none qualified)" } else { "" },+ None => format!("Teardrops {} through KiCad's dialog (counts need the IPC API)", if remove { "removed" } else { "applied" }),+ },+ "before": before, "after": after, "added": delta.filter(|d| *d > 0), "removed": delta.filter(|d| *d < 0).map(|d| -d),+ "mutated": delta.map(|d| d != 0),+ "dialogSteps": steps,+ "revision": revision,+ "saved": false,+ "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. Save with kicad_save_board, then kicad_run_drc: teardrops are copper areas and take part in DRC.",+ })+}+@@ -1,65 +1,67 @@⋯ 50 unchanged lines ⋯ pub mod symedit; pub mod libops; pub mod board_reads;+pub mod panel; pub mod libsearch; pub mod sxedit; pub mod fp_text;⋯ 6 unchanged lines ⋯ pub mod sch_review; pub mod sch_render; pub mod sch_sheets;+@@ -0,0 +1,374 @@+//! A manufacturing panel from one board: the board stepped into rows x columns on a V-score+//! grid (the copies abut), each copy's nets renamed `B<n>-<net>` so the copies stay separate nets,+//! every copy's own outline replaced by one panel outline with rails above and below, V-cut lines on+//! Cmts.User along every seam, three tooling holes and three fiducials on the rails.+//!+//! Only a rectangular board (its Edge.Cuts a rectangle) is panelised on V-score here: a board with+//! curves or notches needs tabs and mouse bites (KiKit does that).++use std::collections::HashMap;++use serde_json::{json, Value};++use crate::schematic::{new_uuid, Document, Node};++pub struct Opts {+ pub rows: usize,+ pub cols: usize,+ pub rail_mm: f64,+ pub holes: bool,+ pub fiducials: bool,+}++const HEADER: &[&str] = &["version", "generator", "generator_version", "general", "paper", "title_block", "layers", "setup", "property", "net", "embedded_fonts", "embedded_files"];++fn fmt(v: f64) -> String {+ let r = (v * 1e6).round() / 1e6 + 0.0;+ let s = format!("{r}");+ s+}++/// The board's outline as a rectangle [x0, y0, x1, y1], or why it is not one.+pub fn rect_outline(root: &Node) -> Result<[f64; 4], String> {+ let edges: Vec<&Node> = root.children().iter().filter(|c| c.head().map(|h| h.starts_with("gr_")).unwrap_or(false) && c.child_value("layer").as_deref() == Some("Edge.Cuts")).collect();+ if edges.is_empty() {+ return Err("the board has no Edge.Cuts outline".into());+ }+ let mut segs: Vec<((f64, f64), (f64, f64))> = Vec::new();+ for e in &edges {+ let pt = |t: &str| e.child(t).and_then(|c| Some((c.num(0)?, c.num(1)?)));+ match e.head() {+ Some("gr_line") => segs.push((pt("start").ok_or("line without start")?, pt("end").ok_or("line without end")?)),+ Some("gr_rect") => {+ let (a, b) = (pt("start").ok_or("rect without start")?, pt("end").ok_or("rect without end")?);+ segs.extend([(a, (b.0, a.1)), ((b.0, a.1), b), (b, (a.0, b.1)), ((a.0, b.1), a)]);+ }+ Some("gr_poly") => {+ let pts: Vec<(f64, f64)> = e.child("pts").map(|p| p.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))).collect()).unwrap_or_default();+ for i in 0..pts.len() {+ segs.push((pts[i], pts[(i + 1) % pts.len()]));+ }+ }+ Some(other) => return Err(format!("the outline has a {} (curves need tabs and mouse bites, not V-score)", other.trim_start_matches("gr_"))),+ None => {}+ }+ }+ let xs = segs.iter().flat_map(|(a, b)| [a.0, b.0]);+ let ys = segs.iter().flat_map(|(a, b)| [a.1, b.1]);+ let (x0, x1) = (xs.clone().fold(f64::MAX, f64::min), xs.fold(f64::MIN, f64::max));+ let (y0, y1) = (ys.clone().fold(f64::MAX, f64::min), ys.fold(f64::MIN, f64::max));+ let near = |a: f64, b: f64| (a - b).abs() < 1e-4;+ for (a, b) in &segs {+ let on_h = near(a.1, b.1) && (near(a.1, y0) || near(a.1, y1));+ let on_v = near(a.0, b.0) && (near(a.0, x0) || near(a.0, x1));+ if !(on_h || on_v) {+ return Err(format!("the outline is not a rectangle (an edge from ({}, {}) to ({}, {}) lies inside it); a shaped board needs tabs and mouse bites", fmt(a.0), fmt(a.1), fmt(b.0), fmt(b.1)));+ }+ }+ let perimeter: f64 = segs.iter().map(|(a, b)| (b.0 - a.0).abs() + (b.1 - a.1).abs()).sum();+ if !near(perimeter, 2.0 * ((x1 - x0) + (y1 - y0))) {+ return Err("the outline's edges do not close one rectangle".into());+ }+ Ok([x0, y0, x1, y1])+}++/// Every coordinate node inside an item (not a footprint), moved by (dx, dy).+fn shift(n: &mut Node, dx: f64, dy: f64) {+ if matches!(n.head(), Some("start" | "end" | "mid" | "center" | "at" | "xy")) {+ if let (Some(x), Some(y)) = (n.num(0), n.num(1)) {+ n.set_arg(0, Node::atom(fmt(x + dx)));+ n.set_arg(1, Node::atom(fmt(y + dy)));+ }+ return;+ }+ if let Some(kids) = n.children_mut() {+ for k in kids.iter_mut() {+ if k.is_list() {+ shift(k, dx, dy);+ }+ }+ }+}++/// Every `(uuid ..)`/`(tstamp ..)` in a copy made fresh (ids must be unique across the panel).+fn fresh_ids(n: &mut Node) {+ if matches!(n.head(), Some("uuid" | "tstamp")) {+ n.set_arg(0, Node::quoted(&new_uuid()));+ return;+ }+ if let Some(kids) = n.children_mut() {+ for k in kids.iter_mut() {+ if k.is_list() {+ fresh_ids(k);+ }+ }+ }+}++/// Nets of one copy renamed: `(net "X")` (and a zone's `(net_name "X")`) to the copy's name;+/// numbered nets `(net 3)` / `(net 3 "X")` to the copy's number and name.+fn rename_nets(n: &mut Node, copy: usize, numbers: &HashMap<i64, i64>, names: &HashMap<String, String>) {+ match n.head() {+ Some("net") => {+ let a0 = n.arg(0);+ match a0.as_deref().and_then(|s| s.parse::<i64>().ok()) {+ Some(code) => {+ if let Some(new) = numbers.get(&code) {+ n.set_arg(0, Node::atom(new.to_string()));+ }+ if let Some(name) = n.arg(1).and_then(|x| names.get(&x).cloned()) {+ n.set_arg(1, Node::quoted(&name));+ }+ }+ None => {+ if let Some(name) = a0.and_then(|x| names.get(&x).cloned()) {+ n.set_arg(0, Node::quoted(&name));+ }+ }+ }+ return;+ }+ Some("net_name") => {+ if let Some(name) = n.arg(0).and_then(|x| names.get(&x).cloned()) {+ n.set_arg(0, Node::quoted(&name));+ }+ return;+ }+ _ => {}+ }+ let _ = copy;+ if let Some(kids) = n.children_mut() {+ for k in kids.iter_mut() {+ if k.is_list() {+ rename_nets(k, copy, numbers, names);+ }+ }+ }+}++fn parse(text: &str) -> Result<Node, String> {+ crate::schematic::parse_node(text.trim())+}++fn tool_footprint(reference: &str, x: f64, y: f64, fiducial: bool, hole_mm: f64) -> Result<Node, String> {+ let pad = if fiducial {+ format!("(pad \"1\" smd circle (at 0 0) (size 1 1) (layers \"F.Cu\" \"F.Mask\") (solder_mask_margin 0.5) (uuid \"{}\"))", new_uuid())+ } else {+ format!("(pad \"\" np_thru_hole circle (at 0 0) (size {h} {h}) (drill {h}) (layers \"*.Cu\" \"*.Mask\") (uuid \"{}\"))", new_uuid(), h = fmt(hole_mm))+ };+ let name = if fiducial { "Fiducial_1mm_Mask2mm" } else { "ToolingHole" };+ parse(&format!(+ "(footprint \"Panel:{name}\" (layer \"F.Cu\") (uuid \"{}\") (at {} {}) \+ (property \"Reference\" \"{reference}\" (at 0 -2 0) (layer \"F.SilkS\") (hide yes) (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)))) \+ (property \"Value\" \"{name}\" (at 0 2 0) (layer \"F.Fab\") (hide yes) (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)))) \+ (attr {}exclude_from_pos_files exclude_from_bom) {pad})",+ new_uuid(), fmt(x), fmt(y), new_uuid(), new_uuid(), if fiducial { "smd " } else { "" }+ ))+}++fn line(layer: &str, a: (f64, f64), b: (f64, f64), width: f64) -> Result<Node, String> {+ parse(&format!("(gr_line (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (layer \"{layer}\") (uuid \"{}\"))", fmt(a.0), fmt(a.1), fmt(b.0), fmt(b.1), fmt(width), new_uuid()))+}++/// The panel's board text and a report.+pub fn panelize(text: &str, o: &Opts) -> Result<(String, Value), String> {+ if o.rows == 0 || o.cols == 0 || o.rows * o.cols > 200 {+ return Err("rows and cols must be at least 1, at most 200 boards".into());+ }+ let mut doc = Document::parse(text)?;+ if doc.root.head() != Some("kicad_pcb") {+ return Err("not a board".into());+ }+ let [x0, y0, x1, y1] = rect_outline(&doc.root)?;+ let (w, h) = (x1 - x0, y1 - y0);+ let kids: Vec<Node> = doc.root.children().to_vec();+ // numbered nets: the declaration table+ let decls: Vec<(i64, String)> = kids.iter().filter(|c| c.head() == Some("net")).filter_map(|c| Some((c.arg(0)?.parse().ok()?, c.arg(1).unwrap_or_default()))).collect();+ let span = decls.iter().map(|(c, _)| *c).max().unwrap_or(0) + 1;+ // every net name in use (numbered or by name), for the name-style boards+ let mut all_names: Vec<String> = decls.iter().map(|(_, n)| n.clone()).collect();+ fn collect_names(n: &Node, out: &mut Vec<String>) {+ if matches!(n.head(), Some("net" | "net_name")) {+ let name = if n.head() == Some("net_name") { n.arg(0) } else if n.arg(0).and_then(|s| s.parse::<i64>().ok()).is_some() { n.arg(1) } else { n.arg(0) };+ if let Some(x) = name {+ if !out.contains(&x) {+ out.push(x);+ }+ }+ return;+ }+ for k in n.children().iter().filter(|k| k.is_list()) {+ collect_names(k, out);+ }+ }+ for k in kids.iter().filter(|c| !c.head().map(|h| HEADER.contains(&h)).unwrap_or(true)) {+ collect_names(k, &mut all_names);+ }+ let items: Vec<&Node> = kids.iter().filter(|c| c.is_list() && !c.head().map(|h| HEADER.contains(&h)).unwrap_or(true)).collect();+ let mut out_items: Vec<Node> = Vec::new();+ let mut new_decls: Vec<Node> = Vec::new();+ let mut n = 0usize;+ for r in 0..o.rows {+ for c in 0..o.cols {+ n += 1;+ let (dx, dy) = (c as f64 * w, r as f64 * h);+ let names: HashMap<String, String> = all_names.iter().filter(|x| !x.is_empty()).map(|x| (x.clone(), format!("B{n}-{x}"))).collect();+ let numbers: HashMap<i64, i64> = decls.iter().map(|(code, _)| (*code, if *code == 0 { 0 } else { code + span * (n as i64 - 1) })).collect();+ for (code, name) in &decls {+ if *code == 0 {+ continue;+ }+ new_decls.push(parse(&format!("(net {} \"{}\")", numbers[code], names.get(name).cloned().unwrap_or_else(|| name.clone()).replace('"', "\\\"")))?);+ }+ for it in &items {+ let head = it.head().unwrap_or("");+ if head == "group" || (head.starts_with("gr_") && it.child_value("layer").as_deref() == Some("Edge.Cuts")) {+ continue;+ }+ let mut cp = (*it).clone();+ if head == "footprint" {+ if let Some(at) = cp.child_mut("at") {+ shift(at, dx, dy);+ }+ } else {+ if head == "zone" {+ // fills are recomputed by KiCad (kicad_refill_zones or kicad-cli --refill-zones)+ if let Some(k) = cp.children_mut() {+ k.retain(|c| !matches!(c.head(), Some("filled_polygon" | "fill_segments")));+ }+ }+ shift(&mut cp, dx, dy);+ }+ fresh_ids(&mut cp);+ rename_nets(&mut cp, n, &numbers, &names);+ out_items.push(cp);+ }+ }+ }+ // the panel: outline with rails, V-cuts, tooling holes, fiducials+ let (pw, ph) = (o.cols as f64 * w, o.rows as f64 * h);+ let rail = o.rail_mm.max(0.0);+ let (top, bottom) = (y0 - rail, y0 + ph + rail);+ let mut panel_items: Vec<Node> = Vec::new();+ for (a, b) in [((x0, top), (x0 + pw, top)), ((x0 + pw, top), (x0 + pw, bottom)), ((x0 + pw, bottom), (x0, bottom)), ((x0, bottom), (x0, top))] {+ panel_items.push(line("Edge.Cuts", a, b, 0.1)?);+ }+ let mut vcuts = 0;+ for r in 0..=o.rows {+ let y = y0 + r as f64 * h;+ if rail <= 0.0 && (r == 0 || r == o.rows) {+ continue;+ }+ panel_items.push(line("Cmts.User", (x0 - 2.0, y), (x0 + pw + 2.0, y), 0.1)?);+ vcuts += 1;+ }+ for c in 1..o.cols {+ let x = x0 + c as f64 * w;+ panel_items.push(line("Cmts.User", (x, top - 2.0), (x, bottom + 2.0), 0.1)?);+ vcuts += 1;+ }+ panel_items.push(parse(&format!("(gr_text \"V-CUT\" (at {} {} 0) (layer \"Cmts.User\") (uuid \"{}\") (effects (font (size 1 1) (thickness 0.15)) (justify left)))", fmt(x0 + pw + 3.0), fmt(y0), new_uuid()))?);+ let (mut holes, mut fids) = (0, 0);+ if rail >= 3.0 {+ let hole = if rail >= 6.0 { 3.0 } else { 2.0 };+ let inset = (rail / 2.0).max(2.5);+ let mid_top = top + rail / 2.0;+ let mid_bot = bottom - rail / 2.0;+ if o.holes {+ for (i, (x, y)) in [(x0 + inset, mid_top), (x0 + pw - inset, mid_top), (x0 + inset, mid_bot)].into_iter().enumerate() {+ panel_items.push(tool_footprint(&format!("TH{}", i + 1), x, y, false, hole)?);+ holes += 1;+ }+ }+ if o.fiducials && pw > 4.0 * inset + 6.0 {+ for (i, (x, y)) in [(x0 + 3.0 * inset, mid_top), (x0 + pw - 3.0 * inset, mid_top), (x0 + 3.0 * inset, mid_bot)].into_iter().enumerate() {+ panel_items.push(tool_footprint(&format!("FID{}", i + 1), x, y, true, 0.0)?);+ fids += 1;+ }+ }+ }+ // rebuild: the header, the renamed net table, the copies, the panel+ let mut root_kids: Vec<Node> = Vec::new();+ let lead = kids.iter().find(|c| c.is_list()).map(|c| c.lead.clone()).unwrap_or_else(|| "\n\t".into());+ let mut decl_done = false;+ for k in &kids {+ let head = k.head();+ if head == Some("net") {+ if !decl_done {+ root_kids.push(k.clone()); // net 0+ for mut d in new_decls.drain(..) {+ d.lead = lead.clone();+ root_kids.push(d);+ }+ decl_done = true;+ }+ continue;+ }+ if !k.is_list() || head.map(|h| HEADER.contains(&h)).unwrap_or(true) {+ root_kids.push(k.clone());+ }+ }+ for mut it in out_items.into_iter().chain(panel_items) {+ it.lead = lead.clone();+ root_kids.push(it);+ }+ if let Some(r) = doc.root.children_mut() {+ *r = root_kids;+ }+ let text_out = doc.to_text();+ Ok((text_out, json!({+ "boards": o.rows * o.cols, "rows": o.rows, "cols": o.cols,+ "boardSizeMm": [w, h], "panelSizeMm": [pw, ph + 2.0 * rail], "railMm": rail,+ "separation": "vscore", "vCuts": vcuts, "vCutLayer": "Cmts.User", "toolingHoles": holes, "fiducials": fids,+ "netsRenamed": all_names.iter().filter(|x| !x.is_empty()).count() * o.rows * o.cols,+ })))+}++#[cfg(test)]+mod tests {+ use super::*;++ fn fixture(name: &str) -> String {+ std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(name)).unwrap()+ }++ #[test]+ fn a_rectangular_board_panelises_with_separate_nets() {+ let t = fixture("tests/fixtures/konnect-route/route-fixture.kicad_pcb");+ let (out, rep) = panelize(&t, &Opts { rows: 2, cols: 3, rail_mm: 5.0, holes: true, fiducials: true }).unwrap();+ assert_eq!(rep["boards"], 6);+ let doc = Document::parse(&out).unwrap();+ let fps = doc.root.children().iter().filter(|c| c.head() == Some("footprint")).count();+ let src = Document::parse(&t).unwrap().root.children().iter().filter(|c| c.head() == Some("footprint")).count();+ assert_eq!(fps, src * 6 + 6, "six copies plus three holes and three fiducials");+ assert!(out.contains("\"B6-GND\"") && out.contains("\"B1-GND\""), "nets renamed per copy");+ assert!(!out.contains("(net \"GND\")"), "no copy keeps the shared name");+ // ids unique across the panel+ let ids: Vec<&str> = out.match_indices("(uuid \"").map(|(i, _)| &out[i + 7..i + 43]).collect();+ let set: std::collections::HashSet<&&str> = ids.iter().collect();+ assert_eq!(set.len(), ids.len(), "every uuid unique");+ // one outline: four Edge.Cuts lines+ let edges = doc.root.children().iter().filter(|c| c.child_value("layer").as_deref() == Some("Edge.Cuts")).count();+ assert_eq!(edges, 4);+ assert!(crate::schematic::Document::parse(&out).is_ok());+ }++ #[test]+ fn a_shaped_board_is_refused() {+ let t = "(kicad_pcb (version 20250907) (gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\")) (gr_arc (start 10 0) (mid 12 5) (end 10 10) (layer \"Edge.Cuts\")) (gr_line (start 10 10) (end 0 10) (layer \"Edge.Cuts\")) (gr_line (start 0 10) (end 0 0) (layer \"Edge.Cuts\")))";+ let e = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true }).unwrap_err();+ assert!(e.contains("arc"), "{e}");+ let notched = "(kicad_pcb (version 20250907) (gr_line (start 0 0) (end 10 0) (layer \"Edge.Cuts\")) (gr_line (start 10 0) (end 10 10) (layer \"Edge.Cuts\")) (gr_line (start 10 10) (end 5 10) (layer \"Edge.Cuts\")) (gr_line (start 5 10) (end 5 8) (layer \"Edge.Cuts\")) (gr_line (start 5 8) (end 0 8) (layer \"Edge.Cuts\")) (gr_line (start 0 8) (end 0 0) (layer \"Edge.Cuts\")))";+ assert!(panelize(notched, &Opts { rows: 1, cols: 2, rail_mm: 5.0, holes: true, fiducials: true }).is_err());+ }++ #[test]+ fn numbered_nets_get_a_table_entry_per_copy() {+ let t = "(kicad_pcb (version 20250907)\n\t(net 0 \"\")\n\t(net 1 \"GND\")\n\t(net 2 \"VCC\")\n\t(footprint \"L:R\" (layer \"F.Cu\") (uuid \"f1\") (at 5 5) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"GND\") (uuid \"p1\")))\n\t(segment (start 1 1) (end 2 1) (width 0.2) (layer \"F.Cu\") (net 2) (uuid \"s1\"))\n\t(gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\") (uuid \"e1\"))\n)\n";+ let (out, _) = panelize(t, &Opts { rows: 1, cols: 2, rail_mm: 0.0, holes: false, fiducials: false }).unwrap();+ assert!(out.contains("(net 1 \"B1-GND\")") && out.contains("(net 4 \"B2-GND\")") && out.contains("(net 5 \"B2-VCC\")"), "{out}");+ assert!(out.contains("(net 4 \"B2-GND\"))") || out.contains("(net 4 \"B2-GND\") (uuid"), "the second copy's pad uses the new number: {out}");+ assert!(out.contains("(start 11 1)"), "the second copy moved 10 mm right: {out}");+ }+}+@@ -1,363 +1,366 @@⋯ 171 unchanged lines ⋯ fn uia_invoke(&self, _hwnd: u64, _name: &str) -> Result<String, String> { nope("uia_invoke") } /// UI Automation: ValuePattern.SetValue on the descendant whose name contains `name`. fn uia_set_value(&self, _hwnd: u64, _name: &str, _text: &str) -> Result<(), String> { nope("uia_set_value") }+ /// Set a dialog's check box (exact caption) on or off; "unchanged:" when it already was.+ fn uia_set_checked(&self, _hwnd: u64, _name: &str, _on: bool) -> Result<String, String> { nope("uia_set_checked") } /// UI Automation: names of descendants matching a substring (bounded). fn uia_find(&self, _hwnd: u64, _name: &str, _limit: usize) -> Result<Vec<String>, String> { nope("uia_find") } fn foreground(&self) -> Result<u64, String> { nope("foreground") }⋯ 183 unchanged lines ⋯ pub fn native() -> &'static dyn Platform { &NATIVE }+@@ -1,228 +1,260 @@⋯ 16 unchanged lines ⋯ //! probe first and refuses a hung window. use uiautomation::core::UICacheRequest;-use uiautomation::patterns::{UIInvokePattern, UIPatternType, UISelectionItemPattern, UIValuePattern};+use uiautomation::patterns::{UIInvokePattern, UIPatternType, UISelectionItemPattern, UITogglePattern, UIValuePattern}; use uiautomation::types::{ControlType, Handle, TreeScope, UIProperty}; use uiautomation::variants::Variant; use uiautomation::{UIAutomation, UIElement};⋯ 180 unchanged lines ⋯ Err(format!("control_not_found: no UIA button {labels:?} on {} (buttons seen: {seen:?})", hwnd_u64(dialog))) } +/// Set the check box named exactly `name` (mnemonic stripped, case-insensitive) to `on` through+/// its TogglePattern. Answers "set:<name>=on|off" when it toggled, "unchanged:<name>" otherwise.+pub fn set_checked(h: HWND, name: &str, on: bool) -> Result<String, String> {+ require_responsive(h, "uia_set_checked", 500)?;+ let _com = Com::init()?;+ let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;+ let root = root_of(&ua, h)?;+ let req = cache(&ua)?;+ let cond = ua+ .create_property_condition(UIProperty::ControlType, Variant::from(ControlType::CheckBox as i32), None)+ .map_err(|e| format!("UIA condition: {e}"))?;+ let all = root.find_all_build_cache(TreeScope::Descendants, &cond, &req).map_err(|e| format!("UIA FindAll(CheckBox): {e}"))?;+ let want = name.trim().to_lowercase();+ let mut seen = Vec::new();+ for el in all.iter().take(MAX_ELEMENTS) {+ let n = el.get_cached_name().unwrap_or_default();+ if strip_amp(&n).trim().to_lowercase() != want {+ seen.push(n);+ continue;+ }+ let p = el.get_pattern::<UITogglePattern>().map_err(|_| format!("not_toggleable: {n:?} has no TogglePattern"))?;+ let is_on = matches!(p.get_toggle_state().map_err(|e| format!("ToggleState of {n:?}: {e}"))?, uiautomation::types::ToggleState::On);+ if is_on == on {+ return Ok(format!("unchanged:{n}"));+ }+ p.toggle().map_err(|e| format!("Toggle on {n:?}: {e}"))?;+ return Ok(format!("set:{n}={}", if on { "on" } else { "off" }));+ }+ Err(format!("control_not_found: no check box {name:?} on {} (check boxes seen: {seen:?})", hwnd_u64(h)))+}+ /// Names of descendants containing `name` (all named descendants when empty), bounded. pub fn find_names(h: HWND, name: &str, limit: usize) -> Result<Vec<String>, String> { require_responsive(h, "uia_find", 500)?;⋯ 16 unchanged lines ⋯ } Ok(out) }+@@ -1,517 +1,521 @@⋯ 198 unchanged lines ⋯ fn uia_invoke(&self, h: u64, name: &str) -> Result<String, String> { win::uia::invoke(live(h)?, name) }+ fn uia_set_checked(&self, h: u64, name: &str, on: bool) -> Result<String, String> {+ win::uia::set_checked(live(h)?, name, on)+ } fn uia_set_value(&self, h: u64, name: &str, text: &str) -> Result<(), String> { win::uia::set_value(live(h)?, name, text) }⋯ 310 unchanged lines ⋯ let _ = std::fs::remove_dir_all(&dir); } }+@@ -1,1511 +1,1521 @@⋯ 189 unchanged lines ⋯ **{v: dict(phase=9, scenario=True) for v in ("copy_footprint", "rename_footprint", "xcopy_footprint", "remove_footprint", "add_body", "clone_symbol", "rename_symbol", "set_params", "set_visibility", "symbol_prims", "place_on_sch", "doc_params", "sheet_params", "part_parameters", "layers", "silk_state", "ripup_nets", "compile", "list_installed_libraries", "replicate_layout", "diff_pairs")},+ "teardrops": dict(phase=8, scenario=True),+ "panelize": dict(phase=8, scenario=True), # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files "add_footprint": dict(phase=8, scenario=True), "add_mounting_hole": dict(phase=8, scenario=True),⋯ 995 unchanged lines ⋯ r = K("validate_for_fab", {"filePath": B, "fab": "jlcpcb"}, 300); rec("validate_for_fab", r, ok(r)) r = K("estimate_cost", {"filePath": B, "quantities": [5, 50]}); rec("estimate_cost", r, ok(r)) r = K("set_layer_count", {"filePath": w["offline"], "count": 4}); rec("set_layer_count", r, ok(r))+ # 1.0.66: a V-score panel (a shaped board is refused cleanly: that needs mouse bites)+ r = K("panelize", {"pcbPath": B, "panelPath": OUT + "/panel/panel.kicad_pcb", "rows": 2, "cols": 2, "overwrite": True}, 300)+ rec("panelize", r, (ok(r) and (r.get("drc") or {}).get("newErrors", 1) <= 0) or r.get("errorCode") == "not_rectangular", "the panel failed or adds DRC errors beyond its copies") # 8.4 the live board (a second scratch copy, never saved over a fixture) print("[8.4] live board edits")⋯ 47 unchanged lines ⋯ # 1.0.65: a footprint the board does not have is built from the library text and created live r = E("add_footprint", {"libraryId": "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical", "x": round(cx, 2), "y": round(cy + 5, 2), "reference": "J", "value": "HDR2", "validate": False}) record("add_footprint_built_live", r, ok(r) and r.get("commit") == "create-items" and r.get("verified") is True, "a footprint the board lacks was not built and created live")+ # 1.0.66: teardrops through KiCad's own Edit Teardrops dialog, added then removed again+ r = E("teardrops", {"filePath": L2}, 240)+ r2 = E("teardrops", {"filePath": L2, "remove": True}, 240) if ok(r) else {}+ record("teardrops", {"add": r, "remove": r2}, ok(r) and ok(r2) and (r.get("after") or 0) >= (r.get("before") or 0) and r2.get("after") == 0, "teardrops were not added and removed again") r = E("add_mounting_hole", {"x": round(cx, 2), "y": round(cy, 2), "drill": 2.2, "dryRun": True}, chain=False) rec("add_mounting_hole", r, ok(r) or refused_cleanly(r), "no answer") r = E("board_outline", {"shape": "rectangle", "width": 70, "height": 60, "x": 100, "y": 125, "replace": True, "dryRun": True}, chain=False)⋯ 259 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+@@ -1,109 +1,117 @@⋯ 82 unchanged lines ⋯ `export_ipc2581`, `export_ipc356`, `export_gencad`, `export_dxf`, `validate_for_fab` (the standard's `fab_check`), `estimate_cost`. +From 1.0.66:+- `teardrops`: adds or removes teardrops with KiCad's own Edit Teardrops dialog on the board open in the PCB editor,+ counting them before and after and returning the new revision.+- `panelize`: steps a rectangular board into a V-score panel as a new board, with nets renamed per copy, rails,+ V-cuts on Cmts.User, tooling holes and fiducials. Its KiCad DRC is compared with the board's own, so `newErrors`+ counts only what the panel adds. A shaped board needs mouse bites (KiKit).+ ## The EDA Bridge Standard's shapes (from 1.0.63) These verbs are in the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bridge-standard) (0.3.0), so an AI⋯ 18 unchanged lines ⋯ `prices` per quantity. Every verb here runs in the verb battery on a real Windows box before each release.+
Comments
Log in to comment.
No comments yet.