← Commit history

1.0.13: NATIVE (Rust) build. 1.0.13: KiCad window placement survives a bridge restart and a cold spawn, so a multi-monitor user keeps their monitor (wiki #48); a bridge restart closes KiCad gracefully first and kills only if that fails; kicadRunning and restartNeeded come from the real process scan instead of always answering unknown-restart-anyway (wiki #63); a symbol the editor cannot navigate to is no longer called library_cache_stale when this very call opened the editor (wiki #54, #71).

John Lauer ·b04d86b56a ·1mo ago ·parent 56c4aea
11 files changed +214−25
rust/Cargo.lock+3−3
@@ -244,7 +244,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.12"+version = "1.0.13" dependencies = [  "kicad-core",  "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.12"+version = "1.0.13" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.12"+version = "1.0.13" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.12"+version = "1.0.13" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_install.rs+1−1
@@ -79,7 +79,7 @@ pub static VERBS: &[Verb] = &[         related: &["kicad_install_library", "kicad_lint_library", "kicad_show_library", "kicad_pcm_install"],         pitfalls: &[             "a RUNNING KiCad reads lib tables at startup only: the user must restart KiCad themselves (never close it for them)",-            "this build has no process scan yet: kicadRunning is null and restartNeeded is always true (the safe answer)",+            "kicadRunning comes from a process scan for kicad/pcbnew/eeschema: true means the user must restart KiCad before the library appears, false means nothing needs restarting, null means this OS layer has no scan (restartNeeded is then true, the safe answer)",             "capture:true is window-phase (no Symbol Editor is opened, no frame taken): proofSkipped says so; kicad_show_library once the window verbs land",             "zip entries with .. or absolute paths are refused; zip64 and non-deflate entries are not supported",             "the zip is capped at 256MB and 512MB unpacked",
rust/crates/kicad-bridge/src/verbs_maint.rs+2−2
@@ -51,7 +51,7 @@ pub static VERBS: &[Verb] = &[             "a RUNNING KiCad does not see the new libraries until restarted",             "download is sha256-verified against the repository; a mismatch refuses",             "an update removes the old package directories first; keep_on_update patterns are not honoured by the bridge",-            "the native build does not detect a running KiCad yet: kicadRunning is null and restartNeeded is always true",+            "kicadRunning is a real process scan: restartNeeded is true only when KiCad is actually running, or when the scan is unavailable (kicadRunning null, the safe answer)",         ],     },     Verb {@@ -74,7 +74,7 @@ pub static VERBS: &[Verb] = &[         related: &["kicad_pcm_remove_repository", "kicad_pcm_install", "kicad_pcm_list"],         pitfalls: &[             "a KiCad that is running NOW overwrites kicad.json when it saves settings on exit: close it first when in doubt",-            "the native build cannot tell whether KiCad is running: kicadRunning is null",+            "kicadRunning says whether KiCad holds the file right now; null means this OS layer has no process scan, so close KiCad yourself when in doubt",         ],     },     Verb {
rust/crates/kicad-bridge/src/verbs_misc.rs+1−1
@@ -20,7 +20,7 @@ pub static VERBS: &[Verb] = &[         pitfalls: &[             "a running KiCad rewrites kicad_common.json on exit and can undo the change: close KiCad first",             "does not restart KiCad for you",-            "the native build does not detect a running KiCad yet: kicadRunning is null, so close KiCad yourself before flipping",+            "kicadRunning true means KiCad will overwrite this file from memory when it exits: close it (kicad_close) before flipping. null means this OS layer has no process scan, so close KiCad yourself when in doubt",         ],     },     Verb {
rust/crates/kicad-bridge/src/verbs_windows.rs+113−9
@@ -41,7 +41,8 @@ use kicad_core::libraries::{self, LibCtx}; use kicad_core::windows_model as wm; use kicad_core::windows_model::{DialogAction, WindowKind}; use kicad_core::{ab, bridge_log, kicad_common, pcm};-use kicad_platform::{native, unsupported, WindowInfo};+use kicad_core::window_memory::{self, Restore};+use kicad_platform::{native, unsupported, Placement, WindowInfo};  // ── Catalog ─────────────────────────────────────────────────────────────────── @@ -52,9 +53,10 @@ pub static VERBS: &[Verb] = &[         mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,         input: "{\"project\"?: \"C:/.../x.kicad_pro\", \"waitSeconds\"?: 15, \"traceMasks\"?: \"KICAD_...\"}",         example: "kicad_launch {}",-        hint: "Idempotent: no-op with a hint if KiCad is already up. Opens in the BACKGROUND (no focus steal). Optional project: path to a .kicad_pro.",+        hint: "Idempotent: no-op with a hint if KiCad is already up. Opens in the BACKGROUND (no focus steal). Optional project: path to a .kicad_pro. Each window is put back on the monitor the user last had it on (placementRestored says what moved); a rect on a monitor that is no longer connected is left alone.",         related: &["kicad_open_editors", "kicad_window_info", "kicad_readiness", "kicad_upgrade"],-        pitfalls: &["first launch on a fresh install can show a setup wizard; kicad_window_info self-heals benign dialogs"],+        pitfalls: &["first launch on a fresh install can show a setup wizard; kicad_window_info self-heals benign dialogs",+            "placement memory is per window KIND (hwnds do not survive a restart) and lives in ~/.adom/kicad-bridge-placement.json; a box that has never run KiCad under the bridge has none, so the first launch lands wherever Windows puts it"],     },     Verb {         name: "kicad_open_board",@@ -267,7 +269,7 @@ pub static VERBS: &[Verb] = &[         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. 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).",+        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."],     },@@ -610,6 +612,71 @@ 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, &current, 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) }@@ -1424,11 +1491,15 @@ pub(crate) fn launch(state: &mut State, args: &Value) -> Value {     }     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,+            "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"],         });@@ -1713,7 +1784,13 @@ fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, l         });     }     let title = title_of(frame);-    let stale = symbol_on_disk(info, symbol_name) && !rescan;+    // 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);@@ -1733,6 +1810,11 @@ fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, l         "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 }@@ -2151,6 +2233,9 @@ fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> { 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}}),@@ -2974,10 +3059,25 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value {             "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),             "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});     }+    // Wiki #48: a fresh process places every window on the primary display, so the user's+    // monitor choice is captured here, while the windows still exist, and restored below.+    let remembered = remember_placement();+    let mut close_how = "nothing was running";     if !running.is_empty() {-        let closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));+        // 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) {-            return json!({"success": false, "errorCode": "restart_close_failed", "close": closed});+            if closed["errorCode"] == json!("unsaved_changes") {+                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,+                    "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});+            }+            closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));+            close_how = "force-kill after the graceful close failed";+            if !closed["success"].as_bool().unwrap_or(false) {+                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how});+            }         }         std::thread::sleep(std::time::Duration::from_secs(4));     }@@ -2989,8 +3089,12 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value {         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",-                "_hint": "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."});+                "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how,+                "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});         }     }     json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",
rust/crates/kicad-core/src/install.rs+14−6
@@ -1373,10 +1373,17 @@ fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extra         report["modelRefsRewritten"] = fp_res["data"].get("modelRefsRewritten").cloned().unwrap_or(json!(0));     } -    // The native build has no process scan yet (same as kicad_pcm_install):-    // a running KiCad is not detected, so the safe answer is given.-    report["kicadRunning"] = Value::Null;-    report["restartNeeded"] = json!(true);+    // Whether the user has to restart KiCad is a fact about their machine, not a guess:+    // the process scan answers it where the OS layer has one (wiki #63).+    let (running, restart, why_unknown, pids) = crate::install_kicad::running_report();+    report["kicadRunning"] = running.clone();+    report["restartNeeded"] = json!(restart);+    if let Some(why) = &why_unknown {+        report["kicadRunningUnknownBecause"] = json!(why);+    }+    if !pids.is_empty() {+        report["kicadPids"] = json!(pids);+    }      let mut parts = vec![format!("Library '{lib_name}' installed: {} symbols", report["symbols"])];     if report["footprints"].as_u64().unwrap_or(0) > 0 {@@ -1385,13 +1392,14 @@ fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extra     if report["modelsInstalled"].as_u64().unwrap_or(0) > 0 {         parts.push(format!("{} 3D models", report["modelsInstalled"]));     }-    let summary = parts.join(", ") + ". KiCad reads library tables at startup, so a KiCad that was already open must be restarted to see it.";+    let restart_line = crate::install_kicad::restart_sentence(&running, &pids, "the library");+    let summary = parts.join(", ") + ". " + &restart_line;      let mut result = json!({         "success": true,         "output": summary,         "data": report,-        "_hint": "The bundle is installed and registered in both lib tables. This build cannot tell whether KiCad is running (kicadRunning is null): KiCad reads sym-/fp-lib-table at startup only, so if it was open during the install the user must restart it before the library appears. Never close their KiCad for them.",+        "_hint": format!("The bundle is installed and registered in both lib tables. {restart_line}"),     });     if args.get("capture").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(false) {         result["proofSkipped"] = json!("capture is window-phase: this build opens no Symbol Editor and takes no frame; call kicad_show_library once the window verbs land");
rust/crates/kicad-core/src/install_kicad.rs+52
@@ -347,6 +347,35 @@ pub fn kicad_running() -> (Option<bool>, Vec<u32>, Option<String>) {     } } +/// What a library, PCM or settings verb should report about a running KiCad:+/// `(kicadRunning, restartNeeded, whyUnknown, pids)`.+///+/// KiCad reads sym-/fp-lib-table and kicad_common.json at startup only, so what the caller+/// actually needs to know is whether the user has to restart. A KiCad that is NOT running+/// needs no restart at all, one that IS running does, and one the OS layer cannot see is+/// reported as unknown with `restartNeeded` true, which is the safe answer (wiki #63).+pub fn running_report() -> (Value, bool, Option<String>, Vec<u32>) {+    running_report_from(kicad_running())+}++fn running_report_from(r: (Option<bool>, Vec<u32>, Option<String>)) -> (Value, bool, Option<String>, Vec<u32>) {+    match r.0 {+        Some(true) => (json!(true), true, None, r.1),+        Some(false) => (json!(false), false, None, Vec::new()),+        None => (Value::Null, true, r.2, Vec::new()),+    }+}++/// The sentence a verb puts in its hint about restarting, given `running_report`'s first value.+/// `what` names what the user is waiting to see, e.g. "the library".+pub fn restart_sentence(running: &Value, pids: &[u32], what: &str) -> String {+    match running.as_bool() {+        Some(true) => format!("KiCad is running (pid(s) {pids:?}) and reads its tables at startup only, so the user must restart it before {what} appears. Never close their KiCad for them."),+        Some(false) => format!("No KiCad is running, so there is nothing to restart: {what} is there the next time KiCad starts."),+        None => format!("This build cannot tell whether KiCad is running (kicadRunning is null), and KiCad reads its tables at startup only, so if it was open during this call the user must restart it before {what} appears. Never close their KiCad for them."),+    }+}+ // --------------------------------------------------------------------------- // The verb // ---------------------------------------------------------------------------@@ -844,4 +873,27 @@ mod tests {         assert!(r["error"].as_str().unwrap().contains("OS package path"), "{r}");         assert_eq!(r["latestVersion"], json!("10.0.6"));     }++    // wiki #63: the restart answer follows the process scan, and a KiCad that is not running+    // is told plainly that nothing needs restarting instead of the old always-true guess.+    #[test]+    fn running_report_follows_the_scan() {+        let up = running_report_from((Some(true), vec![4120, 5008], None));+        assert_eq!(up.0, json!(true));+        assert!(up.1, "a running KiCad has to be restarted");+        assert_eq!(up.3, vec![4120, 5008]);+        assert!(restart_sentence(&up.0, &up.3, "the library").contains("4120"));++        let down = running_report_from((Some(false), vec![], None));+        assert_eq!(down.0, json!(false));+        assert!(!down.1, "nothing to restart when KiCad is not running");+        let says = restart_sentence(&down.0, &down.3, "the library");+        assert!(says.contains("nothing to restart"), "{says}");++        let unknown = running_report_from((None, vec![], Some("processes: not supported on this platform".into())));+        assert!(unknown.0.is_null());+        assert!(unknown.1, "unknown stays on the safe answer");+        assert_eq!(unknown.2.as_deref(), Some("processes: not supported on this platform"));+        assert!(restart_sentence(&unknown.0, &unknown.3, "the library").contains("cannot tell"));+    } }
rust/crates/kicad-core/src/kicad_common.rs+16−2
@@ -129,8 +129,22 @@ pub fn ipc_api(root: Option<&Path>, args: &Value, now_utc: &str) -> Value {             result["needsRestart"] = json!(true);             result["backup"] = json!(backup.to_string_lossy().replace('\\', "/"));             result["changedAt"] = json!(now_utc);-            result["kicadRunning"] = Value::Null;-            result["_hint"] = json!("Written to kicad_common.json. KiCad reads it at start: kicad_close, then launch again, then kicad_routing_state will connect. A running KiCad that saves its preferences on exit overwrites this change, so close KiCad first when in doubt (the native build cannot tell whether KiCad is running: kicadRunning is null).");+            let (running, _, why_unknown, pids) = crate::install_kicad::running_report();+            result["kicadRunning"] = running.clone();+            if let Some(why) = &why_unknown {+                result["kicadRunningUnknownBecause"] = json!(why);+            }+            if !pids.is_empty() {+                result["kicadPids"] = json!(pids);+            }+            let danger = match running.as_bool() {+                // A running KiCad rewrites kicad_common.json from memory when it exits, which+                // silently undoes this edit: say so as a fact, not as a caveat (wiki #63).+                Some(true) => format!("KiCad IS running right now (pid(s) {pids:?}), and it rewrites kicad_common.json from memory when it exits, which would undo this edit: close KiCad (kicad_close), then launch it again."),+                Some(false) => "No KiCad is running, so nothing will overwrite this edit: the next launch picks it up.".to_string(),+                None => "This build cannot tell whether KiCad is running (kicadRunning is null). A running KiCad that saves its preferences on exit overwrites this change, so close KiCad first when in doubt.".to_string(),+            };+            result["_hint"] = json!(format!("Written to kicad_common.json. KiCad reads it at start: kicad_close, then launch again, then kicad_routing_state will connect. {danger}"));         }         _ => {             result["_hint"] = json!(format!(
rust/crates/kicad-core/src/lib.rs+1
@@ -22,6 +22,7 @@ pub mod ipc; pub mod uninstall; pub mod ab; pub mod windows_model;+pub mod window_memory; pub mod progress; pub mod install_kicad; pub mod demo;
rust/crates/kicad-core/src/lint.rs+10
@@ -1393,6 +1393,16 @@ mod tests {         let na = vec![json!({"available": false, "ok": null, "symbols": []})];         let h = library_hint(&[], 0, 0, 2, 0, &[], &na);         assert!(h.contains("format version(s) unspecified. KiCad's own parser was NOT consulted"));+        // wiki #103: footprints alone are not a missing kicad-cli. kicad-cli lints symbol+        // libraries and has no footprint verdict, so say that instead of sending the caller+        // hunting for an executable that is installed and working.+        let h = library_hint(&[], 0, 0, 0, 3, &[20260206], &[]);+        assert!(h.starts_with("✅ 3 footprint(s) balanced by the BRIDGE's reader"), "{h}");+        assert!(h.contains("no footprint verdict to give"), "{h}");+        assert!(!h.contains("kicad-cli not found"), "a working kicad-cli must not be blamed: {h}");+        // a symbol library with no verdict still reads as a missing tool+        let h = library_hint(&[], 0, 0, 1, 0, &[20231120], &[]);+        assert!(h.contains("NOT consulted (kicad-cli not found)"), "{h}");     }      #[test]