KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
Bind 3D evidence to its source editor and expose native pan #5
Bind 3D viewer discovery to the requested source editor, refuse stale explicit HWND/document fallback, and expose native pan actions through dynamically discovered Move Board menus. This addresses issues #99/#102 and the pan portion of #105. The canvas probe now scans the full sample instead of accepting its first thirteen colors and rejects near-black noise (#100); it remains a heuristic, not geometry qualification.
Validation: cargo test --workspace --lib --bins passes, including new source/owner/ambiguity and black-noise regressions. Full integration suite reaches schematic_edit but eight tests fail because the exported repository fixture lookup expects tour-pack under rust/ rather than the repository root. No shared bridge runtime was replaced. Windows multi-editor live acceptance is requested before release. Reference/region framing and model/zone/silkscreen primitives remain separate work.
Diff Skip to comments (1)
@@ -1,3133 +1,3125 @@⋯ 113 unchanged lines ⋯ mechanism: Mechanism::Window, risk: "input", timeout_sec: 120, input: "{\"action\": \"zoom_in|zoom_out|fit|rotate_x_cw|rotate_x_ccw|rotate_y_cw|rotate_y_ccw|rotate_z_cw|rotate_z_ccw|view_top|view_bottom|view_front|view_back|view_left|view_right|flip|tour|actions\", \"count\"?: 3, \"pauseMs\"?: 700, \"hwnd\"?: 123, \"steps\"?: [[\"rotate_z_cw\", 8], [\"zoom_in\", 3]]}", example: "kicad_3d_view {\"action\":\"rotate_z_cw\",\"count\":8,\"pauseMs\":700}",- hint: "Every action is a View-menu command fired on the 3D Viewer window by WM_COMMAND: no focus, no cursor, works with the viewer in the background (a window recording of it still sees every frame). count repeats the action with pauseMs between, so a slow orbit is one call. action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list. Open the viewer first with kicad_open_3d_viewer. The viewer has no menu command for panning; zoom about the centre and rotate instead.",- related: &["kicad_open_3d_viewer", "kicad_send_key", "kicad_screenshot_all", "kicad_model_check"],- pitfalls: &["the viewer must be open (kicad_open_3d_viewer)", "a huge board takes seconds per redraw; keep pauseMs at 500 or more"],- },- Verb {- name: "kicad_open_3d_viewer",- summary: "Open the 3D board viewer for a .kicad_pcb.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 300,- input: "{\"editor\"?: \"auto\"|\"fp\"|\"pcb\", \"pcbHwnd\"?: 123, \"fpHwnd\"?: 123, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",- example: "kicad_open_3d_viewer {\"editor\":\"pcb\"}",- hint: "Opens the 3D viewer from an open PCB Editor (or Footprint Editor) through its View menu, in the background. Follow with screenshot_all to capture it.",- related: &["kicad_open_board", "kicad_screenshot_all", "kicad_close_3d_viewer"],- pitfalls: &["3D models only render if the footprints reference installed 3D model files", "the viewer takes a few seconds to render; wait before screenshotting"],- },- Verb {- name: "kicad_open_editors",- summary: "List currently open KiCad editor windows.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,- input: "{}",- example: "kicad_open_editors {}",- hint: "Read-only INVENTORY (despite the name): lists editors inside a running KiCad. It never launches anything; to cold-start KiCad use kicad_launch.",- related: &["kicad_launch", "kicad_window_info", "kicad_screenshot_all", "kicad_close"],- pitfalls: &["with KiCad closed it succeeds with 0 editors; that is not an error, kicad_launch is the verb that starts KiCad"],- },- Verb {- name: "kicad_close",- summary: "Close KiCad (editors first, then the project manager).",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,- input: "{\"force\"?: bool, \"discardChanges\"?: bool}",- example: "kicad_close {}",- hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. Read the kicad-interaction skill before blind-closing.",- related: &["kicad_open_editors", "kicad_window_info"],- pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently"],- },- Verb {- name: "kicad_close_symbol_editor",- summary: "Close the Symbol Editor window.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,- input: "{}", example: "kicad_close_symbol_editor {}",- hint: "Closes the Symbol Editor if open; no-op otherwise.",- related: &["kicad_open_symbol_editor", "kicad_close"],- pitfalls: &["unsaved edits may prompt; save first if you made changes"],- },- Verb {- name: "kicad_close_footprint_editor",- summary: "Close the Footprint Editor window.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,- input: "{}", example: "kicad_close_footprint_editor {}",- hint: "Closes the Footprint Editor if open; no-op otherwise.",- related: &["kicad_open_footprint_editor", "kicad_close"],- pitfalls: &["unsaved edits may prompt; save first"],- },- Verb {- name: "kicad_close_3d_viewer",- summary: "Close the 3D viewer window.",- mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,- input: "{}", example: "kicad_close_3d_viewer {}",- hint: "Closes the 3D viewer if open; no-op otherwise.",- related: &["kicad_open_3d_viewer", "kicad_close"],- pitfalls: &[],- },- Verb {- name: "kicad_dismiss_dialogs",- summary: "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).",- mechanism: Mechanism::Window, risk: "input", timeout_sec: 60,- input: "{\"all\"?: bool, \"screenshot\"?: true, \"forceSoftwareCanvas\"?: bool, \"debug\"?: bool}",- example: "kicad_dismiss_dialogs {\"all\":true}",- hint: "Read-only-ish self-heal: clicks each dialog's OK/default button. window_info also auto-expires benign dialogs; use this to force-clear a specific/all dialogs before retrying an open/screenshot.",- related: &["kicad_window_info", "kicad_screenshot_all", "kicad_open_board"],- pitfalls: &["benign-only by default; pass {\"all\":true} to also clear real dialogs you have already read", "a NON-benign dialog left up may indicate a real error; read its body first", "progress dialogs (Load PCB, Loading Symbol Libraries) are never dismissed: their one button is Cancel"],- },- Verb {- name: "kicad_window_info",- summary: "Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs).",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 60,- input: "{\"autoDismiss\"?: true}",- example: "kicad_window_info {}",- hint: "Read-only; returns each KiCad window's position/size/state. Use before click/send_key to target the right window.",- related: &["kicad_open_editors", "kicad_click", "kicad_send_key"],- pitfalls: &["coordinates are screen-absolute; re-read after a window moves/resizes"],- },- Verb {- name: "kicad_state",- summary: "KiCad's full state of the union: every owned window as inline base64 PNG + logical state.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,- input: "{\"includeImages\"?: true, \"maxWidth\"?: 1100}",- example: "kicad_state {\"includeImages\":false}",- hint: "Read-only, non-gated. shots[] = base64 screenshot of EVERY KiCad window (render all of them; one window is not the app); plus editors inventory, modal dialogs, running flag. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll; maxWidth caps image size.",- related: &["kicad_screenshot_all", "kicad_open_editors", "kicad_window_info"],- pitfalls: &["shots are inline base64; the response is large, use includeImages:false when you only need the logical state"],- },- Verb {- name: "kicad_screenshot_all",- summary: "Screenshot every open KiCad window.",- mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,- input: "{}",- example: "kicad_screenshot_all {}",- hint: "Captures all KiCad windows through ab's desktop_screenshot_window (background, owned popups included, coordMap per image); the go-to way to VERIFY what actually happened after an open/edit. canvas.rendered says whether the GL canvas actually painted.",- related: &["kicad_open_editors", "kicad_window_info", "kicad_open_board"],- pitfalls: &["a window still rendering (e.g. 3D viewer) shows blank; wait a beat first", "with ab unreachable no image is written (abUnavailable:true); only the canvas probe answers"],- },- Verb {- name: "kicad_send_key",- summary: "Send a key to KiCad, or fire a menu item by label; chords and menu labels resolve to menu commands (background)",- mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,- input: "{\"key\"?: \"enter\"|\"escape\"|\"a\"|\"ctrl+s\", \"menu\"?: \"Plugin and Content Manager\", \"hwnd\"?: 123}",- example: "kicad_send_key {\"hwnd\":123456,\"key\":\"escape\"}",- hint: "menu: a label substring fired as a menu command on the target window without touching focus (e.g. menu:\"Plugin and Content Manager\" on the project manager opens the PCM dialog; the dialog is a child, kicad_screenshot_all captures it). For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use Bridge's desktop_press_key; it owns full chord parsing; do not route chords here. Use this verb only for the case Bridge's focus-first model cannot cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",- related: &["kicad_window_info", "kicad_click", "kicad_fix_keyboard"],- pitfalls: &["not for chords; use desktop_press_key", "needs a target hwnd (from window_info)", "allowForeground (real keystrokes) is not in the native build"],- },- Verb {- name: "kicad_click",- summary: "Click at coordinates inside a KiCad window.",- mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,- input: "{\"hwnd\": 123, \"x\": 0.5, \"y\": 0.5, \"relative\"?: true}",- example: "kicad_click {\"hwnd\":123456,\"x\":0.5,\"y\":0.5}",- hint: "Posts the click to the window's own queue (background: the user's mouse never moves); read window_info + screenshot_all first to compute coordinates. relative:true (default) takes fractions 0.0-1.0 of the window.",- related: &["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"],- pitfalls: &["coordinates are window-relative and break if the window resized; re-read window_info", "allowForeground (a real cursor click) is not in the native build"],- },- Verb {- name: "kicad_fix_keyboard",- summary: "Reset stuck modifier keys on a KiCad window.",- mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,- input: "{}", example: "kicad_fix_keyboard {}",- hint: "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave. The native build never sends global keystrokes, so there is never anything to fix.",- related: &["kicad_send_key"],- pitfalls: &[],- },- Verb {- name: "kicad_place_footprint",- summary: "Deterministically place a footprint into a board preview.",- mechanism: Mechanism::Window, risk: "write", timeout_sec: 130,- input: "{\"fileName\": \"X.kicad_mod\", \"fileContent\": \"<base64>\", \"footprintName\"?: \"X\", \"x\"?: 148, \"y\"?: 105, \"open\"?: true}",- example: "kicad_place_footprint {\"fileName\":\"BMI423.kicad_mod\",\"fileContent\":\"KGZvb3RwcmludCAu...\"}",- hint: "Splices a footprint into a fresh preview board via s-expr (no GUI edit) and opens it in pcbnew in the background; good for previews.",- related: &["kicad_open_board", "kicad_install_footprint"],- pitfalls: &["operates on the file; close the board in KiCad first or the edit races the GUI"],- },- Verb {- name: "kicad_enable_software_opengl",- summary: "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.",- mechanism: Mechanism::File, risk: "write", timeout_sec: 240,- input: "{}", example: "kicad_enable_software_opengl {}",- hint: "Only when KiCad cannot render at all AND there is no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",- related: &["kicad_dismiss_dialogs", "kicad_upgrade"],- pitfalls: &["a real GPU / Hyper-V GPU-P passthrough is far better; this is CPU rendering, slow", "writing KiCad's bin needs an elevated Bridge"],- },- 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"];--/// 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_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.-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 };- if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600)) {- let had_error = swept["hadError"].as_bool().unwrap_or(false);- if had_error {- let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();- out["_dialogHint"] = json!(format!(- "KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.",- swept["count"], titles.join(", ")));- }- out["_autoDialogs"] = swept;- }- // Foreground etiquette (John, 2026-08-14 and 2026-08-16): the ONE sanctioned way a- // KiCad window comes to the front is an explicit foreground flag WITH a reason.- let wants_fg = user_wants_foreground(args);- let success = out.get("success").and_then(Value::as_bool).unwrap_or(true);- if wants_fg && spawns {- std::thread::sleep(Duration::from_secs(1)); // let the window exist- let reason = arg_str(args, "foregroundReason").or_else(|| arg_str(args, "reason")).unwrap_or("").trim().to_string();- let mut shown: Option<u64> = None;- if !reason.is_empty() {- if let Some(h) = verb_window(command, out) {- if bring_to_user(h, &reason) {- shown = Some(h);- }- }- }- out["_broughtToUser"] = json!(shown.is_some());- out["windowEtiquette"] = json!(if shown.is_some() { "foreground" } else { "background" });- if shown.is_none() {- out["errorCode"] = json!("foreground_reason_required");- out["_foregroundRefused"] = json!("foreground requested without foregroundReason - pass foregroundReason:'<why the user needs to see this>' and the bridge will caption it for 3s, then foreground.");- out["_hint"] = json!("errorCode foreground_reason_required: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'.");- }- } else if spawns && success {- let o = out.as_object_mut().unwrap();- o.entry("windowEtiquette").or_insert(json!("background"));- o.entry("openedInBackground").or_insert(json!(true));- o.entry("_foregroundHint").or_insert(json!("Window opened in the BACKGROUND - the user's foreground was not touched. To show it to the user: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'. The bridge captions the reason via an ab toast for 3 seconds, then brings the window forward. Foreground without a reason is refused."));- }- // One-shot focus check (the phase 4 guardian's stand-in): did a KiCad window take the- // foreground from a user window during this verb? Push it back once, and say so.- let mut events = Vec::new();- if !(wants_fg && out["_broughtToUser"] == json!(true)) {- // 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> {- 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))-}--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})- }- fn from_info(w: &WindowInfo) -> Win {- Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: None, owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }- }-}--struct Cache {- at: Option<Instant>,- rows: Vec<Win>,- source: &'static str,-}--static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });-const CACHE_TTL: Duration = Duration::from_millis(250);--/// Call after anything that opens, closes, or retitles a KiCad window.-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).map(Win::from_info).collect(), "local:EnumWindows"))- }- }-}--/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.-fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {- let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;- let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);- if stale {- let (rows, src) = list_all_windows()?;- c.rows = rows;- c.source = src;- c.at = Some(Instant::now());- }- let src = c.source;- if all_windows {- return Ok((c.rows.clone(), src));- }- // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).- let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();- let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();- Ok((rows, src))-}--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> {- let before = fg_before?;- if is_kicad_hwnd(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).- let restored = ab::desktop_restore_user_window(before, "restore the user's window after a KiCad window raised itself").is_ok()- || matches!(native().bring_to_front(before), Ok(true));- 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;- }- 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, "at": kicad_common::now_utc_iso()}));- ok-}--// ── 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;- }- if wm::looks_like_global_dialog(&w.title, &w.class_name) {- out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });- seen.push(w.hwnd);- }- }- out-}--fn find_save_prompt() -> Option<Dialog> {- scan_dialogs().into_iter().find(|d| wm::is_save_prompt(&d.title, &d.body))-}--/// Dismiss a dialog: a known dismiss button (OK for info boxes, Cancel/Finish for-/// wizards) through a time-boxed click, then Escape + WM_CLOSE. All posted to the exact-/// hwnd, no focus theft.-fn dismiss_dialog(hwnd: u64) -> String {- for tier in wm::DISMISS_BUTTON_TIERS {- if native().click_button(hwnd, tier).is_ok() {- invalidate();- return "clicked-button".into();- }- }- let _ = native().post_key(hwnd, wm::VK_ESCAPE, None);- let _ = native().post_close(hwnd);- invalidate();- "escape+WM_CLOSE".into()-}--/// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed.-fn screenshot_dialog(hwnd: u64, title: &str) -> String {- ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default()-}--/// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops-/// attempting OpenGL on this machine. Called only right after dismissing the "could not-/// use OpenGL" notice, which is PROOF OpenGL is unavailable here.-fn force_software_canvas(version: &str) -> bool {- let Some(root) = native().config_root() else { return false };- let mut targets: Vec<PathBuf> = Vec::new();- if !version.is_empty() {- let d = root.join(kicad_platform::major_minor(version));- if d.is_dir() {- targets.push(d.join("kicad_common.json"));- }- }- if targets.is_empty() {- targets = kicad_common::common_files(&root);- if targets.is_empty() && !version.is_empty() {- targets.push(root.join(kicad_platform::major_minor(version)).join("kicad_common.json"));- }- }- let mut changed = false;- for cfg in targets {- let mut doc = std::fs::read_to_string(&cfg).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));- if !wm::force_software_canvas_doc(&mut doc) {- continue;- }- if let Some(p) = cfg.parent() {- let _ = std::fs::create_dir_all(p);- }- if let Ok(text) = serde_json::to_string_pretty(&doc) {- if std::fs::write(&cfg, text).is_ok() {- changed = true;- }- }- }- changed-}--/// close_windows._win_sweep_dialogs: expire the benign dialogs (or ALL dialogs with-/// dismiss_all), looping because dismissing one often spawns the next.-fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value {- let mut dismissed = Vec::new();- let mut remaining = Vec::new();- let mut forced = false;- let mut shot: Vec<u64> = Vec::new();- let deadline = Instant::now() + Duration::from_secs(25);- for _pass in 0..5 {- if Instant::now() > deadline {- break;- }- remaining.clear();- let mut acted = false;- for d in scan_dialogs() {- match wm::sweep_decision(&d.title, &d.body, dismiss_all) {- DialogAction::LeaveProgress => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body, "progress": true})),- DialogAction::Dismiss { benign } => {- let mut entry = json!({"title": d.title, "body": d.body});- if screenshot && !shot.contains(&d.hwnd) {- entry["screenshot"] = json!(screenshot_dialog(d.hwnd, &d.title));- shot.push(d.hwnd);- }- entry["action"] = json!(dismiss_dialog(d.hwnd));- dismissed.push(entry);- acted = true;- if benign && !forced {- forced = force_software_canvas(&info.version);- }- }- DialogAction::Leave => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})),- }- }- if !acted {- break;- }- sleep_ms(400);- }- json!({"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced})-}--/// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late-/// dialog, screenshots and reads each one found, expires it (never a progress dialog),-/// and returns None when nothing was ever seen.-fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration) -> Option<Value> {- let rounds = settle_rounds.max(1);- let mut found = Vec::new();- for r in 0..rounds {- invalidate();- found = scan_dialogs();- if !found.is_empty() {- break;- }- if r + 1 < rounds {- std::thread::sleep(settle_gap);- }- }- if found.is_empty() {- return None;- }- let mut dialogs = Vec::new();- let mut had_error = false;- let mut seen: Vec<u64> = Vec::new();- let deadline = Instant::now() + Duration::from_secs(25);- for _pass in 0..4 {- if Instant::now() > deadline {- break;- }- let mut acted = false;- invalidate();- for d in scan_dialogs() {- if seen.contains(&d.hwnd) {- continue;- }- seen.push(d.hwnd);- match wm::auto_sweep_decision(&d.title, &d.body) {- DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),- DialogAction::Dismiss { benign } => {- if !benign {- had_error = true;- }- let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };- let action = dismiss_dialog(d.hwnd);- dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot}));- acted = true;- }- DialogAction::Leave => {}- }- }- if !acted {- break;- }- sleep_ms(350);- }- if dialogs.is_empty() {- return None;- }- Some(json!({"count": dialogs.len(), "dialogs": dialogs, "hadError": had_error}))-}--/// The rows `kicad_errors` reads (bridge_log::DialogRow), for the misc group to consume-/// once kicad_errors moves to a group that runs after this one.-pub fn dialog_rows() -> Vec<bridge_log::DialogRow> {- scan_dialogs().into_iter().map(|d| {- let texts: Vec<String> = native().child_windows(d.hwnd).map(|c| c.iter().filter(|c| c.class_name != "Button").map(|c| c.title.clone()).filter(|t| !t.trim().is_empty()).collect()).unwrap_or_default();- bridge_log::DialogRow { hwnd: d.hwnd, title: d.title, texts, uia_text: None }- }).collect()-}--// ── Menu walk, UIA, posted keys: the rungs ────────────────────────────────────--/// Resolve a menu item whose label contains `needle` from the window's live menu bar and-/// fire it with WM_COMMAND. Ok(Some((id, label))) when fired, Ok(None) when no such item.-fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>, String> {- let items = native().menu_tree(hwnd)?;- let want = needle.to_lowercase();- // 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('&', ""))));- }- for it in items {- let Some(label) = it.path.last() else { continue };- if clean(label).contains(&want) && it.id != 0 {- native().menu_invoke(hwnd, it.id)?;- invalidate();- return Ok(Some((it.id, label.replace('&', ""))));- }- }- Ok(None)-}--/// win_menu.find_by_accelerator: a chord to the menu command that carries it.-fn menu_invoke_by_accel(hwnd: u64, chord: &str) -> Result<Option<(u32, String)>, String> {- let items = native().menu_tree(hwnd)?;- let want = wm::norm_accel(chord);- for it in items {- if let Some(a) = &it.accel {- if wm::norm_accel(a) == want && it.id != 0 {- native().menu_invoke(hwnd, it.id)?;- invalidate();- let label = it.path.last().map(|l| l.replace('&', "")).unwrap_or_default();- return Ok(Some((it.id, label)));- }- }- }- Ok(None)-}--/// Poll for a window of `kind` until `deadline`; `repost` fires once at `repost_after`.-fn wait_for_kind(kind: WindowKind, deadline: Instant, repost: Option<(u64, u32)>, repost_after: Duration) -> Option<u64> {- let t0 = Instant::now();- let mut reposted = false;- loop {- invalidate();- if let Some(h) = first_of_kind(kind) {- return Some(h);- }- if Instant::now() >= deadline {- return None;- }- if !reposted && t0.elapsed() > repost_after {- if let Some((host, id)) = repost {- let _ = native().menu_invoke(host, id);- }- reposted = true;- }- sleep_ms(500);- }-}--/// Every descendant (class, text, rect, visible) of a window with its parent handle.-/// The platform's `child_windows` returns the whole subtree with `owner` carrying the-/// PARENT handle (the Windows implementation is built for exactly this join).-#[derive(Clone, Debug)]-struct Child {- hwnd: u64,- class_name: String,- text: String,- rect: (i32, i32, i32, i32),- visible: bool,- parent: u64,-}--fn descendants(root: u64) -> Vec<Child> {- let Ok(kids) = native().child_windows(root) else { return Vec::new() };- kids.iter().map(|k| Child { hwnd: k.hwnd, class_name: k.class_name.clone(), text: k.title.clone(), rect: k.rect, visible: k.visible, parent: if k.owner == 0 { root } else { k.owner } }).collect()-}--/// The library tree's filter Edit: class "Edit" under a parent whose text is "searchCtrl".-fn find_search_edit(frame: u64) -> Option<Child> {- let all = descendants(frame);- all.iter().find(|c| c.class_name == "Edit" && all.iter().any(|p| p.hwnd == c.parent && p.text == "searchCtrl")).cloned()-}--static TREE_TOGGLED: Mutex<Vec<(u64, Instant)>> = Mutex::new(Vec::new());--/// The tree search control, showing the Library Tree pane through the frame's own View-/// menu when the user has it hidden (toggle at most once per editor per minute).-fn ensure_tree_search(frame: u64, trace: &mut Vec<String>) -> Option<Child> {- if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {- return Some(e);- }- let may_toggle = TREE_TOGGLED.lock().map(|g| !g.iter().any(|(h, t)| *h == frame && t.elapsed() < Duration::from_secs(60))).unwrap_or(true);- if may_toggle {- match menu_invoke_by_text(frame, "library tree") {- Ok(Some((id, _))) => {- trace.push(format!("library tree pane was hidden: View menu toggled (id {id})"));- if let Ok(mut g) = TREE_TOGGLED.lock() {- g.retain(|(h, _)| *h != frame);- g.push((frame, Instant::now()));- }- }- Ok(None) => trace.push("search Edit not visible and no 'Library Tree' menu item found".into()),- Err(e) => trace.push(format!("search Edit not visible; menu walk failed: {e}")),- }- }- for _ in 0..16 {- if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {- return Some(e);- }- sleep_ms(250);- }- None-}--/// The rung used to put text in the filter box: "uia" (ValuePattern) else "post" (WM_SETTEXT).-fn set_filter_text(edit: u64, text: &str) -> Result<&'static str, String> {- if native().uia_set_value(edit, "", text).is_ok() {- return Ok("uia");- }- native().set_text(edit, text).map(|_| "post")-}--fn post_vk(hwnd: u64, vk: u16) {- let _ = native().post_key(hwnd, vk, None);-}--/// Which editor title check applies during navigation.-#[derive(Clone, Copy)]-enum Verify<'a> {- Symbol { name: &'a str, library: &'a str },- Footprint { name: &'a str, library: &'a str },- Never,-}--fn verify_title(title: &str, v: Verify<'_>) -> bool {- match v {- Verify::Symbol { name, library } => wm::title_confirms_symbol(title, name, library),- Verify::Footprint { name, library } => wm::title_confirms_footprint(title, name, library),- Verify::Never => false,- }-}--/// The plugin-free BACKGROUND library-tree load (0.9.83/0.9.89, wiki #71): text into the-/// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates,-/// Down x1..3 then Enter, a posted double-click on the first row. Zero focus.-/// Returns (confirmed, trace, mechanism).-fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {- let mut steps: Vec<String> = Vec::new();- let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});- let mut mechanism = "post";- let Some(edit) = ensure_tree_search(frame, &mut steps) else {- steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());- trace["steps"] = json!(steps);- return (false, trace, mechanism);- };- trace["editHwnd"] = json!(edit.hwnd);- match set_filter_text(edit.hwnd, filter) {- Ok(m) => {- mechanism = m;- steps.push(format!("filter text set via {m}"));- }- Err(e) => {- steps.push(format!("could not set the filter text: {e}"));- trace["steps"] = json!(steps);- return (false, trace, mechanism);- }- }- sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild- // Down THROUGH the filter box: KiCad forwards nav keys to the tree.- post_vk(edit.hwnd, wm::VK_DOWN);- sleep_ms(500);- steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));- // Tree candidates: tall siblings under the Libraries pane (wx generic controls share- // bland class names, so geometry is the discriminator) plus any wxDataView.- let all = descendants(frame);- let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);- let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);- let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();- trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());- let title_ok = || verify_title(&title_of(frame), verify);- // Round 1: Enter posted straight to each tree candidate.- for c in &cands {- post_vk(c.hwnd, wm::VK_RETURN);- }- for _ in 0..6 {- sleep_ms(700);- if title_ok() {- steps.push("VK_RETURN to tree candidates -> title confirms".into());- trace["steps"] = json!(steps);- return (true, trace, mechanism);- }- }- steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));- // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside- // the tree: Down (first lands on the library node, second on the symbol row), then Enter.- for downs in 1..=3 {- for c in &cands {- post_vk(c.hwnd, wm::VK_DOWN);- }- sleep_ms(400);- for c in &cands {- post_vk(c.hwnd, wm::VK_RETURN);- }- for _ in 0..5 {- sleep_ms(600);- if title_ok() {- steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));- trace["steps"] = json!(steps);- return (true, trace, mechanism);- }- }- steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));- }- // Round 2: a posted double-click on the first visible row (just under the header).- for c in &cands {- let x = (c.rect.2 / 3).max(10);- let y = 58;- let _ = native().post_click(c.hwnd, x, y, false);- let _ = native().post_click(c.hwnd, x, y, true);- }- for _ in 0..8 {- sleep_ms(700);- if title_ok() {- steps.push("posted double-click on first row -> title confirms".into());- trace["steps"] = json!(steps);- return (true, trace, mechanism);- }- }- steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));- trace["steps"] = json!(steps);- (false, trace, mechanism)-}--// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────--struct Budget {- t0: Instant,- limit: Duration,-}--impl Budget {- fn new(args: &Value) -> Budget {- let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);- Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }- }- fn out(&self) -> bool {- self.t0.elapsed() > self.limit- }- fn elapsed_sec(&self) -> u64 {- self.t0.elapsed().as_secs()- }-}--struct OpenSpec {- kind: WindowKind,- /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").- menu_needle: &'static str,- uia_name: &'static str,- /// Frames whose menu bar / UIA tree carries the launcher, in preference order- /// (a running editor of the same family first, the project manager last).- hosts: &'static [WindowKind],- error_code: &'static str,- starting_code: &'static str,- label: &'static str,-}--const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };-const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };--/// The early-return contract when a slow cold start would outlive the transport budget-/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.-fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {- invalidate();- if first_of_kind(spec.kind).is_none() {- return json!({- "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,- "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,- "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),- "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),- });- }- json!({- "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",- "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),- "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),- "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",- })-}--/// Make sure the project manager exists (spawn kicad.exe in the background if not).-fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {- invalidate();- if let Some(h) = find_project_manager() {- return Ok(h);- }- let Some(exe) = kicad_exe(info, "kicad") else {- return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));- };- spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;- pathways.push("spawned-project-manager".into());- let deadline = Instant::now() + Duration::from_secs(10);- loop {- sleep_ms(500);- invalidate();- if let Some(h) = find_project_manager() {- sleep_ms(2000);- return Ok(h);- }- if Instant::now() > deadline {- return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));- }- }-}--/// Open an editor frame: an existing one wins; else the native menu walk on each host-/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).-fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {- 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)) {- return json!({- "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",- "output": format!("{label} editor already has {name} open"),- "_hint": "The existing window was reused, not reloaded. kicad_close first if you need a fresh load from disk.",- });- }- let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {- Ok(x) => x,- Err(mut e) => {- if e["errorCode"] != json!("not_supported_on_platform") {- e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));- e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");- }- return e;- }- };- // Verify by window title with one deadline (no nested sleeps).- let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);- let deadline = Instant::now() + Duration::from_secs_f64(wait_s);- let mut appeared: Option<Win> = None;- while Instant::now() < deadline {- invalidate();- let wins = windows_of_kind(kind);- appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();- if appeared.is_some() {- break;- }- if native().process_alive(pid) == Some(false) {- break;- }- sleep_ms(500);- }- let hint = format!(- "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."- );- let mut out = json!({- "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),- "output": format!("Launched {label} editor with {name}"),- "_hint": hint,- });- match appeared {- Some(w) => {- out["hwnd"] = json!(w.hwnd);- out["title"] = json!(w.title);- out["windowAppeared"] = json!(true);- // 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-}--// ── 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.",- });- merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));- v-}--// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────--fn poll_3d_window(rounds: usize) -> Option<Win> {- for _ in 0..rounds.max(1) {- sleep_ms(750);- invalidate();- if let Some(w) = windows_of_kind(WindowKind::Viewer3d).into_iter().next() {- return Some(w);- }- }- None-}--/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a-/// 3D Viewer window appeared.-fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {- let mut unsupported_menu: Option<Value> = None;- match menu_invoke_by_text(frame, "3d viewer") {- Ok(Some((id, _))) => {- pathways.push(format!("win32menu(id={id})"));- if let Some(w) = poll_3d_window(12) {- return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));- }- }- Ok(None) => pathways.push("win32menu-not-found".into()),- Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),- Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),- }- match native().uia_invoke(frame, "3D Viewer") {- Ok(_) => {- pathways.push("uia".into());- if let Some(w) = poll_3d_window(12) {- return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));- }- }- Err(e) if is_not_impl(&e) => {- // Neither rung exists on this OS: say so instead of "did not open".- if let Some(u) = unsupported_menu {- return Err(u);- }- pathways.push("uia-unsupported".into());- }- Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),- }- Ok(None)-}---/// `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.-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)] = &[- ("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.- return json!({- "success": false, "errorCode": "close_failed",- "error": format!("Failed to close: {}", failed.join(", ")),- "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },- "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows (a kill discards unsaved work silently). kicad_screenshot_all shows whether a dialog is holding the window.",- });- }- let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});- if !discard_log.is_empty() {- out["discard"] = json!(discard_log);- }- out-}--// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────--fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {- let changed = force_software_canvas(&info.version);- return json!({- "success": true, "forcedSoftwareCanvas": changed,- "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },- "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",- });- }- if let Err(e) = find_windows(true, true) {- return e;- }- if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {- let wins = scan_dialogs();- let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();- return json!({- "success": true, "debug": true, "kicadPids": pids,- "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),- "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),- });- }- let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);- let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);- let res = sweep_dialogs(&info, all, screenshot);- let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);- let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);- let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);- json!({- "success": true,- "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),- "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,- "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),- })-}--fn window_info(state: &mut State, args: &Value) -> Value {- let info = state.kicad_info();- if let Err(e) = find_windows(true, true) {- return e;- }- let mut auto_dismissed = json!([]);- let mut forced_sw = false;- if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {- let s = sweep_dialogs(&info, false, false);- auto_dismissed = s["dismissed"].clone();- forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);- }- invalidate();- let (wins, source) = match find_windows(false, true) {- Ok(x) => x,- Err(e) => return e,- };- let mut editors = Vec::new();- let mut dialogs = Vec::new();- let mut manager = Value::Null;- for w in &wins {- let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});- if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {- let mut d = base;- d["ownerHwnd"] = json!(w.owner);- d["body"] = json!(dialog_body_text(w.hwnd));- dialogs.push(d);- } else if wm::is_manager_title(&w.title) {- manager = base;- } else {- editors.push(base);- }- }- let mut data = json!({- "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),- "autoDismissed": auto_dismissed, "windowSource": source,- });- if forced_sw {- data["forcedSoftwareCanvas"] = json!(true);- }- let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);- if n_auto > 0 {- data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));- }- if !dialogs.is_empty() {- data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");- }- json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})-}--// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────--/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.-fn canvas_probe(hwnd: u64) -> Value {- let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };- let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));- wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)-}--/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);-/// the canvas verdict is always ours.-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();- json!({- "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",- "_hint": if ok {- "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."- } else {- "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."- },- })-}--#[cfg(test)]-mod tests {- use super::*;-- #[test]- fn footprint_splice_matches_python() {- let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";- let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();- assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));- let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";- let modern = format!("(footprint{}", &legacy["(module".len()..]);- let q = quote_first_layer(&modern);- assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));- assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");- assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());- let u = pseudo_uuid();- assert_eq!(u.len(), 36);- assert_eq!(&u[14..15], "4");- }-- #[test]- fn base64_round_trip() {- assert_eq!(b64_encode(b""), "");- assert_eq!(b64_encode(b"f"), "Zg==");- assert_eq!(b64_encode(b"fo"), "Zm8=");- assert_eq!(b64_encode(b"foo"), "Zm9v");- assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");- let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();- assert_eq!(back, b"\x89PNG\r\n\x1a\n");- }-- #[test]- fn stamp_and_foreground_args() {- let s = utc_stamp();- assert_eq!(s.len(), 15);- assert_eq!(&s[8..9], "-");- assert!(user_wants_foreground(&json!({"foreground": true})));- assert!(user_wants_foreground(&json!({"showToUser": true})));- assert!(user_wants_foreground(&json!({"bringToFront": true})));- assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));- assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));- assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);- }-- #[test]- fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {- let mut state = State::new();- assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());- assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());- let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();- assert_eq!(fk["success"], json!(true));- assert_eq!(fk["stuckKeys"], json!([]));- // Without window control (this host) the window verbs answer not_supported_on_platform- // or an ab-unavailable error, never a hang and never a fake success.- if !native().capabilities().window_control {- std::env::remove_var("ADOM_DIRECT_API_URL");- let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();- assert_eq!(r["success"], json!(false));- let code = r["errorCode"].as_str().unwrap_or("");- assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");- let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();- assert_eq!(r["success"], json!(false));- let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();- assert_eq!(r["success"], json!(false));- }- let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();- assert_eq!(r["errorCode"], json!("bad_arg"));- let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();- assert_eq!(r["errorCode"], json!("missing_arg"));- let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();- assert_eq!(r["errorCode"], json!("bad_arg"));- }-- #[test]- fn catalog_covers_the_python_window_group() {- let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();- for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {- assert!(names.contains(&n), "{n} missing from the catalog");- }- assert!(!names.contains(&"kicad_errors"));- for v in VERBS {- assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);- }- }-}---/// 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";- 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."});- }- 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});- }- }- 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,- "_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."})-}+ hint: "Every action is a View-menu command fired on the 3D Viewer window by WM_COMMAND: no focus, no cursor, works with the viewer in the background (a window recording of it still sees every frame). count repeats the action with pauseMs between, so a slow orbit is one call. action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list. Open the viewer first with kicad_open_3d_viewer. pan_left, pan_right, pan_up and pan_down discover Move Board menu commands at runtime. Reference framing is not yet available.",+ related: &["kicad_open_3d_viewer", "kicad_send_key", "kicad_screenshot_all", "kicad_model_check"],+ pitfalls: &["the viewer must be open (kicad_open_3d_viewer)", "a huge board takes seconds per redraw; keep pauseMs at 500 or more"],+ },+ Verb {+ name: "kicad_open_3d_viewer",+ summary: "Open the 3D board viewer for a .kicad_pcb.",+ mechanism: Mechanism::Window, risk: "process", timeout_sec: 300,+ input: "{\"editor\"?: \"auto\"|\"fp\"|\"pcb\", \"pcbHwnd\"?: 123, \"fpHwnd\"?: 123, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_open_3d_viewer {\"editor\":\"pcb\"}",+ hint: "Opens the 3D viewer from an open PCB Editor (or Footprint Editor) through its View menu, in the background. Follow with screenshot_all to capture it.",+ related: &["kicad_open_board", "kicad_screenshot_all", "kicad_close_3d_viewer"],+ pitfalls: &["3D models only render if the footprints reference installed 3D model files", "the viewer takes a few seconds to render; wait before screenshotting"],+ },+ Verb {+ name: "kicad_open_editors",+ summary: "List currently open KiCad editor windows.",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,+ input: "{}",+ example: "kicad_open_editors {}",+ hint: "Read-only INVENTORY (despite the name): lists editors inside a running KiCad. It never launches anything; to cold-start KiCad use kicad_launch.",+ related: &["kicad_launch", "kicad_window_info", "kicad_screenshot_all", "kicad_close"],+ pitfalls: &["with KiCad closed it succeeds with 0 editors; that is not an error, kicad_launch is the verb that starts KiCad"],+ },+ Verb {+ name: "kicad_close",+ summary: "Close KiCad (editors first, then the project manager).",+ mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,+ input: "{\"force\"?: bool, \"discardChanges\"?: bool}",+ example: "kicad_close {}",+ hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. Read the kicad-interaction skill before blind-closing.",+ related: &["kicad_open_editors", "kicad_window_info"],+ pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently"],+ },+ Verb {+ name: "kicad_close_symbol_editor",+ summary: "Close the Symbol Editor window.",+ mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+ input: "{}", example: "kicad_close_symbol_editor {}",+ hint: "Closes the Symbol Editor if open; no-op otherwise.",+ related: &["kicad_open_symbol_editor", "kicad_close"],+ pitfalls: &["unsaved edits may prompt; save first if you made changes"],+ },+ Verb {+ name: "kicad_close_footprint_editor",+ summary: "Close the Footprint Editor window.",+ mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+ input: "{}", example: "kicad_close_footprint_editor {}",+ hint: "Closes the Footprint Editor if open; no-op otherwise.",+ related: &["kicad_open_footprint_editor", "kicad_close"],+ pitfalls: &["unsaved edits may prompt; save first"],+ },+ Verb {+ name: "kicad_close_3d_viewer",+ summary: "Close the 3D viewer window.",+ mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+ input: "{}", example: "kicad_close_3d_viewer {}",+ hint: "Closes the 3D viewer if open; no-op otherwise.",+ related: &["kicad_open_3d_viewer", "kicad_close"],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_dismiss_dialogs",+ summary: "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).",+ mechanism: Mechanism::Window, risk: "input", timeout_sec: 60,+ input: "{\"all\"?: bool, \"screenshot\"?: true, \"forceSoftwareCanvas\"?: bool, \"debug\"?: bool}",+ example: "kicad_dismiss_dialogs {\"all\":true}",+ hint: "Read-only-ish self-heal: clicks each dialog's OK/default button. window_info also auto-expires benign dialogs; use this to force-clear a specific/all dialogs before retrying an open/screenshot.",+ related: &["kicad_window_info", "kicad_screenshot_all", "kicad_open_board"],+ pitfalls: &["benign-only by default; pass {\"all\":true} to also clear real dialogs you have already read", "a NON-benign dialog left up may indicate a real error; read its body first", "progress dialogs (Load PCB, Loading Symbol Libraries) are never dismissed: their one button is Cancel"],+ },+ Verb {+ name: "kicad_window_info",+ summary: "Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs).",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 60,+ input: "{\"autoDismiss\"?: true}",+ example: "kicad_window_info {}",+ hint: "Read-only; returns each KiCad window's position/size/state. Use before click/send_key to target the right window.",+ related: &["kicad_open_editors", "kicad_click", "kicad_send_key"],+ pitfalls: &["coordinates are screen-absolute; re-read after a window moves/resizes"],+ },+ Verb {+ name: "kicad_state",+ summary: "KiCad's full state of the union: every owned window as inline base64 PNG + logical state.",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+ input: "{\"includeImages\"?: true, \"maxWidth\"?: 1100}",+ example: "kicad_state {\"includeImages\":false}",+ hint: "Read-only, non-gated. shots[] = base64 screenshot of EVERY KiCad window (render all of them; one window is not the app); plus editors inventory, modal dialogs, running flag. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll; maxWidth caps image size.",+ related: &["kicad_screenshot_all", "kicad_open_editors", "kicad_window_info"],+ pitfalls: &["shots are inline base64; the response is large, use includeImages:false when you only need the logical state"],+ },+ Verb {+ name: "kicad_screenshot_all",+ summary: "Screenshot every open KiCad window.",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+ input: "{}",+ example: "kicad_screenshot_all {}",+ hint: "Captures all KiCad windows through ab's desktop_screenshot_window (background, owned popups included, coordMap per image); the go-to way to VERIFY what actually happened after an open/edit. canvas.rendered says whether the GL canvas actually painted.",+ related: &["kicad_open_editors", "kicad_window_info", "kicad_open_board"],+ pitfalls: &["a window still rendering (e.g. 3D viewer) shows blank; wait a beat first", "with ab unreachable no image is written (abUnavailable:true); only the canvas probe answers"],+ },+ Verb {+ name: "kicad_send_key",+ summary: "Send a key to KiCad, or fire a menu item by label; chords and menu labels resolve to menu commands (background)",+ mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,+ input: "{\"key\"?: \"enter\"|\"escape\"|\"a\"|\"ctrl+s\", \"menu\"?: \"Plugin and Content Manager\", \"hwnd\"?: 123}",+ example: "kicad_send_key {\"hwnd\":123456,\"key\":\"escape\"}",+ hint: "menu: a label substring fired as a menu command on the target window without touching focus (e.g. menu:\"Plugin and Content Manager\" on the project manager opens the PCM dialog; the dialog is a child, kicad_screenshot_all captures it). For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use Bridge's desktop_press_key; it owns full chord parsing; do not route chords here. Use this verb only for the case Bridge's focus-first model cannot cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",+ related: &["kicad_window_info", "kicad_click", "kicad_fix_keyboard"],+ pitfalls: &["not for chords; use desktop_press_key", "needs a target hwnd (from window_info)", "allowForeground (real keystrokes) is not in the native build"],+ },+ Verb {+ name: "kicad_click",+ summary: "Click at coordinates inside a KiCad window.",+ mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,+ input: "{\"hwnd\": 123, \"x\": 0.5, \"y\": 0.5, \"relative\"?: true}",+ example: "kicad_click {\"hwnd\":123456,\"x\":0.5,\"y\":0.5}",+ hint: "Posts the click to the window's own queue (background: the user's mouse never moves); read window_info + screenshot_all first to compute coordinates. relative:true (default) takes fractions 0.0-1.0 of the window.",+ related: &["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"],+ pitfalls: &["coordinates are window-relative and break if the window resized; re-read window_info", "allowForeground (a real cursor click) is not in the native build"],+ },+ Verb {+ name: "kicad_fix_keyboard",+ summary: "Reset stuck modifier keys on a KiCad window.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+ input: "{}", example: "kicad_fix_keyboard {}",+ hint: "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave. The native build never sends global keystrokes, so there is never anything to fix.",+ related: &["kicad_send_key"],+ pitfalls: &[],+ },+ Verb {+ name: "kicad_place_footprint",+ summary: "Deterministically place a footprint into a board preview.",+ mechanism: Mechanism::Window, risk: "write", timeout_sec: 130,+ input: "{\"fileName\": \"X.kicad_mod\", \"fileContent\": \"<base64>\", \"footprintName\"?: \"X\", \"x\"?: 148, \"y\"?: 105, \"open\"?: true}",+ example: "kicad_place_footprint {\"fileName\":\"BMI423.kicad_mod\",\"fileContent\":\"KGZvb3RwcmludCAu...\"}",+ hint: "Splices a footprint into a fresh preview board via s-expr (no GUI edit) and opens it in pcbnew in the background; good for previews.",+ related: &["kicad_open_board", "kicad_install_footprint"],+ pitfalls: &["operates on the file; close the board in KiCad first or the edit races the GUI"],+ },+ Verb {+ name: "kicad_enable_software_opengl",+ summary: "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 240,+ input: "{}", example: "kicad_enable_software_opengl {}",+ hint: "Only when KiCad cannot render at all AND there is no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",+ related: &["kicad_dismiss_dialogs", "kicad_upgrade"],+ pitfalls: &["a real GPU / Hyper-V GPU-P passthrough is far better; this is CPU rendering, slow", "writing KiCad's bin needs an elevated Bridge"],+ },+ 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"];++/// 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_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.+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 };+ if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600)) {+ let had_error = swept["hadError"].as_bool().unwrap_or(false);+ if had_error {+ let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();+ out["_dialogHint"] = json!(format!(+ "KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.",+ swept["count"], titles.join(", ")));+ }+ out["_autoDialogs"] = swept;+ }+ // Foreground etiquette (John, 2026-08-14 and 2026-08-16): the ONE sanctioned way a+ // KiCad window comes to the front is an explicit foreground flag WITH a reason.+ let wants_fg = user_wants_foreground(args);+ let success = out.get("success").and_then(Value::as_bool).unwrap_or(true);+ if wants_fg && spawns {+ std::thread::sleep(Duration::from_secs(1)); // let the window exist+ let reason = arg_str(args, "foregroundReason").or_else(|| arg_str(args, "reason")).unwrap_or("").trim().to_string();+ let mut shown: Option<u64> = None;+ if !reason.is_empty() {+ if let Some(h) = verb_window(command, out) {+ if bring_to_user(h, &reason) {+ shown = Some(h);+ }+ }+ }+ out["_broughtToUser"] = json!(shown.is_some());+ out["windowEtiquette"] = json!(if shown.is_some() { "foreground" } else { "background" });+ if shown.is_none() {+ out["errorCode"] = json!("foreground_reason_required");+ out["_foregroundRefused"] = json!("foreground requested without foregroundReason - pass foregroundReason:'<why the user needs to see this>' and the bridge will caption it for 3s, then foreground.");+ out["_hint"] = json!("errorCode foreground_reason_required: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'.");+ }+ } else if spawns && success {+ let o = out.as_object_mut().unwrap();+ o.entry("windowEtiquette").or_insert(json!("background"));+ o.entry("openedInBackground").or_insert(json!(true));+ o.entry("_foregroundHint").or_insert(json!("Window opened in the BACKGROUND - the user's foreground was not touched. To show it to the user: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'. The bridge captions the reason via an ab toast for 3 seconds, then brings the window forward. Foreground without a reason is refused."));+ }+ // One-shot focus check (the phase 4 guardian's stand-in): did a KiCad window take the+ // foreground from a user window during this verb? Push it back once, and say so.+ let mut events = Vec::new();+ if !(wants_fg && out["_broughtToUser"] == json!(true)) {+ // 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> {+ 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))+}++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})+ }+ fn from_info(w: &WindowInfo) -> Win {+ Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: None, owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }+ }+}++struct Cache {+ at: Option<Instant>,+ rows: Vec<Win>,+ source: &'static str,+}++static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });+const CACHE_TTL: Duration = Duration::from_millis(250);++/// Call after anything that opens, closes, or retitles a KiCad window.+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).map(Win::from_info).collect(), "local:EnumWindows"))+ }+ }+}++/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.+fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {+ let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;+ let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);+ if stale {+ let (rows, src) = list_all_windows()?;+ c.rows = rows;+ c.source = src;+ c.at = Some(Instant::now());+ }+ let src = c.source;+ if all_windows {+ return Ok((c.rows.clone(), src));+ }+ // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).+ let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();+ let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();+ Ok((rows, src))+}++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;+ for w in kicad_windows() {+ let kind = w.kind();+ if matches!(kind, WindowKind::Dialog | WindowKind::Other) {+ continue;+ }+ // 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();+ }+ let mut out = Vec::new();+ for w in kicad_windows() {+ let kind = w.kind();+ let Some(remembered) = mem.get(kind.as_str()) else { continue };+ let r = &remembered.place;+ let current = match native().placement(w.hwnd) {+ Ok(p) => p,+ Err(e) => {+ out.push(json!({"window": kind.as_str(), "hwnd": w.hwnd, "moved": false, "error": e}));+ continue;+ }+ };+ let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);+ match window_memory::decide(r, ¤t, on_monitor) {+ Restore::Skip(why) => out.push(json!({"window": kind.as_str(), "hwnd": w.hwnd, "moved": false, "why": why})),+ Restore::Move(p) => match native().set_placement(w.hwnd, &p) {+ Ok(()) => out.push(json!({"window": kind.as_str(), "hwnd": w.hwnd, "moved": true,+ "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},+ "rememberedAt": remembered.at})),+ Err(e) => out.push(json!({"window": kind.as_str(), "hwnd": w.hwnd, "moved": false, "error": e})),+ },+ }+ }+ out+}++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> {+ let before = fg_before?;+ if is_kicad_hwnd(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).+ let restored = ab::desktop_restore_user_window(before, "restore the user's window after a KiCad window raised itself").is_ok()+ || matches!(native().bring_to_front(before), Ok(true));+ 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;+ }+ 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, "at": kicad_common::now_utc_iso()}));+ ok+}++// ── 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;+ }+ if wm::looks_like_global_dialog(&w.title, &w.class_name) {+ out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });+ seen.push(w.hwnd);+ }+ }+ out+}++fn find_save_prompt() -> Option<Dialog> {+ scan_dialogs().into_iter().find(|d| wm::is_save_prompt(&d.title, &d.body))+}++/// Dismiss a dialog: a known dismiss button (OK for info boxes, Cancel/Finish for+/// wizards) through a time-boxed click, then Escape + WM_CLOSE. All posted to the exact+/// hwnd, no focus theft.+fn dismiss_dialog(hwnd: u64) -> String {+ for tier in wm::DISMISS_BUTTON_TIERS {+ if native().click_button(hwnd, tier).is_ok() {+ invalidate();+ return "clicked-button".into();+ }+ }+ let _ = native().post_key(hwnd, wm::VK_ESCAPE, None);+ let _ = native().post_close(hwnd);+ invalidate();+ "escape+WM_CLOSE".into()+}++/// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed.+fn screenshot_dialog(hwnd: u64, title: &str) -> String {+ ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default()+}++/// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops+/// attempting OpenGL on this machine. Called only right after dismissing the "could not+/// use OpenGL" notice, which is PROOF OpenGL is unavailable here.+fn force_software_canvas(version: &str) -> bool {+ let Some(root) = native().config_root() else { return false };+ let mut targets: Vec<PathBuf> = Vec::new();+ if !version.is_empty() {+ let d = root.join(kicad_platform::major_minor(version));+ if d.is_dir() {+ targets.push(d.join("kicad_common.json"));+ }+ }+ if targets.is_empty() {+ targets = kicad_common::common_files(&root);+ if targets.is_empty() && !version.is_empty() {+ targets.push(root.join(kicad_platform::major_minor(version)).join("kicad_common.json"));+ }+ }+ let mut changed = false;+ for cfg in targets {+ let mut doc = std::fs::read_to_string(&cfg).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));+ if !wm::force_software_canvas_doc(&mut doc) {+ continue;+ }+ if let Some(p) = cfg.parent() {+ let _ = std::fs::create_dir_all(p);+ }+ if let Ok(text) = serde_json::to_string_pretty(&doc) {+ if std::fs::write(&cfg, text).is_ok() {+ changed = true;+ }+ }+ }+ changed+}++/// close_windows._win_sweep_dialogs: expire the benign dialogs (or ALL dialogs with+/// dismiss_all), looping because dismissing one often spawns the next.+fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value {+ let mut dismissed = Vec::new();+ let mut remaining = Vec::new();+ let mut forced = false;+ let mut shot: Vec<u64> = Vec::new();+ let deadline = Instant::now() + Duration::from_secs(25);+ for _pass in 0..5 {+ if Instant::now() > deadline {+ break;+ }+ remaining.clear();+ let mut acted = false;+ for d in scan_dialogs() {+ match wm::sweep_decision(&d.title, &d.body, dismiss_all) {+ DialogAction::LeaveProgress => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body, "progress": true})),+ DialogAction::Dismiss { benign } => {+ let mut entry = json!({"title": d.title, "body": d.body});+ if screenshot && !shot.contains(&d.hwnd) {+ entry["screenshot"] = json!(screenshot_dialog(d.hwnd, &d.title));+ shot.push(d.hwnd);+ }+ entry["action"] = json!(dismiss_dialog(d.hwnd));+ dismissed.push(entry);+ acted = true;+ if benign && !forced {+ forced = force_software_canvas(&info.version);+ }+ }+ DialogAction::Leave => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})),+ }+ }+ if !acted {+ break;+ }+ sleep_ms(400);+ }+ json!({"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced})+}++/// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late+/// dialog, screenshots and reads each one found, expires it (never a progress dialog),+/// and returns None when nothing was ever seen.+fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration) -> Option<Value> {+ let rounds = settle_rounds.max(1);+ let mut found = Vec::new();+ for r in 0..rounds {+ invalidate();+ found = scan_dialogs();+ if !found.is_empty() {+ break;+ }+ if r + 1 < rounds {+ std::thread::sleep(settle_gap);+ }+ }+ if found.is_empty() {+ return None;+ }+ let mut dialogs = Vec::new();+ let mut had_error = false;+ let mut seen: Vec<u64> = Vec::new();+ let deadline = Instant::now() + Duration::from_secs(25);+ for _pass in 0..4 {+ if Instant::now() > deadline {+ break;+ }+ let mut acted = false;+ invalidate();+ for d in scan_dialogs() {+ if seen.contains(&d.hwnd) {+ continue;+ }+ seen.push(d.hwnd);+ match wm::auto_sweep_decision(&d.title, &d.body) {+ DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),+ DialogAction::Dismiss { benign } => {+ if !benign {+ had_error = true;+ }+ let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };+ let action = dismiss_dialog(d.hwnd);+ dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot}));+ acted = true;+ }+ DialogAction::Leave => {}+ }+ }+ if !acted {+ break;+ }+ sleep_ms(350);+ }+ if dialogs.is_empty() {+ return None;+ }+ Some(json!({"count": dialogs.len(), "dialogs": dialogs, "hadError": had_error}))+}++/// The rows `kicad_errors` reads (bridge_log::DialogRow), for the misc group to consume+/// once kicad_errors moves to a group that runs after this one.+pub fn dialog_rows() -> Vec<bridge_log::DialogRow> {+ scan_dialogs().into_iter().map(|d| {+ let texts: Vec<String> = native().child_windows(d.hwnd).map(|c| c.iter().filter(|c| c.class_name != "Button").map(|c| c.title.clone()).filter(|t| !t.trim().is_empty()).collect()).unwrap_or_default();+ bridge_log::DialogRow { hwnd: d.hwnd, title: d.title, texts, uia_text: None }+ }).collect()+}++// ── Menu walk, UIA, posted keys: the rungs ────────────────────────────────────++/// Resolve a menu item whose label contains `needle` from the window's live menu bar and+/// fire it with WM_COMMAND. Ok(Some((id, label))) when fired, Ok(None) when no such item.+fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>, String> {+ let items = native().menu_tree(hwnd)?;+ let want = needle.to_lowercase();+ // 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('&', ""))));+ }+ for it in items {+ let Some(label) = it.path.last() else { continue };+ if clean(label).contains(&want) && it.id != 0 {+ native().menu_invoke(hwnd, it.id)?;+ invalidate();+ return Ok(Some((it.id, label.replace('&', ""))));+ }+ }+ Ok(None)+}++/// win_menu.find_by_accelerator: a chord to the menu command that carries it.+fn menu_invoke_by_accel(hwnd: u64, chord: &str) -> Result<Option<(u32, String)>, String> {+ let items = native().menu_tree(hwnd)?;+ let want = wm::norm_accel(chord);+ for it in items {+ if let Some(a) = &it.accel {+ if wm::norm_accel(a) == want && it.id != 0 {+ native().menu_invoke(hwnd, it.id)?;+ invalidate();+ let label = it.path.last().map(|l| l.replace('&', "")).unwrap_or_default();+ return Ok(Some((it.id, label)));+ }+ }+ }+ Ok(None)+}++/// Poll for a window of `kind` until `deadline`; `repost` fires once at `repost_after`.+fn wait_for_kind(kind: WindowKind, deadline: Instant, repost: Option<(u64, u32)>, repost_after: Duration) -> Option<u64> {+ let t0 = Instant::now();+ let mut reposted = false;+ loop {+ invalidate();+ if let Some(h) = first_of_kind(kind) {+ return Some(h);+ }+ if Instant::now() >= deadline {+ return None;+ }+ if !reposted && t0.elapsed() > repost_after {+ if let Some((host, id)) = repost {+ let _ = native().menu_invoke(host, id);+ }+ reposted = true;+ }+ sleep_ms(500);+ }+}++/// Every descendant (class, text, rect, visible) of a window with its parent handle.+/// The platform's `child_windows` returns the whole subtree with `owner` carrying the+/// PARENT handle (the Windows implementation is built for exactly this join).+#[derive(Clone, Debug)]+struct Child {+ hwnd: u64,+ class_name: String,+ text: String,+ rect: (i32, i32, i32, i32),+ visible: bool,+ parent: u64,+}++fn descendants(root: u64) -> Vec<Child> {+ let Ok(kids) = native().child_windows(root) else { return Vec::new() };+ kids.iter().map(|k| Child { hwnd: k.hwnd, class_name: k.class_name.clone(), text: k.title.clone(), rect: k.rect, visible: k.visible, parent: if k.owner == 0 { root } else { k.owner } }).collect()+}++/// The library tree's filter Edit: class "Edit" under a parent whose text is "searchCtrl".+fn find_search_edit(frame: u64) -> Option<Child> {+ let all = descendants(frame);+ all.iter().find(|c| c.class_name == "Edit" && all.iter().any(|p| p.hwnd == c.parent && p.text == "searchCtrl")).cloned()+}++static TREE_TOGGLED: Mutex<Vec<(u64, Instant)>> = Mutex::new(Vec::new());++/// The tree search control, showing the Library Tree pane through the frame's own View+/// menu when the user has it hidden (toggle at most once per editor per minute).+fn ensure_tree_search(frame: u64, trace: &mut Vec<String>) -> Option<Child> {+ if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {+ return Some(e);+ }+ let may_toggle = TREE_TOGGLED.lock().map(|g| !g.iter().any(|(h, t)| *h == frame && t.elapsed() < Duration::from_secs(60))).unwrap_or(true);+ if may_toggle {+ match menu_invoke_by_text(frame, "library tree") {+ Ok(Some((id, _))) => {+ trace.push(format!("library tree pane was hidden: View menu toggled (id {id})"));+ if let Ok(mut g) = TREE_TOGGLED.lock() {+ g.retain(|(h, _)| *h != frame);+ g.push((frame, Instant::now()));+ }+ }+ Ok(None) => trace.push("search Edit not visible and no 'Library Tree' menu item found".into()),+ Err(e) => trace.push(format!("search Edit not visible; menu walk failed: {e}")),+ }+ }+ for _ in 0..16 {+ if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {+ return Some(e);+ }+ sleep_ms(250);+ }+ None+}++/// The rung used to put text in the filter box: "uia" (ValuePattern) else "post" (WM_SETTEXT).+fn set_filter_text(edit: u64, text: &str) -> Result<&'static str, String> {+ if native().uia_set_value(edit, "", text).is_ok() {+ return Ok("uia");+ }+ native().set_text(edit, text).map(|_| "post")+}++fn post_vk(hwnd: u64, vk: u16) {+ let _ = native().post_key(hwnd, vk, None);+}++/// Which editor title check applies during navigation.+#[derive(Clone, Copy)]+enum Verify<'a> {+ Symbol { name: &'a str, library: &'a str },+ Footprint { name: &'a str, library: &'a str },+ Never,+}++fn verify_title(title: &str, v: Verify<'_>) -> bool {+ match v {+ Verify::Symbol { name, library } => wm::title_confirms_symbol(title, name, library),+ Verify::Footprint { name, library } => wm::title_confirms_footprint(title, name, library),+ Verify::Never => false,+ }+}++/// The plugin-free BACKGROUND library-tree load (0.9.83/0.9.89, wiki #71): text into the+/// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates,+/// Down x1..3 then Enter, a posted double-click on the first row. Zero focus.+/// Returns (confirmed, trace, mechanism).+fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {+ let mut steps: Vec<String> = Vec::new();+ let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});+ let mut mechanism = "post";+ let Some(edit) = ensure_tree_search(frame, &mut steps) else {+ steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());+ trace["steps"] = json!(steps);+ return (false, trace, mechanism);+ };+ trace["editHwnd"] = json!(edit.hwnd);+ match set_filter_text(edit.hwnd, filter) {+ Ok(m) => {+ mechanism = m;+ steps.push(format!("filter text set via {m}"));+ }+ Err(e) => {+ steps.push(format!("could not set the filter text: {e}"));+ trace["steps"] = json!(steps);+ return (false, trace, mechanism);+ }+ }+ sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild+ // Down THROUGH the filter box: KiCad forwards nav keys to the tree.+ post_vk(edit.hwnd, wm::VK_DOWN);+ sleep_ms(500);+ steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));+ // Tree candidates: tall siblings under the Libraries pane (wx generic controls share+ // bland class names, so geometry is the discriminator) plus any wxDataView.+ let all = descendants(frame);+ let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);+ let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);+ let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();+ trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());+ let title_ok = || verify_title(&title_of(frame), verify);+ // Round 1: Enter posted straight to each tree candidate.+ for c in &cands {+ post_vk(c.hwnd, wm::VK_RETURN);+ }+ for _ in 0..6 {+ sleep_ms(700);+ if title_ok() {+ steps.push("VK_RETURN to tree candidates -> title confirms".into());+ trace["steps"] = json!(steps);+ return (true, trace, mechanism);+ }+ }+ steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));+ // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside+ // the tree: Down (first lands on the library node, second on the symbol row), then Enter.+ for downs in 1..=3 {+ for c in &cands {+ post_vk(c.hwnd, wm::VK_DOWN);+ }+ sleep_ms(400);+ for c in &cands {+ post_vk(c.hwnd, wm::VK_RETURN);+ }+ for _ in 0..5 {+ sleep_ms(600);+ if title_ok() {+ steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));+ trace["steps"] = json!(steps);+ return (true, trace, mechanism);+ }+ }+ steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));+ }+ // Round 2: a posted double-click on the first visible row (just under the header).+ for c in &cands {+ let x = (c.rect.2 / 3).max(10);+ let y = 58;+ let _ = native().post_click(c.hwnd, x, y, false);+ let _ = native().post_click(c.hwnd, x, y, true);+ }+ for _ in 0..8 {+ sleep_ms(700);+ if title_ok() {+ steps.push("posted double-click on first row -> title confirms".into());+ trace["steps"] = json!(steps);+ return (true, trace, mechanism);+ }+ }+ steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));+ trace["steps"] = json!(steps);+ (false, trace, mechanism)+}++// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────++struct Budget {+ t0: Instant,+ limit: Duration,+}++impl Budget {+ fn new(args: &Value) -> Budget {+ let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);+ Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }+ }+ fn out(&self) -> bool {+ self.t0.elapsed() > self.limit+ }+ fn elapsed_sec(&self) -> u64 {+ self.t0.elapsed().as_secs()+ }+}++struct OpenSpec {+ kind: WindowKind,+ /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").+ menu_needle: &'static str,+ uia_name: &'static str,+ /// Frames whose menu bar / UIA tree carries the launcher, in preference order+ /// (a running editor of the same family first, the project manager last).+ hosts: &'static [WindowKind],+ error_code: &'static str,+ starting_code: &'static str,+ label: &'static str,+}++const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };+const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };++/// The early-return contract when a slow cold start would outlive the transport budget+/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.+fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {+ invalidate();+ if first_of_kind(spec.kind).is_none() {+ return json!({+ "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,+ "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,+ "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),+ "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),+ });+ }+ json!({+ "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",+ "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),+ "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),+ "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",+ })+}++/// Make sure the project manager exists (spawn kicad.exe in the background if not).+fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {+ invalidate();+ if let Some(h) = find_project_manager() {+ return Ok(h);+ }+ let Some(exe) = kicad_exe(info, "kicad") else {+ return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));+ };+ spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;+ pathways.push("spawned-project-manager".into());+ let deadline = Instant::now() + Duration::from_secs(10);+ loop {+ sleep_ms(500);+ invalidate();+ if let Some(h) = find_project_manager() {+ sleep_ms(2000);+ return Ok(h);+ }+ if Instant::now() > deadline {+ return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));+ }+ }+}++/// Open an editor frame: an existing one wins; else the native menu walk on each host+/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).+fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {+ invalidate();+ if let Some(h) = first_of_kind(spec.kind) {+ return Ok((h, "existing"));+ }+ // Hosts: a live editor frame of the same family first, then the project manager+ // (spawned in the background when nothing at all is running).+ let mut hosts: Vec<u64> = Vec::new();+ for k in spec.hosts {+ if *k == WindowKind::ProjectManager {+ continue;+ }+ hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));+ }+ if spec.hosts.contains(&WindowKind::ProjectManager) {+ let pm = match find_project_manager() {+ Some(h) => h,+ None if hosts.is_empty() => ensure_project_manager(info, pathways)?,+ None => 0,+ };+ if pm != 0 {+ hosts.push(pm);+ }+ }+ if hosts.is_empty() {+ return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));+ }+ // Rung: native menu walk + WM_COMMAND (background, version-proof ids).+ let mut menu_unsupported: Option<Value> = None;+ for &host in &hosts {+ if budget.out() {+ return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+ }+ match menu_invoke_by_text(host, spec.menu_needle) {+ Ok(Some((id, _label))) => {+ pathways.push(format!("menu-walk-id-{id}"));+ pathways.push("wm_command".into());+ let deadline = Instant::now() + Duration::from_secs(30);+ if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {+ return Ok((h, "menu"));+ }+ pathways.push("wm_command-no-window".into());+ }+ Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),+ Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }+ Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),+ }+ }+ // Rung: UI Automation Invoke by accessible name (no focus, no cursor).+ let mut uia_ran = false;+ for &host in &hosts {+ if budget.out() {+ return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+ }+ match native().uia_invoke(host, spec.uia_name) {+ Ok(_) => {+ uia_ran = true;+ pathways.push("uia".into());+ let deadline = Instant::now() + Duration::from_secs(30);+ if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {+ return Ok((h, "uia"));+ }+ pathways.push("uia-no-window".into());+ }+ Err(e) if is_not_impl(&e) => {+ if let Some(u) = menu_unsupported.take() {+ return Err(u);+ }+ pathways.push("uia-unsupported".into());+ }+ Err(e) => {+ uia_ran = true;+ pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));+ }+ }+ }+ if let (Some(u), false) = (menu_unsupported, uia_ran) {+ return Err(u);+ }+ let idle = user_idle_seconds();+ let busy = idle.map(|i| i < 120.0).unwrap_or(false);+ Err(json!({+ "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,+ "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,+ if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),+ "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",+ }))+}++/// Ask an open editor to close so the next open re-indexes the libraries: ab's+/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is+/// measured here. Returns (gone, how).+fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {+ let how = match ab::desktop_close_window(hwnd, reason) {+ Ok(_) => "ad:desktop_close_window",+ Err(_) => {+ if native().post_close(hwnd).is_err() {+ return (false, "none");+ }+ "local:WM_CLOSE"+ }+ };+ invalidate();+ let deadline = Instant::now() + timeout;+ while Instant::now() < deadline {+ sleep_ms(300);+ invalidate();+ if !window_alive(hwnd) {+ return (true, how);+ }+ }+ (false, how)+}++// ── kicad_launch ──────────────────────────────────────────────────────────────++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)) {+ return json!({+ "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",+ "output": format!("{label} editor already has {name} open"),+ "_hint": "The existing window was reused, not reloaded. kicad_close first if you need a fresh load from disk.",+ });+ }+ let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {+ Ok(x) => x,+ Err(mut e) => {+ if e["errorCode"] != json!("not_supported_on_platform") {+ e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));+ e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");+ }+ return e;+ }+ };+ // Verify by window title with one deadline (no nested sleeps).+ let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);+ let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+ let mut appeared: Option<Win> = None;+ while Instant::now() < deadline {+ invalidate();+ let wins = windows_of_kind(kind);+ appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();+ if appeared.is_some() {+ break;+ }+ if native().process_alive(pid) == Some(false) {+ break;+ }+ sleep_ms(500);+ }+ let hint = format!(+ "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."+ );+ let mut out = json!({+ "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),+ "output": format!("Launched {label} editor with {name}"),+ "_hint": hint,+ });+ match appeared {+ Some(w) => {+ out["hwnd"] = json!(w.hwnd);+ out["title"] = json!(w.title);+ out["windowAppeared"] = json!(true);+ }+ None => {+ let alive = native().process_alive(pid);+ out["windowAppeared"] = json!(false);+ if alive == Some(false) {+ out["success"] = json!(false);+ out["errorCode"] = json!("editor_exited");+ out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));+ out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));+ } else {+ out["statusVerb"] = json!("kicad_window_info");+ out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));+ }+ }+ }+ out+}++// ── kicad_open_symbol_editor ──────────────────────────────────────────────────++fn lib_ctx(info: &KicadInfo) -> LibCtx {+ let inst = info.primary();+ let version = inst.map(|i| i.version.clone()).unwrap_or_default();+ let mut ctx = LibCtx {+ version: version.clone(),+ base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+ config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+ user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+ kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+ env_overrides: Default::default(),+ };+ ctx.load_env_overrides();+ ctx+}++/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE+/// prefix match in either direction against the Adom library's real names.+fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {+ let ctx = lib_ctx(info);+ let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };+ if !lib_path.is_file() {+ return (None, "library file not found".into());+ }+ let names = libraries::list_symbols(&lib_path);+ if names.iter().any(|n| n == requested) {+ return (Some(requested.to_string()), "exact".into());+ }+ if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {+ return (Some(n.clone()), "case-insensitive".into());+ }+ let r = requested.to_lowercase();+ let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();+ match pref.len() {+ 1 => (Some(pref[0].clone()), "unique prefix match".into()),+ 0 => (None, "no match".into()),+ _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),+ }+}++fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {+ let ctx = lib_ctx(info);+ libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)+}++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.",+ });+ merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+ v+}++// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────++fn viewer_for_frame(frame: u64, source_pid: u32, windows: Vec<Win>) -> Option<Win> {+ let exact: Vec<_> = windows.iter().filter(|w| w.owner == frame).cloned().collect();+ if exact.len() == 1 { return exact.into_iter().next(); }+ let same_process: Vec<_> = windows.into_iter().filter(|w| source_pid != 0 && w.pid == source_pid).collect();+ if same_process.len() == 1 { same_process.into_iter().next() } else { None }+}+fn poll_3d_window(frame: u64, source_pid: u32, rounds: usize) -> Option<Win> {+ for _ in 0..rounds.max(1) {+ sleep_ms(750);+ invalidate();+ if let Some(w) = viewer_for_frame(frame, source_pid, windows_of_kind(WindowKind::Viewer3d)) {+ 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 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, 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, 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.+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.+ return json!({+ "success": false, "errorCode": "close_failed",+ "error": format!("Failed to close: {}", failed.join(", ")),+ "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+ "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows (a kill discards unsaved work silently). kicad_screenshot_all shows whether a dialog is holding the window.",+ });+ }+ let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});+ if !discard_log.is_empty() {+ out["discard"] = json!(discard_log);+ }+ out+}++// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────++fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {+ let info = state.kicad_info();+ if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {+ let changed = force_software_canvas(&info.version);+ return json!({+ "success": true, "forcedSoftwareCanvas": changed,+ "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },+ "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",+ });+ }+ if let Err(e) = find_windows(true, true) {+ return e;+ }+ if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {+ let wins = scan_dialogs();+ let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();+ return json!({+ "success": true, "debug": true, "kicadPids": pids,+ "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),+ "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),+ });+ }+ let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);+ let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);+ let res = sweep_dialogs(&info, all, screenshot);+ let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);+ let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);+ let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+ json!({+ "success": true,+ "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),+ "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,+ "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),+ })+}++fn window_info(state: &mut State, args: &Value) -> Value {+ let info = state.kicad_info();+ if let Err(e) = find_windows(true, true) {+ return e;+ }+ let mut auto_dismissed = json!([]);+ let mut forced_sw = false;+ if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {+ let s = sweep_dialogs(&info, false, false);+ auto_dismissed = s["dismissed"].clone();+ forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+ }+ invalidate();+ let (wins, source) = match find_windows(false, true) {+ Ok(x) => x,+ Err(e) => return e,+ };+ let mut editors = Vec::new();+ let mut dialogs = Vec::new();+ let mut manager = Value::Null;+ for w in &wins {+ let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});+ if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {+ let mut d = base;+ d["ownerHwnd"] = json!(w.owner);+ d["body"] = json!(dialog_body_text(w.hwnd));+ dialogs.push(d);+ } else if wm::is_manager_title(&w.title) {+ manager = base;+ } else {+ editors.push(base);+ }+ }+ let mut data = json!({+ "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),+ "autoDismissed": auto_dismissed, "windowSource": source,+ });+ if forced_sw {+ data["forcedSoftwareCanvas"] = json!(true);+ }+ let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);+ if n_auto > 0 {+ data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));+ }+ if !dialogs.is_empty() {+ data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");+ }+ json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})+}++// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────++/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.+fn canvas_probe(hwnd: u64) -> Value {+ let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };+ let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));+ wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)+}++/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);+/// the canvas verdict is always ours.+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();+ 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};+ let other=win(10,1,100);let requested=win(20,2,200);+ assert_eq!(viewer_for_frame(2,200,vec![other.clone(),requested.clone()]).unwrap().hwnd,20);+ assert!(viewer_for_frame(3,300,vec![other.clone(),requested.clone()]).is_none());+ assert!(viewer_for_frame(3,200,vec![requested.clone(),win(21,4,200)]).is_none());+ assert_eq!(viewer_for_frame(2,200,vec![requested,win(21,4,200)]).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";+ 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."});+ }+ 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});+ }+ }+ 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,+ "_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."})+}+@@ -1,972 +1,976 @@⋯ 252 unchanged lines ⋯ if let Some(w) = wins.iter().find(|w| w.hwnd == h) { return Some(w.clone()); }+ return None; // Never substitute another window for an explicit HWND. } if !needle.is_empty() { if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(needle)) { return Some(w.clone()); }+ return None; // A missing requested document is not any open editor. } wins.into_iter().next() }⋯ 383 unchanged lines ⋯ // needs a live PCB editor) so we do not race "not open". step(CMD_3D_BOARD, "waiting for the PCB Editor window", 30); pcb_hwnd = wait_for_surface(WindowKind::PcbEditor, opened.get("hwnd").and_then(Value::as_u64), &stem_of(f), 40, 750).map(|w| w.hwnd);+ if pcb_hwnd.is_none() { return json!({"success":false,"errorCode":"requested_board_not_open","error":"The requested PCB editor did not appear.","_hint":"Inspect kicad_open_editors and dialogs; resolve any file lock before retrying kicad_show_3d_board with the same filePath."}); } } step(CMD_3D_BOARD, "opening the 3D Viewer from the PCB Editor", 40); let mut a = args.clone();⋯ 317 unchanged lines ⋯ assert_eq!(surface_kind("show_project"), None); } }+@@ -1,746 +1,752 @@⋯ 532 unchanged lines ⋯ let step_y = ((y1 - y0) / 60).max(1); let mut colors: std::collections::HashSet<[u8; 3]> = std::collections::HashSet::new(); let mut samples = 0u64;+ let mut lit_samples = 0u64; let mut y = y0; while y < y1 { let mut x = x0;⋯ 1 unchanged line ⋯ let i = ((y * w + x) * 3) as usize; colors.insert([rgb[i], rgb[i + 1], rgb[i + 2]]); samples += 1;- if colors.len() > 12 {- return json!({"checked": true, "distinctColors": colors.len(), "rendered": true, "samples": samples, "region": region});- }+ if rgb[i].max(rgb[i+1]).max(rgb[i+2]) > 24 { lit_samples += 1; } x += step_x; } y += step_y; }- json!({"checked": true, "distinctColors": colors.len(), "rendered": colors.len() > 3, "samples": samples, "region": region})+ json!({"checked": true, "distinctColors": colors.len(), "rendered": colors.len() > 3 && lit_samples * 100 >= samples * 3, "litSamples":lit_samples, "method":"canvas color heuristic, not geometry qualification", "samples": samples, "region": region}) } /// A filename-safe label for a screenshot (kicad_ui.handle_screenshot_all).⋯ 166 unchanged lines ⋯ } #[test]+ fn almost_black_canvas_is_not_accepted_for_sparse_color_noise() {+ let mut rgb=vec![0u8;200*100*3];+ for i in 0..rgb.len() { rgb[i]=(i%23) as u8; }+ assert_eq!(canvas_uniformity(&rgb,200,100,None)["rendered"],json!(false));+ }+ #[test] fn canvas_probe_flat_versus_drawn() { let (w, h) = (200u32, 100u32); let flat = vec![40u8; (w * h * 3) as usize];⋯ 18 unchanged lines ⋯ assert_eq!(safe_label("", "dialog"), "dialog"); } }+
Comments
Log in to comment.
Superseded by https://wiki.adom.inc/adom/kicad-bridge/prs/7, rebased onto Bridge 1.0.17 and including the same-process, multiple-editor viewer ambiguity regression. Closing this older file set to avoid merging stale source. Native Windows acceptance remains pending.