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 viewer evidence to its editor and expose native text metrics and pan #7
Rebased onto current Bridge 1.0.17 source, preserving its intervening changes. Supersedes PRs #5 and #6.
Bind 3D evidence to the source editor. Exact window ownership wins; otherwise a uniquely new viewer from the requested process may be bound after the native menu invocation. A process-only fallback is allowed only when that process has one possible 3D source editor. Known bindings are reused while their HWND/PID remain valid. Two same-process editors with an unowned old viewer no longer make the second editor return the first viewer. Ambiguous cases refuse. This incorporates the Bridge owner's independent reproduction on issue #102. Explicit stale HWND/document requests also refuse instead of silently selecting another surface.
Expose native pan menu actions and reject near-black canvas noise without claiming that a pixel heuristic certifies geometry. Add read-only kicad_text_bounds through IPC GetTextExtents, with exact requested-board selection, optional revision checking, mm units, size/stroke, rotation, left/right/vertical justification and mirroring. Text bounds do not include other obstacles or certify readability.
Validation: cargo test --workspace --lib --bins passes on the rebased source, including same-process viewer ownership/new-window/ambiguity/cache regressions, sparse black-noise rejection, and text request conversion for right alignment and mirrored multiline 0.3 mm text. Native Windows acceptance is still required. No shared bridge runtime was replaced. This addresses #99/#100 and parts of #102/#104/#105; stable-ID silk editing, full obstacle extraction and region framing remain follow-on work.
Diff Skip to comments (1)
@@ -1,3140 +1,3142 @@⋯ 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})- }- /// `z` is the window's position in the OS enumeration, which EnumWindows returns front- /// first, so a caller can tell which of two windows of a kind the user was looking at- /// (wiki #48). ab's inventory carries its own z; this is the native fallback's.- fn from_info(w: &WindowInfo, z: usize) -> Win {- Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: Some(z as i64), owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }- }-}--struct Cache {- at: Option<Instant>,- rows: Vec<Win>,- source: &'static str,-}--static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });-const CACHE_TTL: Duration = Duration::from_millis(250);--/// Call after anything that opens, closes, or retitles a KiCad window.-pub(crate) fn invalidate() {- if let Ok(mut c) = CACHE.lock() {- c.at = None;- c.rows.clear();- }-}--fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {- match ab::desktop_list_windows() {- Ok(l) if !(l.windows.is_empty() && l.degraded) => {- let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {- hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: w.z, owner: w.owner, pid: w.pid,- exe: w.image.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung,- }).collect();- // ab before 2.1.12 carries no image/owner (they arrived together): fill both- // from the OS layer when it can, only for rows that lack the exe.- for r in rows.iter_mut() {- if r.exe.is_empty() {- if let Ok(Some(i)) = native().window_info(r.hwnd) {- r.exe = i.exe;- if r.owner == 0 { r.owner = i.owner; }- if r.class_name.is_empty() { r.class_name = i.class_name; }- }- }- }- Ok((rows, "ad:desktop_list_windows"))- }- _ => {- let rows = plat("window enumeration (list_windows)", native().list_windows())?;- Ok((rows.iter().filter(|w| w.visible).enumerate().map(|(z, w)| Win::from_info(w, z)).collect(), "local:EnumWindows"))- }- }-}--/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.-fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {- let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;- let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);- if stale {- let (rows, src) = list_all_windows()?;- c.rows = rows;- c.source = src;- c.at = Some(Instant::now());- }- let src = c.source;- if all_windows {- return Ok((c.rows.clone(), src));- }- // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).- let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();- let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();- Ok((rows, src))-}--pub(crate) fn kicad_windows() -> Vec<Win> {- find_windows(false, false).map(|(w, _)| w).unwrap_or_default()-}--pub(crate) fn windows_of_kind(kind: WindowKind) -> Vec<Win> {- kicad_windows().into_iter().filter(|w| w.kind() == kind).collect()-}--fn first_of_kind(kind: WindowKind) -> Option<u64> {- windows_of_kind(kind).first().map(|w| w.hwnd)-}--/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can-/// put it back. Called before anything the bridge does that destroys geometry (a restart, a-/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach-/// nothing, so they are skipped.-pub(crate) fn remember_placement() -> usize {- let now = utc_stamp();- let mut mem = window_memory::load();- let mut learned = 0;- // Front to back, so that when two windows share a kind (two PCB Editors, say) the one the- // user was actually looking at is the one whose place is remembered for that kind.- let mut windows = kicad_windows();- windows.sort_by_key(|w| w.z.unwrap_or(i64::MAX));- let mut seen: Vec<WindowKind> = Vec::new();- for w in windows {- let kind = w.kind();- if matches!(kind, WindowKind::Dialog | WindowKind::Other) || seen.contains(&kind) {- continue;- }- seen.push(kind);- // placement() reports the NORMAL rect plus the maximized flag, which is what has to- // come back; the enumerated rect is the fallback where that call is unavailable.- let place = native().placement(w.hwnd).unwrap_or(Placement {- x: w.rect.0, y: w.rect.1, width: w.rect.2, height: w.rect.3, maximized: w.maximized,- });- if window_memory::observe(&mut mem, kind.as_str(), &place, w.minimized, &now) {- learned += 1;- }- }- if learned > 0 {- if let Err(e) = window_memory::save(&mem) {- eprintln!("[placement] could not save the placement memory: {e}");- }- }- learned-}--/// Wiki #48: put each window back where the user had it, without activating it. A fresh-/// KiCad process places every window on the PRIMARY display, which is how a multi-monitor-/// user's PCB Editor kept coming back to the laptop panel. Returns one row per window so the-/// caller's reply can say what moved and what was left alone.-pub(crate) fn restore_placement() -> Vec<Value> {- let mem = window_memory::load();- if mem.is_empty() {- return Vec::new();- }- kicad_windows().iter().filter_map(|w| restore_one(&mem, w.kind(), w.hwnd)).collect()-}--/// Wiki #48: one freshly opened window put back on the monitor the user keeps that kind of-/// window on. Called when a verb OPENS an editor, not only when it restarts KiCad, because-/// "the bridge opens or restarts KiCad and it comes back on the laptop screen" is one-/// complaint with two halves. Returns None when nothing is remembered for that kind.-pub(crate) fn restore_placement_one(kind: WindowKind, hwnd: u64) -> Option<Value> {- restore_one(&window_memory::load(), kind, hwnd)-}--fn restore_one(mem: &window_memory::Memory, kind: WindowKind, hwnd: u64) -> Option<Value> {- let remembered = mem.get(kind.as_str())?;- let r = &remembered.place;- let current = match native().placement(hwnd) {- Ok(p) => p,- Err(e) => return Some(json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e})),- };- let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);- Some(match window_memory::decide(r, ¤t, on_monitor) {- Restore::Skip(why) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "why": why}),- Restore::Move(p) => match native().set_placement(hwnd, &p) {- Ok(()) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": true,- "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},- "rememberedAt": remembered.at}),- Err(e) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e}),- },- })-}--fn find_project_manager() -> Option<u64> {- kicad_windows().iter().find(|w| wm::is_project_manager(&w.title, w.owner)).map(|w| w.hwnd)-}--/// A window's current title without hanging on a modal-blocked thread.-pub(crate) fn title_of(hwnd: u64) -> String {- if let Ok(Some(i)) = native().window_info(hwnd) {- return i.title;- }- find_windows(true, true).ok().and_then(|(w, _)| w.into_iter().find(|w| w.hwnd == hwnd)).map(|w| w.title).unwrap_or_default()-}--pub(crate) fn window_alive(hwnd: u64) -> bool {- match native().window_info(hwnd) {- Ok(Some(i)) => i.visible,- Ok(None) => false,- Err(_) => find_windows(true, true).map(|(w, _)| w.iter().any(|w| w.hwnd == hwnd)).unwrap_or(false),- }-}--/// The window a verb surfaced, for foreground and labelling: the kind the verb opens,-/// else the topmost KiCad window.-fn verb_window(command: &str, out: &Value) -> Option<u64> {- if let Some(h) = out.get("hwnd").and_then(Value::as_u64).filter(|h| *h != 0) {- return Some(h);- }- let kind = match command {- "kicad_open_symbol_editor" => Some(WindowKind::SymbolEditor),- "kicad_open_footprint_editor" => Some(WindowKind::FootprintEditor),- "kicad_open_board" | "kicad_place_footprint" => Some(WindowKind::PcbEditor),- "kicad_open_schematic" => Some(WindowKind::SchematicEditor),- "kicad_open_3d_viewer" => Some(WindowKind::Viewer3d),- _ => None,- };- let wins = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();- kind.and_then(|k| wins.iter().find(|w| w.kind() == k).map(|w| w.hwnd)).or_else(|| wins.first().map(|w| w.hwnd))-}--// ── Focus etiquette (win_focus.py, the one-shot part only) ────────────────────--static FOCUS_EVENTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());--fn record_focus_event(ev: Value) {- if let Ok(mut g) = FOCUS_EVENTS.lock() {- g.push(ev);- if g.len() > 200 {- let n = g.len() - 200;- g.drain(..n);- }- }-}--fn is_kicad_hwnd(hwnd: u64) -> bool {- if let Ok(Some(i)) = native().window_info(hwnd) {- return wm::is_kicad_exe(&i.exe);- }- kicad_windows().iter().any(|w| w.hwnd == hwnd)-}--/// capture_foreground before, compare after: if a KiCad window now holds the foreground-/// and the user had a non-KiCad window before, push KiCad back once (never activate the-/// user's window: Windows re-activates it naturally) and report the event.-fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {- 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";- let mut graceful_failure = Value::Null;- if !running.is_empty() {- // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;- // the kill is the fallback for a window that will not go.- let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));- close_how = "graceful";- if !closed["success"].as_bool().unwrap_or(false) {- if closed["errorCode"] == json!("unsaved_changes") {- return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,- "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});- }- // Keep WHY the graceful close failed: otherwise the kill overwrites the only- // evidence and the next person sees "force-kill" with no cause.- graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});- closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));- close_how = "force-kill after the graceful close failed";- if !closed["success"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});- }- }- std::thread::sleep(std::time::Duration::from_secs(4));- }- let launched = launch(state, &json!({}));- if !launched["success"].as_bool().unwrap_or(false) {- return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});- }- for _ in 0..80 {- std::thread::sleep(std::time::Duration::from_millis(750));- invalidate();- if !kicad_windows().is_empty() {- sleep_ms(1200); // let the fresh windows settle before moving them- let restored = restore_placement();- let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();- return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",- "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,- "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});- }- }- json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",- "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})-}+ 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})+ }+ /// `z` is the window's position in the OS enumeration, which EnumWindows returns front+ /// first, so a caller can tell which of two windows of a kind the user was looking at+ /// (wiki #48). ab's inventory carries its own z; this is the native fallback's.+ fn from_info(w: &WindowInfo, z: usize) -> Win {+ Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: Some(z as i64), owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }+ }+}++struct Cache {+ at: Option<Instant>,+ rows: Vec<Win>,+ source: &'static str,+}++static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });+const CACHE_TTL: Duration = Duration::from_millis(250);++/// Call after anything that opens, closes, or retitles a KiCad window.+pub(crate) fn invalidate() {+ if let Ok(mut c) = CACHE.lock() {+ c.at = None;+ c.rows.clear();+ }+}++fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {+ match ab::desktop_list_windows() {+ Ok(l) if !(l.windows.is_empty() && l.degraded) => {+ let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {+ hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: w.z, owner: w.owner, pid: w.pid,+ exe: w.image.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung,+ }).collect();+ // ab before 2.1.12 carries no image/owner (they arrived together): fill both+ // from the OS layer when it can, only for rows that lack the exe.+ for r in rows.iter_mut() {+ if r.exe.is_empty() {+ if let Ok(Some(i)) = native().window_info(r.hwnd) {+ r.exe = i.exe;+ if r.owner == 0 { r.owner = i.owner; }+ if r.class_name.is_empty() { r.class_name = i.class_name; }+ }+ }+ }+ Ok((rows, "ad:desktop_list_windows"))+ }+ _ => {+ let rows = plat("window enumeration (list_windows)", native().list_windows())?;+ Ok((rows.iter().filter(|w| w.visible).enumerate().map(|(z, w)| Win::from_info(w, z)).collect(), "local:EnumWindows"))+ }+ }+}++/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.+fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {+ let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;+ let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);+ if stale {+ let (rows, src) = list_all_windows()?;+ c.rows = rows;+ c.source = src;+ c.at = Some(Instant::now());+ }+ let src = c.source;+ if all_windows {+ return Ok((c.rows.clone(), src));+ }+ // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).+ let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();+ let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();+ Ok((rows, src))+}++pub(crate) fn kicad_windows() -> Vec<Win> {+ find_windows(false, false).map(|(w, _)| w).unwrap_or_default()+}++pub(crate) fn windows_of_kind(kind: WindowKind) -> Vec<Win> {+ kicad_windows().into_iter().filter(|w| w.kind() == kind).collect()+}++fn first_of_kind(kind: WindowKind) -> Option<u64> {+ windows_of_kind(kind).first().map(|w| w.hwnd)+}++/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can+/// put it back. Called before anything the bridge does that destroys geometry (a restart, a+/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach+/// nothing, so they are skipped.+pub(crate) fn remember_placement() -> usize {+ let now = utc_stamp();+ let mut mem = window_memory::load();+ let mut learned = 0;+ 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 ──────────────────────────────────────────────────────++static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());+fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{+ let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();+ let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();+ if exact.len()==1{return exact.into_iter().next()}+ if exact.len()>1{return None}+ if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}+ let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();+ if created.len()==1{return created.into_iter().next()}+ if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}+ None+}+fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{+ for _ in 0..rounds.max(1){+ sleep_ms(750);invalidate();+ let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();+ let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));+ if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){+ if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}+ return Some(w)+ }+ }+ None+}++/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a+/// 3D Viewer window appeared.+fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {+ let source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {+ Some(w) => w.pid,+ None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),+ };+ let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect();+ let mut unsupported_menu: Option<Value> = None;+ match menu_invoke_by_text(frame, "3d viewer") {+ Ok(Some((id, _))) => {+ pathways.push(format!("win32menu(id={id})"));+ if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {+ return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));+ }+ }+ Ok(None) => pathways.push("win32menu-not-found".into()),+ Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),+ Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),+ }+ match native().uia_invoke(frame, "3D Viewer") {+ Ok(_) => {+ pathways.push("uia".into());+ if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {+ return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));+ }+ }+ Err(e) if is_not_impl(&e) => {+ // Neither rung exists on this OS: say so instead of "did not open".+ if let Some(u) = unsupported_menu {+ return Err(u);+ }+ pathways.push("uia-unsupported".into());+ }+ Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),+ }+ Ok(None)+}+++/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.++/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.+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};+ assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);+ assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());+ assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());+ assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);+ assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());+ assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);+ }+++ #[test]+ fn footprint_splice_matches_python() {+ let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";+ let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();+ assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));+ let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";+ let modern = format!("(footprint{}", &legacy["(module".len()..]);+ let q = quote_first_layer(&modern);+ assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));+ assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");+ assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());+ let u = pseudo_uuid();+ assert_eq!(u.len(), 36);+ assert_eq!(&u[14..15], "4");+ }++ #[test]+ fn base64_round_trip() {+ assert_eq!(b64_encode(b""), "");+ assert_eq!(b64_encode(b"f"), "Zg==");+ assert_eq!(b64_encode(b"fo"), "Zm8=");+ assert_eq!(b64_encode(b"foo"), "Zm9v");+ assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");+ let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();+ assert_eq!(back, b"\x89PNG\r\n\x1a\n");+ }++ #[test]+ fn stamp_and_foreground_args() {+ let s = utc_stamp();+ assert_eq!(s.len(), 15);+ assert_eq!(&s[8..9], "-");+ assert!(user_wants_foreground(&json!({"foreground": true})));+ assert!(user_wants_foreground(&json!({"showToUser": true})));+ assert!(user_wants_foreground(&json!({"bringToFront": true})));+ assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));+ assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));+ assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);+ }++ #[test]+ fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {+ let mut state = State::new();+ assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());+ assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());+ let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();+ assert_eq!(fk["success"], json!(true));+ assert_eq!(fk["stuckKeys"], json!([]));+ // Without window control (this host) the window verbs answer not_supported_on_platform+ // or an ab-unavailable error, never a hang and never a fake success.+ if !native().capabilities().window_control {+ std::env::remove_var("ADOM_DIRECT_API_URL");+ let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();+ assert_eq!(r["success"], json!(false));+ let code = r["errorCode"].as_str().unwrap_or("");+ assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");+ let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();+ assert_eq!(r["success"], json!(false));+ let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();+ assert_eq!(r["success"], json!(false));+ }+ let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();+ assert_eq!(r["errorCode"], json!("bad_arg"));+ let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();+ assert_eq!(r["errorCode"], json!("missing_arg"));+ let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();+ assert_eq!(r["errorCode"], json!("bad_arg"));+ }++ #[test]+ fn catalog_covers_the_python_window_group() {+ let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();+ for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {+ assert!(names.contains(&n), "{n} missing from the catalog");+ }+ assert!(!names.contains(&"kicad_errors"));+ for v in VERBS {+ assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);+ }+ }+}+++/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.+fn rescan_libraries(state: &mut State, args: &Value) -> Value {+ let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+ if !force {+ let mut refreshed: Vec<u64> = Vec::new();+ for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {+ if let Some(h) = first_of_kind(kind) {+ if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {+ refreshed.push(h);+ }+ }+ }+ if !refreshed.is_empty() {+ // The tree reloads asynchronously; the Python waited 3 s before probing.+ std::thread::sleep(std::time::Duration::from_secs(3));+ invalidate();+ let mut wanted: Vec<(String, String)> = Vec::new();+ for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {+ match spec {+ Value::String(s) if s.contains(':') => {+ let (lib, name) = s.split_once(':').unwrap();+ wanted.push((lib.to_string(), name.to_string()));+ }+ Value::Object(o) => {+ if let Some(name) = o.get("name").and_then(Value::as_str) {+ wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));+ }+ }+ _ => {}+ }+ }+ let mut probed: Vec<Value> = Vec::new();+ if !wanted.is_empty() {+ let fp = first_of_kind(WindowKind::FootprintEditor);+ for (lib, name) in &wanted {+ let (ok, trace, _mech) = match fp {+ Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),+ None => (false, json!({}), "none"),+ };+ probed.push(json!({+ "library": lib, "name": name, "visible": ok,+ "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },+ "navigation": trace,+ }));+ }+ }+ let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));+ let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});+ out["_hint"] = json!(if !wanted.is_empty() && !verified {+ format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",+ probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))+ } else if !wanted.is_empty() {+ "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()+ } else {+ "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()+ });+ post_verb(state, "kicad_rescan_libraries", args, &mut out, None);+ return out;+ }+ }+ // Restart path: the native build has no ownership ledger yet (phase 4), so a running+ // KiCad is treated as the user's unless the caller confirms.+ let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();+ let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);+ if !running.is_empty() && !confirm {+ return json!({"success": false, "errorCode": "rescan_refused",+ "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",+ "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),+ "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});+ }+ // Wiki #48: a fresh process places every window on the primary display, so the user's+ // monitor choice is captured here, while the windows still exist, and restored below.+ let remembered = remember_placement();+ let mut close_how = "nothing was running";+ let mut graceful_failure = Value::Null;+ if !running.is_empty() {+ // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;+ // the kill is the fallback for a window that will not go.+ let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));+ close_how = "graceful";+ if !closed["success"].as_bool().unwrap_or(false) {+ if closed["errorCode"] == json!("unsaved_changes") {+ return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,+ "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});+ }+ // Keep WHY the graceful close failed: otherwise the kill overwrites the only+ // evidence and the next person sees "force-kill" with no cause.+ graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});+ closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));+ close_how = "force-kill after the graceful close failed";+ if !closed["success"].as_bool().unwrap_or(false) {+ return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});+ }+ }+ std::thread::sleep(std::time::Duration::from_secs(4));+ }+ let launched = launch(state, &json!({}));+ if !launched["success"].as_bool().unwrap_or(false) {+ return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});+ }+ for _ in 0..80 {+ std::thread::sleep(std::time::Duration::from_millis(750));+ invalidate();+ if !kicad_windows().is_empty() {+ sleep_ms(1200); // let the fresh windows settle before moving them+ let restored = restore_placement();+ let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();+ return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",+ "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,+ "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});+ }+ }+ json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",+ "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})+}+@@ -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"); } }+@@ -1,1583 +1,1612 @@⋯ 1580 unchanged lines ⋯ assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer"); } }++/// Native rendered text bounds without changing the board.+fn text_spec_from_json(v:&Value)->RResult<kicad_ipc_rs::TextSpec> {+ use kicad_ipc_rs::{TextSpec,TextAttributesSpec,TextHorizontalAlignment as H,TextVerticalAlignment as V};+ let text=v["text"].as_str().ok_or_else(||RoutingError::new("invalid_text","text is required"))?;+ let number=|k:&str,d:f64|->RResult<f64>{let n=v.get(k).map(|x|x.as_f64().unwrap_or(f64::NAN)).unwrap_or(d);if n.is_finite(){Ok(n)}else{Err(RoutingError::new("invalid_text",format!("{k} must be finite")))}};+ let height=number("height",1.0)?;let width=number("width",height)?;let stroke=number("stroke",height*0.15)?;+ if height<=0.0 || width<=0.0 || stroke<=0.0 || height>1000.0 || width>1000.0{return Err(RoutingError::new("invalid_text","positive dimensions in mm required; maximum text size 1000 mm"))}+ let align=match v["align"].as_str().unwrap_or("center"){"left"=>H::Left,"center"=>H::Center,"right"=>H::Right,_=>return Err(RoutingError::new("invalid_text","align must be left, center or right"))};+ let vertical=match v["verticalAlign"].as_str().unwrap_or("center"){"top"=>V::Top,"center"=>V::Center,"bottom"=>V::Bottom,_=>return Err(RoutingError::new("invalid_text","verticalAlign must be top, center or bottom"))};+ Ok(TextSpec{text:text.into(),position_nm:Some(Vector2Nm{x_nm:(number("x",0.0)?*1e6).round() as i64,y_nm:(number("y",0.0)?*1e6).round() as i64}),attributes:Some(TextAttributesSpec{font_name:v["font"].as_str().map(str::to_string),horizontal_alignment:align,vertical_alignment:vertical,angle_degrees:Some(number("rotation",0.0)?),stroke_width_nm:Some((stroke*1e6).round() as i64),size_nm:Some(Vector2Nm{x_nm:(width*1e6).round() as i64,y_nm:(height*1e6).round() as i64}),mirrored:v["mirrored"].as_bool().unwrap_or(false),bold:v["bold"].as_bool().unwrap_or(false),italic:v["italic"].as_bool().unwrap_or(false),multiline:text.contains('\n'),..Default::default()}),hyperlink:None})+}+pub fn text_bounds(ctx:&Ctx,args:&Value)->RResult<Value>{+ let rows=args["texts"].as_array().ok_or_else(||RoutingError::new("invalid_text","texts must be a nonempty array"))?;+ if rows.is_empty() || rows.len()>2000{return Err(RoutingError::new("invalid_text","texts must contain 1..2000 entries"))}+ let specs=rows.iter().map(text_spec_from_json).collect::<RResult<Vec<_>>>()?;+ let s=connect(ctx,args)?;let revision=s.revision()?;if args.get("expectedRevision").is_some(){check_revision(args,&revision)?;}+ let mut bounds=Vec::new();+ for (row,spec) in rows.iter().zip(specs){let b=s.client.get_text_extents(spec).map_err(map_err)?;bounds.push(json!({"id":row["id"],"text":row["text"],"box":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]}));}+ Ok(json!({"success":true,"mutated":false,"revision":revision,"source":"native-GetTextExtents","units":"mm","texts":bounds,"_hint":"Native axis-aligned rendered bounds at the requested rotation and justification. Keep holes, exposed pads and component bodies as separate obstacles. This does not place text or certify readability."}))+}+#[cfg(test)] mod text_metric_tests {+ use super::*;+ #[test] fn alignment_and_bottom_mirroring(){+ let s=text_spec_from_json(&json!({"text":"MC10\nDSHOT","x":12.5,"height":0.3,"stroke":0.05,"align":"right","mirrored":true,"rotation":90})).unwrap();assert_eq!(s.position_nm.unwrap().x_nm,12_500_000);let a=s.attributes.unwrap();assert_eq!(a.horizontal_alignment,kicad_ipc_rs::TextHorizontalAlignment::Right);assert!(a.mirrored&&a.multiline);assert_eq!(a.size_nm.unwrap().y_nm,300_000);+ assert!(text_spec_from_json(&json!({"text":"x","height":0})).is_err());assert!(text_spec_from_json(&json!({"text":"x","align":"random"})).is_err());+ }+}+@@ -1,1492 +1,1505 @@⋯ 17 unchanged lines ⋯ pub static VERBS: &[Verb] = &[ Verb {+ name: "kicad_text_bounds",+ summary: "Measure candidate silkscreen text using native KiCad text metrics without editing.",+ mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,+ input: "{filePath, expectedRevision?, texts:[{id?,text,x?,y?,height?,width?,stroke?,rotation?,align?:left|center|right,verticalAlign?:top|center|bottom,mirrored?,font?,bold?,italic?}]}; dimensions in mm",+ example: "kicad_text_bounds {\"filePath\":\"C:/p/b.kicad_pcb\",\"texts\":[{\"text\":\"DSHOT\",\"height\":0.3,\"align\":\"left\"}]}",+ hint: "Use native bounds before solving labels. Mirroring and justification affect the box. No text is created by this read-only command.",+ related: &["kicad_routing_state"],+ pitfalls: &["Bodies, holes and exposed pads remain separate obstacles."],+ },+ Verb { name: "kicad_routing_state", summary: "Inspect the live editor: pads, copper, revision and KiCad-measured net connectivity.", mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,⋯ 147 unchanged lines ⋯ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> { Some(match command { "kicad_routing_state" => live(state, args, Live::State),+ "kicad_text_bounds" => live(state,args,Live::TextBounds), "kicad_route_net" => live(state, args, Live::RouteNet), "kicad_remove_route" => live(state, args, Live::RemoveRoute), "kicad_routing_validate" => live(state, args, Live::Validate),⋯ 19 unchanged lines ⋯ #[derive(Clone, Copy)] enum Live { State,+ TextBounds, RouteNet, RemoveRoute, Validate,⋯ 14 unchanged lines ⋯ }; let result = match which { Live::State => ipc::routing_state(&ctx, args),+ Live::TextBounds => ipc::text_bounds(&ctx,args), Live::RouteNet => ipc::route_net(&ctx, args), Live::RemoveRoute => ipc::remove_route(&ctx, args), Live::Validate => ipc::routing_validate(&ctx, args),⋯ 1271 unchanged lines ⋯
Comments
Log in to comment.
Merged by hand and shipped as KiCad Bridge 1.0.21 on insiders, with hardware verification. Closing this PR rather than pressing merge. Why, because it will matter for your next one:
This PR's diffs are whole-file replacements, and its
verbs_windows.rsalso removed two things unrelated to its purpose: the 1.0.14 restore-on-open placement code (restore_placement_one, its calls fromopen_editor_frameandopen_file) and the 1.0.15 front-window-wins rule inremember_placement. Both are part of the #48 fix proven on CRR the night before. Merging on the page would also have written the PR'sverbs_routing.rsover 1.0.18-1.0.20 (the #93 work).So I did a three-way merge against the PR's true base. Its "before" files are byte-identical to 87a8f65 (1.0.17), apart from one trailing newline. Every line you added is in; the six hunks that deleted the placement code are not. Next time, rebasing from the page repo's current head would avoid the round trip, and a diff that touches only what changed would make a stray removal easy to spot.
Verified on ConfRoomROG, 1.0.21, KiCad 10.0.5, with two boards open in two pcbnew processes, each with its own 3D Viewer:
kicad_open_3d_viewer {pcbHwnd:200328}(esc-g431-astra-models, pid 44200)kicad_open_3d_viewer {pcbHwnd:788614}(esc-g431-astra, pid 4168)pcbHwnd:123456source_editor_missing, nothing substituted, no window openedkicad_3d_view pan_left x4, thenpan_right x4kicad_text_bounds"DSHOT", 1.0 mm vs 0.5 mmmutated:falseReviewer's notes, none blocking: the new code is written without rustfmt spacing (
fn viewer_for_frame(frame:u64,...)), which reads differently from the files around it. When the binding refuses as ambiguous, the caller currently gets the generic "no viewer appeared" path; a distinctviewer_binding_ambiguouswould tell an AI to pass the viewer hwnd explicitly rather than retry.VIEWER_SOURCE_BINDINGSis never pruned, which is harmless because every hit is re-checked against live windows.Thanks, this was a real fix for a bug that could certify the wrong board.