app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
rust: show family, progress registry (verb_times, operations), kicad_upgrade with silent install; alpha.3, 94 verbs, 180 tests
13 files changed
+2966−38
rust/Cargo.lock+3−3rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/verbs.rs+6rust/crates/kicad-bridge/src/verbs_maint.rs+30−4rust/crates/kicad-bridge/src/verbs_show.rs+967−6rust/crates/kicad-bridge/src/verbs_windows.rs+21−21rust/crates/kicad-core/src/install_kicad.rs+847−1rust/crates/kicad-core/src/progress.rs+823−1rust/crates/kicad-platform/src/lib.rs+19rust/crates/kicad-platform/src/linux.rs+29rust/crates/kicad-platform/src/macos.rs+29rust/crates/kicad-platform/src/win/install.rs+176rust/crates/kicad-platform/src/windows.rs+15−1rust/Cargo.lock+3−3@@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.0-alpha.2"+version = "1.0.0-alpha.3" dependencies = [ "kicad-core", "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.0-alpha.2"+version = "1.0.0-alpha.3" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -280,7 +280,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.0-alpha.2"+version = "1.0.0-alpha.3" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.0-alpha.2"+version = "1.0.0-alpha.3" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs.rs+6@@ -33,8 +33,11 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal }; // The ab callback client forwards this request's caller identity (headers first, args.caller as fallback). kicad_core::ab::set_caller(kicad_core::ab::caller_from(args, &_caller.thread, &_caller.container, &_caller.reason));+ // Every run is timed and logged; the 20 instrumented verbs get the terminal progress block.+ let mut prog = kicad_core::progress::begin(command, crate::verbs_show::kicad_running); // Verb groups ported later live in their own files; each returns Some when it owns the verb. if let Some(mut out) = crate::groups::dispatch(state, command, args) {+ prog.attach(&mut out); if let Some(o) = out.as_object_mut() { o.entry("mechanism").or_insert(json!(verb.mechanism.as_str())); }@@ -56,6 +59,7 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal "kicad_uninstall" => { let info = state.kicad_info(); kicad_core::uninstall::handle(&info.installs, args) } _ => fail("not_implemented", format!("{command} is catalogued but not implemented in this phase"), "Use the Python bridge for this verb until the phase that ports it lands."), };+ prog.attach(&mut out); if let Some(o) = out.as_object_mut() { o.entry("mechanism").or_insert(json!(verb.mechanism.as_str())); }@@ -75,6 +79,8 @@ fn status(state: &mut State) -> Value { "installed": info.installed, "kicadVersion": info.version, "install": info.primary(),+ "operations": kicad_core::progress::operations(None, 10),+ "_operationsHint": kicad_core::progress::OPERATIONS_HINT, "_hint": if info.installed { "KiCad found. Headless verbs (run_drc, run_erc) are ready." } else { "KiCad not found here. kicad_readiness lists the alternatives." }, }) }
rust/crates/kicad-bridge/src/verbs_maint.rs+30−4@@ -1,7 +1,7 @@ //! Verb group "maint": kicad_model_check, kicad_pcm_list, kicad_pcm_install,-//! kicad_pcm_uninstall, kicad_check_for_updates. Ported from handlers/model_check.py,-//! handlers/pcm.py and the read-only half of handlers/upgrade.py; the installer itself-//! (kicad_upgrade) is a later phase.+//! kicad_pcm_uninstall, kicad_check_for_updates, kicad_upgrade. Ported from+//! handlers/model_check.py, handlers/pcm.py and both halves of handlers/upgrade.py (the+//! installer orchestration lives in kicad_core::install_kicad). use std::time::Duration; use serde_json::{json, Value};@@ -9,7 +9,7 @@ use serde_json::{json, Value}; use crate::catalog::{Mechanism, Verb}; use crate::util::State; use kicad_core::model_check::{self, Ctx};-use kicad_core::{pcm, updates};+use kicad_core::{install_kicad, pcm, updates}; pub static VERBS: &[Verb] = &[ Verb {@@ -69,6 +69,22 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_upgrade", "kicad_list_versions"], pitfalls: &["read-only: it does NOT install; run kicad_upgrade to apply"], },+ Verb {+ name: "kicad_upgrade",+ summary: "Install/upgrade KiCad via the official installer (agent installs for the user).",+ mechanism: Mechanism::Cli, risk: "exec", timeout_sec: 900,+ input: "{\"version\"?: \"10.0.6\", \"scope\"?: \"allusers\"|\"currentuser\", \"diagnoseOnly\"?: bool, \"force\"?: bool, \"reDownload\"?: bool}",+ example: "kicad_upgrade {}",+ hint: "Downloads the official downloads.kicad.org installer + silent /S installs; installs from scratch too. The agent does this FOR the user: never hand them a manual download. Default scope is currentuser (per-user, NO admin, NO UAC) unless the bridge runs elevated; the installer is cached under <LOCALAPPDATA>/Adom Bridge/kicad-installers and reused when its size matches the server's. diagnoseOnly:true returns the elevation/UAC facts and cache state without downloading or running anything. Refuses while kicad.exe, pcbnew.exe or eeschema.exe run: kicad_close first.",+ related: &["kicad_check_for_updates", "kicad_readiness", "kicad_list_versions", "kicad_close"],+ pitfalls: &[+ "Windows only: macOS and Linux get unsupported_platform with the OS package path named (installerKind dmg / distro-package)",+ "scope allusers needs an elevated bridge; a limited token gets NsisMultiUser rc=666661 (elevation restricted), bare /S would be rc=666660 (invalid parameters), so the bridge always passes a scope flag",+ "blocks until the installer finishes (a ~1 GB download plus a few minutes of install; 900 s deadline, killed on timeout)",+ "never run mid-design-work: the verb refuses while KiCad is running rather than installing over open files",+ "the first GUI open after install is wizard-free: the new version's kicad_common.json, eeschema.json, kicad.json and lib tables are seeded right after the install",+ ],+ }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {@@ -94,6 +110,16 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> } out }+ "kicad_upgrade" => {+ let info = state.kicad_info();+ let out = install_kicad::handle(&info.installs, args, &updates::http_fetch);+ // server.py: a successful install refreshes the detection cache so open_*,+ // list_versions and readiness see the new KiCad without a bridge restart.+ if out.get("upgraded").and_then(Value::as_bool).unwrap_or(false) {+ state.refresh();+ }+ out+ } _ => return None, }; Some(out)
rust/crates/kicad-bridge/src/verbs_show.rs+967−6@@ -1,11 +1,972 @@-//! Verb group "show". Placeholder until phase 3b lands.-use serde_json::Value;+//! Verb group "show": the eight `kicad_show_*` surface verbs plus `kicad_progress` and+//! `kicad_verb_times`. Phase 3b of docs/rust-port-plan.md, ported from handlers/show.py and+//! the progress/status/verb_times handlers in server.py.+//!+//! One clean, self-documenting verb per KiCad surface, so a caller (ab, a web demo, an AI)+//! NEVER hand-assembles a workflow. Every one is an arg-normalising wrapper over the window+//! group's open functions (verbs_windows.rs): same background-first behaviour, same+//! verified loads, same ladder (`mechanism` records the rung the open used: existing, menu,+//! uia, post, spawn), plus the three things that make a show verb a show verb:+//!+//! - **Verify the surface (0.9.172, wiki #42).** A show verb has ONE job: put THAT surface+//! on screen. If its window is not there the verb FAILS (`surface_window_missing`) even+//! when every internal step reported success, because the caller screenshots whatever the+//! bridge says succeeded, and a false success manufactures wrong evidence.+//! - **Evidence from the verified window (2026-08-19).** `evidence` is a background capture+//! through ab of the same hwnd that satisfied the verb, polled until the GL canvas has+//! painted (0.9.202: the blank thumbnails were a TIMING problem, the editor answers+//! "ready" before its canvas draws), with `canvasRendered` said out loud either way.+//! - **Warm reuse (wiki #35).** An editor already on the exact library:part is returned+//! in about a second instead of running the cold path (KiCad re-indexes every library+//! when an editor opens). Exact title comparison, never a substring (#49, #54).+//!+//! `shot` (wiki #45, adom-bridge#91) is the opt-in frame for a BROWSER caller: `image`+//! (base64) plus `thumbnailDataUrl` when ab handed back its resized `.safe` variant. The+//! native build has no image decoder, so when ab returns only the full capture the shot+//! says so (`thumbnailUnavailable`) rather than pretending.+//!+//! Dropped from the Python on purpose: `ensure_plugin_ready` (the SWIG reverse-bridge+//! plugin does not exist in the native build; the background menu/UIA rungs need no+//! plugin) and the plugin-LED wording in hints. -use crate::catalog::Verb;-use crate::util::State;+use std::collections::HashMap;+use std::sync::Mutex;+use std::time::{Duration, Instant}; -pub static VERBS: &[Verb] = &[];+use serde_json::{json, Value}; -pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {+use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs_windows as win;+use kicad_core::model_check::{self, Ctx};+use kicad_core::progress;+use kicad_core::windows_model as wm;+use kicad_core::windows_model::WindowKind;+use kicad_platform::native;++// ── Catalog (server.py _VERB_CATALOG, timeouts from bridge.json) ──────────────++pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_show_project",+ summary: "Show the whole electronics PROJECT: the KiCad Project Manager (top of the design tree).",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+ input: "{\"filePath\"?: \"C:/.../x.kicad_pro\", \"project\"?: \"C:/.../x.kicad_pro\", \"capture\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_show_project {\"filePath\":\"C:/Users/john/proj/board.kicad_pro\"}",+ hint: "kicad_show_project {\"filePath\":\"...kicad_pro\"} opens the project manager on a project; no path just brings it up. Schematic/board/editors all hang off this.",+ related: &["kicad_show_schematic", "kicad_show_2d_board", "kicad_launch"],+ pitfalls: &["absolute Windows path to the .kicad_pro; no %VAR% expansion", "idempotent like kicad_launch: with KiCad already running a filePath is not switched to, and the response says so (_projectNote)"],+ },+ Verb {+ name: "kicad_show_symbol",+ summary: "Show a schematic symbol in the Symbol Editor (design/library surface).",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+ input: "{\"symbolName\": \"R\", \"libraryName\"?: \"Device\", \"rescan\"?: bool, \"capture\"?: bool, \"captureMaxWidth\"?: 640, \"waitInline\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_show_symbol {\"symbolName\":\"R\",\"libraryName\":\"Device\"}",+ hint: "kicad_show_symbol {\"symbolName\":\"R\",\"libraryName\":\"Device\"}: verified background load. libraryName alone browses the library. capture:true adds `shot` (a base64 frame a browser can paint); `evidence` is always attempted.",+ related: &["kicad_show_footprint", "kicad_show_3d_chip", "kicad_show_library"],+ pitfalls: &["the symbol must be in an installed sym-lib-table library", "a warm editor already on the exact library:symbol is reused (reused:true, wiki #35): about a second instead of about a minute; rescan:true forbids the reuse"],+ },+ Verb {+ name: "kicad_show_footprint",+ summary: "Show a footprint in the Footprint Editor.",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+ input: "{\"footprintName\": \"R_0603_1608Metric\", \"library\"?: \"Resistor_SMD\", \"capture\"?: bool, \"captureMaxWidth\"?: 640, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_show_footprint {\"footprintName\":\"R_0603_1608Metric\",\"library\":\"Resistor_SMD\"}",+ hint: "kicad_show_footprint {\"footprintName\":\"R_0603_1608Metric\",\"library\":\"Resistor_SMD\"}. library alone browses.",+ related: &["kicad_show_symbol", "kicad_show_3d_chip"],+ pitfalls: &["cold Footprint Editor spawns a fresh pcbnew + loads all fp libs: allow ~20-30s", "the native build has no reverse-bridge plugin to wait for: the background menu and UIA rungs open the editor directly"],+ },+ Verb {+ name: "kicad_show_3d_chip",+ summary: "Show a single part in 3D (the footprint's 3D view).",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 300,+ input: "{\"footprintName\"?: \"...\", \"library\"?: \"...\", \"capture\"?: bool, \"captureMaxWidth\"?: 640, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_show_3d_chip {\"footprintName\":\"QFN-56-1EP_7x7mm\",\"library\":\"Adom\"}",+ hint: "kicad_show_3d_chip {\"footprintName\":...,\"library\":...} loads the footprint then its 3D viewer. No args = 3D of whatever footprint is loaded. `shows.verified` says whether the viewer was rendered from the part you asked for; `models` says whether the part even has a resolvable 3D model (#88).",+ related: &["kicad_show_footprint", "kicad_show_3d_board", "kicad_model_check"],+ pitfalls: &["software-OpenGL boxes render slowly; the window can self-raise late (guarded)", "the 3D Viewer's own title carries no part identity (wiki #42): `shows` is read from the Footprint Editor it was rendered from, and a mismatch fails with wrong_part_in_3d_view"],+ },+ Verb {+ name: "kicad_show_schematic",+ summary: "Show a schematic (2D) in the Schematic Editor.",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+ input: "{\"filePath\": \"C:/.../x.kicad_sch\", \"waitSeconds\"?: 30, \"capture\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_show_schematic {\"filePath\":\"C:/Users/john/proj/board.kicad_sch\"}",+ hint: "kicad_show_schematic {\"filePath\":\"...kicad_sch\"}. Prefer kicad_lint_schematic first for unfamiliar files.",+ related: &["kicad_show_2d_board", "kicad_show_3d_board", "kicad_lint_schematic"],+ pitfalls: &["absolute Windows path; no %VAR% expansion", "open the top-level .kicad_sch, not a sub-sheet"],+ },+ Verb {+ name: "kicad_show_2d_board",+ summary: "Show the 2D board layout in the PCB Editor.",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"waitSeconds\"?: 30, \"capture\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_show_2d_board {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+ hint: "kicad_show_2d_board {\"filePath\":\"...kicad_pcb\"}. Prefer kicad_lint_board / kicad_run_drc first.",+ related: &["kicad_show_3d_board", "kicad_show_schematic", "kicad_lint_board"],+ pitfalls: &["absolute Windows path; no %VAR% expansion", "if the board is already open you get the existing window, not a fresh reload"],+ },+ Verb {+ name: "kicad_show_3d_board",+ summary: "Show the whole board in 3D (the board's 3D viewer).",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 300,+ input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"reason\"?: \"...\", \"capture\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_show_3d_board {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+ hint: "kicad_show_3d_board {\"filePath\":\"...kicad_pcb\"} opens the board then its 3D viewer. No filePath = 3D of the open board. With filePath, `models` reports board-wide 3D model coverage from the file (#88) so a viewer full of bare pads comes with the reason.",+ related: &["kicad_show_2d_board", "kicad_show_3d_chip", "kicad_model_check"],+ pitfalls: &["3D render self-raise is late on slow boxes: the one-shot focus check pushes it back once (the sentinel guardian is phase 4)", "3D models only render if the footprints reference installed model files; read `models` and `_modelHint`"],+ },+ Verb {+ name: "kicad_show_library",+ summary: "Browse a symbol or footprint library (no part loaded).",+ mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+ input: "{\"libraryName\": \"Device\", \"kind\"?: \"symbol\"|\"footprint\", \"symbolName\"?: \"...\", \"footprintName\"?: \"...\", \"capture\"?: bool, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+ example: "kicad_show_library {\"libraryName\":\"Device\"}",+ hint: "kicad_show_library {\"libraryName\":\"Device\"} (add kind:\"footprint\" for the fp side). Filters the tree to the library. A symbolName/footprintName passed alongside is honoured, not dropped (#49 follow-up).",+ related: &["kicad_show_symbol", "kicad_show_footprint", "kicad_list_symbols"],+ pitfalls: &["the library must be registered in the sym-/fp-lib-table"],+ },+ Verb {+ name: "kicad_progress",+ summary: "What this bridge is doing RIGHT NOW: one progress block per in-flight phase for THIS caller (stepLabel, percent, etaSec, confidence, blockedBy, stepShot), plus the machine's timing history.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+ input: "{\"all\"?: bool}", example: "kicad_progress {}",+ hint: "Poll this while a long verb runs (1s is fine): it is served on its own worker thread, so it answers while the verb is in flight. active[] is scoped to YOUR caller identity (wiki #46); all:true is the operator view and every frame carries `caller` either way. An empty active with busy:false means the bridge is idle, NOT that a verb failed.",+ related: &["kicad_status", "kicad_verb_times", "kicad_describe"],+ pitfalls: &["blockedBy appears only once the same KiCad dialog has been up for 3 s and it is not a progress dialog: KiCad's transient 'Load PCB' windows are not a wait on you", "stepShot is at most one small frame per step label; a poll never triggers a new capture while the step has not changed"],+ },+ Verb {+ name: "kicad_verb_times",+ summary: "Measured per-verb run times ON THIS MACHINE: count, p50, p90, max, last, failures from the append-only run log, sorted by p90 descending.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+ input: "{\"limit\"?: 40}", example: "kicad_verb_times {}",+ hint: "p90 is the headline column: p50 15s next to p90 400s is two code paths wearing one name. failures counts runs that returned success:false; a verb that fails fast is a different bug from a slow one.",+ related: &["kicad_progress", "kicad_status"],+ pitfalls: &["sourced from every dispatch, so the numbers converge just by the bridge being used; a fresh box has an empty table"],+ },+];++const SHOT_DEFAULT_MAX: u32 = 640;+const SHOT_MIN_MAX: u32 = 120;+const SHOT_MAX_MAX: u32 = 1600;+/// A dialog only counts as BLOCKING once it has survived this long (server.py).+const DIALOG_BLOCK_AFTER: Duration = Duration::from_secs(3);+/// A stepShot frame stays comfortably under the inline cap.+const STEPSHOT_MAX_BYTES: u64 = 1_200_000;+/// `shot.image` inline cap: a full-resolution capture on a 4K box can exceed what the relay+/// carries in one response.+const SHOT_INLINE_MAX_BYTES: u64 = 12 * 1024 * 1024;++/// Verbs that failed before touching KiCad: no dialog sweep, no etiquette pass.+const UNTOUCHED_CODES: &[&str] = &["not_supported_on_platform", "missing_arg", "not_installed", "bad_arg", "file_not_found", "ab_unavailable", "platform_error", "project_not_found", "kicad_exe_not_found"];++/// Is any KiCad window up? The cold/warm probe for the progress registry (consulted by the+/// dispatch chokepoint only for instrumented verbs: `progress::begin(cmd, kicad_running)`).+#[allow(dead_code)] // wired by verbs.rs's dispatch chokepoint+pub fn kicad_running() -> bool {+ !win::kicad_windows().is_empty()+}++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+ if !VERBS.iter().any(|v| v.name == command) {+ return None;+ }+ match command {+ "kicad_progress" => return Some(progress_verb(args)),+ "kicad_verb_times" => return Some(verb_times(args)),+ _ => {}+ }+ // The user's foreground BEFORE the verb, for the one-shot focus check in post_verb.+ let fg_before = native().foreground().ok().filter(|h| *h != 0);+ // Each show verb wraps one window verb; post_verb is keyed on THAT name so the etiquette+ // (foreground opt-in with reason, background hint, window labelling) matches it.+ let (mut out, inner) = match command {+ "kicad_show_project" => (show_project(state, args), "kicad_launch"),+ "kicad_show_symbol" => (show_symbol(state, args), "kicad_open_symbol_editor"),+ "kicad_show_footprint" => (show_footprint(state, args), "kicad_open_footprint_editor"),+ "kicad_show_3d_chip" => (show_3d_chip(state, args), "kicad_open_3d_viewer"),+ "kicad_show_schematic" => (show_schematic(state, args), "kicad_open_schematic"),+ "kicad_show_2d_board" => (show_2d_board(state, args), "kicad_open_board"),+ "kicad_show_3d_board" => (show_3d_board(state, args), "kicad_open_3d_viewer"),+ "kicad_show_library" => {+ let fp = library_kind_is_footprint(args);+ (show_library(state, args), if fp { "kicad_open_footprint_editor" } else { "kicad_open_symbol_editor" })+ }+ _ => return None,+ };+ let code = out.get("errorCode").and_then(Value::as_str).unwrap_or("");+ if !UNTOUCHED_CODES.contains(&code) {+ // server.py wrapper for a show verb (membership in _SPAWNS_WINDOW / _SPAWNS_WINDOW_FOCUS,+ // found 2026-08-14 when show_* bypassed the guardian): dialog sweep, etiquette, focus check.+ win::post_verb(state, inner, args, &mut out, fg_before);+ }+ Some(out)+}++// ── Small helpers ─────────────────────────────────────────────────────────────++fn sleep_ms(ms: u64) {+ std::thread::sleep(Duration::from_millis(ms));+}++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());+ }+ }+}++fn set_default(v: &mut Value, key: &str, val: Value) {+ if let Some(o) = v.as_object_mut() {+ o.entry(key).or_insert(val);+ }+}++fn is_true(v: &Value, key: &str) -> bool {+ v.get(key).and_then(Value::as_bool).unwrap_or(false)+}++/// Advance this verb's live progress frame (a no-op when the dispatch did not begin one).+fn step(command: &str, label: &str, percent: i64) {+ if let Some(p) = progress::phase_for_verb(command) {+ progress::step(p, label, Some(percent), None);+ }+}++fn library_kind_is_footprint(args: &Value) -> bool {+ matches!(arg_str(args, "kind").map(|k| k.to_ascii_lowercase()).as_deref(), Some("footprint") | Some("fp"))+}++fn stem_of(path: &str) -> String {+ std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default()+}++/// The window a show verb is about: the hwnd the open verb returned when it is still a+/// window of the right kind, else the one whose title carries `needle`, else the first of+/// the kind. Evidence comes from THIS lookup, never a second, possibly different window.+fn surface_window(kind: WindowKind, prefer_hwnd: Option<u64>, needle: &str) -> Option<win::Win> {+ win::invalidate();+ let wins = win::windows_of_kind(kind);+ if let Some(h) = prefer_hwnd {+ if let Some(w) = wins.iter().find(|w| w.hwnd == h) {+ return Some(w.clone());+ }+ }+ if !needle.is_empty() {+ if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(needle)) {+ return Some(w.clone());+ }+ }+ wins.into_iter().next()+}++fn wait_for_surface(kind: WindowKind, prefer_hwnd: Option<u64>, needle: &str, rounds: usize, gap_ms: u64) -> Option<win::Win> {+ for i in 0..rounds.max(1) {+ if let Some(w) = surface_window(kind, prefer_hwnd, needle) {+ return Some(w);+ }+ if i + 1 < rounds {+ sleep_ms(gap_ms);+ }+ } None }++/// Is an editor of `kind` ALREADY open on exactly this part (wiki #35 warm reuse)? Exact+/// library:part title comparison (#49, #54), never `name in title`.+fn editor_showing(kind: WindowKind, confirms: impl Fn(&str) -> bool) -> Option<win::Win> {+ win::invalidate();+ win::windows_of_kind(kind).into_iter().find(|w| confirms(&w.title))+}++// ── Evidence and the shot ─────────────────────────────────────────────────────++/// Capture a shot of `hwnd` through ab, polling until the GL canvas has painted (0.9.202:+/// poll rather than assume; each attempt is a real capture). Returns (shot, attempts waited).+fn capture_settled(hwnd: u64, label: &str, resize_max: Option<u32>, max_attempts: usize) -> Result<(Value, usize), String> {+ let mut waited = 0;+ let mut shot = Value::Null;+ for attempt in 0..max_attempts {+ waited = attempt;+ shot = win::screenshot_hwnd(hwnd, label, resize_max);+ if !is_true(&shot, "success") {+ return Err(shot.get("error").and_then(Value::as_str).unwrap_or("capture failed").to_string());+ }+ let c = &shot["canvas"];+ if !is_true(c, "checked") || is_true(c, "rendered") {+ break;+ }+ sleep_ms(1000);+ }+ Ok((shot, waited))+}++fn canvas_fields(out: &mut Value, shot: &Value, waited: usize, hint_when_blank: &str) {+ let c = &shot["canvas"];+ if is_true(c, "checked") {+ out["canvasRendered"] = json!(is_true(c, "rendered"));+ out["canvasWaitedSec"] = json!(waited);+ if !is_true(c, "rendered") {+ out["_canvasHint"] = json!(hint_when_blank.replace("{colors}", &c["distinctColors"].as_u64().unwrap_or(0).to_string()).replace("{waited}", &waited.to_string()));+ }+ }+}++/// The `evidence` block (Python `_capture_evidence`): PROOF the surface is really on screen,+/// captured in the background from the verified hwnd.+fn capture_evidence(w: &win::Win, label: &str) -> Result<Value, String> {+ let (shot, waited) = capture_settled(w.hwnd, label, None, 8)?;+ let path = shot.get("fullPath").or_else(|| shot.get("savedTo")).cloned().unwrap_or(Value::Null);+ let mut ev = json!({+ "window": w.title,+ "hwnd": w.hwnd,+ "screenshotPath": path,+ "sizeKB": shot.get("sizeKB").cloned().unwrap_or(Value::Null),+ "capturedInBackground": true,+ "capturedBy": shot.get("capturedBy").cloned().unwrap_or(Value::Null),+ "_hint": "Proof this surface is really on screen, captured from the same window that satisfied the verb. ab auto-pulls screenshotPath into the caller's container, so it can be read or shown as a thumbnail without a separate pull_file.",+ });+ for k in ["safePath", "shotId", "coordMap", "ownedPopupCount", "popups"] {+ if let Some(v) = shot.get(k) {+ ev[k] = v.clone();+ }+ }+ canvas_fields(&mut ev, &shot, waited,+ "The window is correct but its drawing canvas is still BLANK ({colors} distinct colour(s)) after waiting {waited}s for it to paint. Usually this just means KiCad is still loading libraries - check the status bar in the image. If it never paints, the canvas is an OpenGL surface and yields no picture on a host with no usable GPU or no active console session; kicad_enable_software_opengl is the last-resort fix there. Either way, treat this thumbnail as proof of the WINDOW, not of the part.");+ Ok(ev)+}++fn clamp_max_width(v: Option<&Value>) -> u32 {+ let mw = match v {+ Some(Value::Number(n)) => n.as_f64().map(|f| f as i64).unwrap_or(SHOT_DEFAULT_MAX as i64),+ Some(Value::String(s)) => s.trim().parse::<i64>().unwrap_or(SHOT_DEFAULT_MAX as i64),+ _ => SHOT_DEFAULT_MAX as i64,+ };+ mw.clamp(SHOT_MIN_MAX as i64, SHOT_MAX_MAX as i64) as u32+}++/// The MIME belongs to whoever encodes the bytes: derive it from what ab actually wrote.+fn mime_for(path: &str) -> &'static str {+ match path.rsplit('.').next().map(|e| e.to_ascii_lowercase()).as_deref() {+ Some("bmp") => "image/bmp",+ Some("webp") => "image/webp",+ Some("jpg") | Some("jpeg") => "image/jpeg",+ _ => "image/png",+ }+}++/// The ab gallery-item `shot` contract (wiki #45, adom-bridge#91): the FRAME a browser can+/// paint, next to `evidence` (the render PROOF). Opt-in via capture:true.+fn build_shot(w: &win::Win, label: &str, max_width: Option<&Value>) -> Result<Value, String> {+ let mw = clamp_max_width(max_width);+ // Wiki #52: the editor answers "ready" before its GL canvas first paints, and blanks+ // cluster on FAST parts. Own the settle here too (up to 10 s) and REPORT the verdict.+ let (shot, waited) = capture_settled(w.hwnd, &format!("shot-{label}"), Some(mw), 11)?;+ let full = shot.get("fullPath").or_else(|| shot.get("savedTo")).and_then(Value::as_str).unwrap_or("").to_string();+ let safe = shot.get("safePath").and_then(Value::as_str).unwrap_or("").to_string();+ let resized = !safe.is_empty() && std::path::Path::new(&safe).is_file();+ let path = if resized { safe.clone() } else { full.clone() };+ if path.is_empty() {+ return Err("ab returned no image path".into());+ }+ let bytes = std::fs::read(&path).map_err(|e| format!("could not read {path}: {e}"))?;+ if bytes.len() as u64 > SHOT_INLINE_MAX_BYTES {+ return Err(format!("the capture is {} MB, over the inline cap; the file is at {path} (ab auto-pulls it)", bytes.len() / (1024 * 1024)));+ }+ let b64 = win::b64_encode(&bytes);+ let mime = mime_for(&path);+ let mut out = json!({+ "title": w.title,+ "image": b64,+ "maxWidth": mw,+ "path": path,+ "fullPath": full,+ "resized": resized,+ "mime": mime,+ "_hint": format!("thumbnailDataUrl drops straight into an <img src>. `image` is the same bytes raw for container callers, and `path` is the on-box file ab auto-pulls. Request it with capture:true; captureMaxWidth defaults to {SHOT_DEFAULT_MAX} and clamps to {SHOT_MIN_MAX}-{SHOT_MAX_MAX}."),+ });+ let (wk, hk) = if resized { ("safeWidth", "safeHeight") } else { ("fullWidth", "fullHeight") };+ if let (Some(wv), Some(hv)) = (shot.get(wk), shot.get(hk)) {+ out["width"] = wv.clone();+ out["height"] = hv.clone();+ }+ if resized {+ out["thumbnailDataUrl"] = json!(format!("data:{mime};base64,{}", out["image"].as_str().unwrap_or("")));+ } else {+ // Honest: the native build has no image decoder, so it cannot downscale a PNG itself.+ out["thumbnailUnavailable"] = json!(format!("ab returned no resized (.safe) variant for this capture, so `image` is the FULL-resolution frame and no thumbnailDataUrl is offered (the native bridge does not decode or downscale images; maxWidth {mw} was requested from ab). Render `image` directly or pull `path`."));+ }+ canvas_fields(&mut out, &shot, waited, "The canvas region is still UNRENDERED after {waited}s of polling - treat this frame as proof of the window, not the part, and recapture rather than render it.");+ Ok(out)+}++/// Attach `shot` when the caller asked for it. Never replaces `evidence`.+fn attach_shot(result: &mut Value, w: &win::Win, label: &str, args: &Value) {+ if !is_true(args, "capture") {+ return;+ }+ match build_shot(w, label, args.get("captureMaxWidth")) {+ Ok(s) => result["shot"] = s,+ Err(e) => result["shotUnavailable"] = json!(format!("capture was requested but the frame could not be grabbed: {e}")),+ }+}++/// Evidence (always attempted) plus the opt-in shot, from ONE verified window. A failed+/// capture never turns a real success into a failure: it is reported as absent, with why.+fn with_evidence(result: &mut Value, w: &win::Win, label: &str, args: &Value, command: &str) {+ step(command, "waiting for the canvas to paint and capturing evidence", 85);+ set_default(result, "window", json!(w.title));+ set_default(result, "hwnd", json!(w.hwnd));+ match capture_evidence(w, label) {+ Ok(ev) => result["evidence"] = ev,+ Err(e) => result["evidenceUnavailable"] = json!(format!("window verified but the capture failed: {e}")),+ }+ attach_shot(result, w, label, args);+}++/// Python `_verify_window`: a show verb has ONE job, put THAT surface on screen. If its+/// window is not there the verb FAILS, even when every internal step reported success.+fn verify_window(mut result: Value, kind: WindowKind, label: &str, needle: &str, args: &Value, command: &str) -> Value {+ if !is_true(&result, "success") {+ return result;+ }+ step(command, &format!("verifying the {label} window"), 70);+ let prefer = result.get("hwnd").and_then(Value::as_u64).filter(|h| *h != 0);+ if let Some(w) = wait_for_surface(kind, prefer, needle, 12, 500) {+ let slug = label.to_lowercase().replace(' ', "-");+ with_evidence(&mut result, &w, &slug, args, command);+ return result;+ }+ let still_starting = result.get("windowAppeared") == Some(&json!(false));+ result["success"] = json!(false);+ result["error"] = json!(format!("{label} reported success but its window never appeared"));+ result["errorCode"] = json!("surface_window_missing");+ if still_starting {+ result["retryable"] = json!(true);+ result["statusVerb"] = json!("kicad_window_info");+ result["_hint"] = json!(format!("The bridge could not put the {label} on screen within this call, so there is nothing truthful to screenshot. The editor process is still alive (windowAppeared:false), so this may be a slow cold start or a large file: poll kicad_window_info, or kicad_screenshot_all for a modal holding it, then re-call with the same args."));+ } else {+ result["_hint"] = json!(format!("The bridge could not put the {label} on screen, so there is nothing truthful to screenshot. Retry after kicad_launch; kicad_screenshot_all shows what is actually up, and a modal dialog there is the usual reason."));+ }+ result+}++/// The wiki #35 reuse reply: the editor already on the exact part, with evidence.+fn reused(w: &win::Win, surface: &str, label: &str, slug: &str, requested_lib: &str, editor_word: &str, args: &Value, command: &str) -> Value {+ let mut v = json!({+ "success": true, "surface": surface, "reused": true, "hwnd": w.hwnd, "window": w.title, "mechanism": "existing",+ "output": format!("{label} is already showing {}: reused it (no relaunch, no library rescan).", w.title),+ "_hint": format!("Returned the existing editor instead of running the cold path. KiCad re-indexes every library when the {label} opens, so reuse is the difference between about a second and about a minute (wiki issue #35). Pass rescan:true when you do not trust the editor's library index."),+ });+ // #49 ask 2: the reuse fast path used to return no resolution fields.+ merge(&mut v, wm::resolved_fields(&w.title, requested_lib, editor_word));+ with_evidence(&mut v, w, slug, args, command);+ v+}++// ── The show verbs ────────────────────────────────────────────────────────────++const CMD_PROJECT: &str = "kicad_show_project";+const CMD_SYMBOL: &str = "kicad_show_symbol";+const CMD_FOOTPRINT: &str = "kicad_show_footprint";+const CMD_3D_CHIP: &str = "kicad_show_3d_chip";+const CMD_SCHEMATIC: &str = "kicad_show_schematic";+const CMD_2D_BOARD: &str = "kicad_show_2d_board";+const CMD_3D_BOARD: &str = "kicad_show_3d_board";+const CMD_LIBRARY: &str = "kicad_show_library";++/// The KiCad Project Manager, optionally on a .kicad_pro. Top of the tree: the schematic,+/// board and every editor hang off one design.+fn show_project(state: &mut State, args: &Value) -> Value {+ let proj = arg_any(args, &["filePath", "project"]);+ step(CMD_PROJECT, "bringing up the project manager", 10);+ let mut launch_args = match proj { Some(p) => json!({"project": p}), None => json!({}) };+ for k in ["waitSeconds", "traceMasks"] {+ if let Some(v) = args.get(k) {+ launch_args[k] = v.clone();+ }+ }+ let mut r = win::launch(state, &launch_args);+ set_default(&mut r, "surface", json!("project"));+ if is_true(&r, "alreadyRunning") {+ set_default(&mut r, "mechanism", json!("existing"));+ if let Some(p) = proj {+ r["_projectNote"] = json!(format!("KiCad was already running, so {p} was not opened: kicad_show_project has kicad_launch's idempotent semantics. Open its sheets directly with kicad_show_schematic / kicad_show_2d_board, or kicad_close first and call again to start KiCad on that project."));+ }+ }+ if is_true(&r, "success") {+ // Evidence when the Project Manager frame is up. Its absence is not a failure here:+ // "already running" legitimately covers a box with only editors open.+ match wait_for_surface(WindowKind::ProjectManager, None, "", 6, 500) {+ Some(w) => with_evidence(&mut r, &w, "project-manager", args, CMD_PROJECT),+ None => r["evidenceUnavailable"] = json!("no Project Manager window is up (KiCad is running with editors only); nothing to capture for this surface"),+ }+ }+ r+}++/// A schematic SYMBOL in the Symbol Editor. Args: symbolName (+ libraryName), or+/// libraryName alone to browse a library.+fn show_symbol(state: &mut State, args: &Value) -> Value {+ let name = arg_str(args, "symbolName").unwrap_or("");+ let lib = arg_any(args, &["libraryName", "library"]).unwrap_or("");+ // rescan:true means "I do not trust this editor's library index" (the remedy for+ // library_cache_stale), so the warm-reuse shortcut must not swallow it.+ if !is_true(args, "rescan") && !name.is_empty() {+ if let Some(w) = editor_showing(WindowKind::SymbolEditor, |t| wm::title_confirms_symbol(t, name, lib)) {+ return reused(&w, "symbol", "Symbol Editor", "symbol-editor-reused", lib, "Symbol Editor", args, CMD_SYMBOL);+ }+ }+ step(CMD_SYMBOL, if name.is_empty() { "opening the Symbol Editor".to_string() } else { format!("opening the Symbol Editor and navigating to {name}") }.as_str(), 10);+ let r = win::open_symbol_editor(state, args);+ let mut r = verify_window(r, WindowKind::SymbolEditor, "Symbol Editor", "", args, CMD_SYMBOL);+ set_default(&mut r, "surface", json!("symbol"));+ r+}++/// A FOOTPRINT in the Footprint Editor. Args: footprintName (+ library), or library alone.+fn show_footprint(state: &mut State, args: &Value) -> Value {+ let name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("");+ let lib = arg_any(args, &["library", "libraryName"]).unwrap_or("");+ if !name.is_empty() {+ if let Some(w) = editor_showing(WindowKind::FootprintEditor, |t| wm::title_confirms_footprint(t, name, lib)) {+ return reused(&w, "footprint", "Footprint Editor", "footprint-editor-reused", lib, "Footprint Editor", args, CMD_FOOTPRINT);+ }+ }+ step(CMD_FOOTPRINT, if name.is_empty() { "opening the Footprint Editor".to_string() } else { format!("opening the Footprint Editor and navigating to {name}") }.as_str(), 10);+ let r = win::open_footprint_editor(state, args);+ let mut r = verify_window(r, WindowKind::FootprintEditor, "Footprint Editor", "", args, CMD_FOOTPRINT);+ set_default(&mut r, "surface", json!("footprint"));+ r+}++/// Wiki #42 item 4: the 3D Viewer is titled just "3D Viewer", so the part identity is read+/// from the Footprint Editor the view was rendered from. Pure, for the tests.+fn shows_block(want: &str, src_title: &str) -> (Value, bool, String, String) {+ let (lib, fp) = wm::parse_editor_title(src_title, "Footprint Editor");+ let verified = !want.is_empty() && !fp.is_empty() && fp.eq_ignore_ascii_case(want);+ let v = json!({+ "requested": if want.is_empty() { Value::Null } else { json!(want) },+ "footprint": if fp.is_empty() { Value::Null } else { json!(fp) },+ "library": if lib.is_empty() { Value::Null } else { json!(lib) },+ "sourceWindow": if src_title.is_empty() { Value::Null } else { json!(src_title) },+ "verified": verified,+ "_hint": "The 3D Viewer's own title carries no part identity, so this is read from the Footprint Editor the view was rendered from. verified:true means that editor is on the part you asked for; false means it is not, and the 3D view is therefore of something else.",+ });+ (v, verified, lib, fp)+}++/// A single part in 3D: the FOOTPRINT's 3D view. Loads the footprint first when named.+fn show_3d_chip(state: &mut State, args: &Value) -> Value {+ let want = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").trim().to_string();+ let lib = arg_any(args, &["library", "libraryName"]).unwrap_or("");+ if !want.is_empty() || !lib.is_empty() {+ step(CMD_3D_CHIP, &format!("loading {} in the Footprint Editor", if want.is_empty() { lib.to_string() } else { want.clone() }), 10);+ let mut loaded = win::open_footprint_editor(state, args);+ if !is_true(&loaded, "success") && !is_true(&loaded, "editorOpened") {+ loaded["surface"] = json!("3d_chip");+ return loaded;+ }+ let _ = wait_for_surface(WindowKind::FootprintEditor, loaded.get("hwnd").and_then(Value::as_u64), "", 20, 500);+ }+ step(CMD_3D_CHIP, "opening the 3D Viewer from the Footprint Editor", 40);+ let mut a = args.clone();+ if !a.is_object() {+ a = json!({});+ }+ a["editor"] = json!("fp");+ let r = win::open_3d_viewer(&a);+ let mut r = verify_window(r, WindowKind::Viewer3d, "3D Viewer", "", args, CMD_3D_CHIP);+ set_default(&mut r, "surface", json!("3d_chip"));+ if !is_true(&r, "success") {+ return r;+ }+ let src_title = surface_window(WindowKind::FootprintEditor, None, "").map(|w| w.title).unwrap_or_default();+ let (shows, verified, lib_t, fp_t) = shows_block(&want, &src_title);+ r["shows"] = shows;+ // #88: say whether the part even HAS a resolvable model before anyone reads pads-only+ // pixels as a rendered chip. File diagnostics only; the frame remains the render proof.+ if !lib_t.is_empty() && !fp_t.is_empty() {+ let ctx = Ctx::from_info(&state.kicad_info());+ let mc = model_check::check_footprint(&ctx, &lib_t, &fp_t, None);+ if is_true(&mc, "success") {+ let warnings = mc["warnings"].as_array().cloned().unwrap_or_default();+ r["models"] = json!({+ "ok": mc["ok"], "count": mc["models"].as_array().map(|a| a.len()).unwrap_or(0),+ "warnings": warnings, "renderVerified": false, "note": mc["note"],+ });+ if let Some(ws) = mc["warnings"].as_array().filter(|w| !w.is_empty()) {+ let parts: Vec<String> = ws.iter().take(3).map(|w| format!("{}: {}", w["code"].as_str().unwrap_or("?"), w["hint"].as_str().unwrap_or(""))).collect();+ r["_modelHint"] = json!(format!("The 3D view may show pads only: {} (kicad_model_check has the full list).", parts.join("; ")));+ }+ }+ }+ if !want.is_empty() && !verified {+ r["success"] = json!(false);+ r["errorCode"] = json!("wrong_part_in_3d_view");+ r["error"] = json!(format!("3D Viewer opened, but it was rendered from '{}' rather than the requested '{want}'.", if fp_t.is_empty() { "unknown" } else { fp_t.as_str() }));+ }+ r+}++/// A SCHEMATIC (2D) in the Schematic Editor. Args: filePath (.kicad_sch).+fn show_schematic(state: &mut State, args: &Value) -> Value {+ step(CMD_SCHEMATIC, "opening the schematic in eeschema", 10);+ let needle = arg_str(args, "filePath").map(stem_of).unwrap_or_default();+ let r = win::open_file(state, args, WindowKind::SchematicEditor);+ let mut r = verify_window(r, WindowKind::SchematicEditor, "Schematic Editor", &needle, args, CMD_SCHEMATIC);+ set_default(&mut r, "surface", json!("schematic"));+ r+}++/// The 2D BOARD LAYOUT in the PCB Editor. Args: filePath (.kicad_pcb).+fn show_2d_board(state: &mut State, args: &Value) -> Value {+ step(CMD_2D_BOARD, "opening the board in pcbnew", 10);+ let needle = arg_str(args, "filePath").map(stem_of).unwrap_or_default();+ let r = win::open_file(state, args, WindowKind::PcbEditor);+ let mut r = verify_window(r, WindowKind::PcbEditor, "PCB Editor", &needle, args, CMD_2D_BOARD);+ set_default(&mut r, "surface", json!("2d_board"));+ r+}++/// The whole board in 3D: the BOARD's 3D viewer. Opens the board first when filePath is given.+fn show_3d_board(state: &mut State, args: &Value) -> Value {+ let file_path = arg_str(args, "filePath").map(str::to_string);+ let mut pcb_hwnd: Option<u64> = None;+ if let Some(f) = &file_path {+ step(CMD_3D_BOARD, "opening the board in pcbnew", 10);+ let a = json!({"filePath": f, "reason": arg_str(args, "reason").unwrap_or("show 3D board"), "waitSeconds": args.get("waitSeconds").cloned().unwrap_or(Value::Null)});+ let mut opened = win::open_file(state, &a, WindowKind::PcbEditor);+ if !is_true(&opened, "success") {+ opened["surface"] = json!("3d_board");+ return opened;+ }+ // open_board launches pcbnew ASYNC: wait for its window before the 3D viewer (which+ // 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);+ }+ step(CMD_3D_BOARD, "opening the 3D Viewer from the PCB Editor", 40);+ let mut a = args.clone();+ if !a.is_object() {+ a = json!({});+ }+ a["editor"] = json!("pcb");+ if let Some(h) = pcb_hwnd {+ // The editor we just opened, so the viewer is never guessed among several PCB Editors.+ set_default(&mut a, "pcbHwnd", json!(h));+ }+ let r = win::open_3d_viewer(&a);+ let mut r = verify_window(r, WindowKind::Viewer3d, "3D Viewer", "", args, CMD_3D_BOARD);+ set_default(&mut r, "surface", json!("3d_board"));+ // #88: board-wide model coverage from the file (read-only), so a viewer full of bare+ // pads comes with the reason instead of a screenshot to interpret.+ if let (Some(f), true) = (&file_path, is_true(&r, "success")) {+ let ctx = Ctx::from_info(&state.kicad_info());+ let mc = model_check::check_board(&ctx, f, 20);+ if is_true(&mc, "success") {+ r["models"] = json!({"ok": mc["ok"], "counts": mc["counts"], "renderVerified": false, "footprintsWithWarnings": mc["footprintsWithWarnings"]});+ if !is_true(&mc, "ok") {+ let c = &mc["counts"];+ r["_modelHint"] = json!(format!("{} footprint(s) have no model and {} reference a file KiCad cannot find; those render as pads only. kicad_model_check {{boardPath}} lists them.", c["missingModel"], c["unresolvedModel"]));+ }+ }+ }+ r+}++/// Browse a LIBRARY (symbol or footprint). Args: libraryName (+ kind: 'symbol'|'footprint',+/// default 'symbol'). No part is loaded unless symbolName/footprintName is also passed.+fn show_library(state: &mut State, args: &Value) -> Value {+ let Some(lib) = arg_any(args, &["libraryName", "library"]) else {+ return fail("missing_arg", "libraryName is required", "kicad_show_library {\"libraryName\":\"Device\"}: add kind:\"footprint\" for the footprint side.");+ };+ let footprint_side = library_kind_is_footprint(args);+ // #49 follow-up: symbolName/footprintName used to be DROPPED here and the response then+ // said "No symbol was requested", which was untrue. Pass them through.+ let want_sym = arg_str(args, "symbolName").unwrap_or("");+ let want_fp = arg_str(args, "footprintName").unwrap_or("");+ let mut a = json!({});+ for k in ["timeout", "waitInline", "rescan"] {+ if let Some(v) = args.get(k) {+ a[k] = v.clone();+ }+ }+ step(CMD_LIBRARY, &format!("filtering the {} library tree to {lib}", if footprint_side { "footprint" } else { "symbol" }), 10);+ let (r, kind, label) = if footprint_side {+ a["library"] = json!(lib);+ if !want_fp.is_empty() {+ a["footprintName"] = json!(want_fp);+ }+ (win::open_footprint_editor(state, &a), WindowKind::FootprintEditor, "Footprint Editor")+ } else {+ a["libraryName"] = json!(lib);+ if !want_sym.is_empty() {+ a["symbolName"] = json!(want_sym);+ }+ (win::open_symbol_editor(state, &a), WindowKind::SymbolEditor, "Symbol Editor")+ };+ let mut r = verify_window(r, kind, label, "", args, CMD_LIBRARY);+ set_default(&mut r, "surface", json!("library"));+ r+}++// ── kicad_progress ────────────────────────────────────────────────────────────++static DIALOG_FIRST_SEEN: Mutex<Option<HashMap<u64, Instant>>> = Mutex::new(None);+/// {phase: (stepLabel, stepShot)}: a shot is taken once per STEP, not once per poll.+static STEPSHOT: Mutex<Option<HashMap<String, (String, Value)>>> = Mutex::new(None);++/// blockedBy, resolved at POLL time (issue #39): PERSISTENCE is what separates "you must+/// act" from "KiCad is busy". A real prompt sits there until a human deals with it, so the+/// SAME dialog must still be up `DIALOG_BLOCK_AFTER` later, and a progress dialog (Load+/// PCB, Loading Symbol Libraries) is never a wait on the user. `dialogs` is (hwnd, title,+/// body). Pure, for the tests.+fn blocker_from(dialogs: &[(u64, String, String)], seen: &mut HashMap<u64, Instant>, now: Instant) -> Option<String> {+ let mut live: Vec<u64> = Vec::new();+ let mut blocker: Option<String> = None;+ for (hwnd, title, body) in dialogs {+ live.push(*hwnd);+ let first = *seen.entry(*hwnd).or_insert(now);+ if wm::is_progress_dialog(title, body) {+ continue;+ }+ if blocker.is_none() && now.duration_since(first) >= DIALOG_BLOCK_AFTER {+ let t = title.trim();+ blocker = Some(if t.is_empty() { "a KiCad dialog".to_string() } else { t.to_string() });+ }+ }+ seen.retain(|h, _| live.contains(h));+ blocker+}++fn current_blocker() -> Option<String> {+ let dialogs: Vec<(u64, String, String)> = win::scan_dialogs().into_iter().map(|d| (d.hwnd, d.title, d.body)).collect();+ let mut g = DIALOG_FIRST_SEEN.lock().unwrap_or_else(|e| e.into_inner());+ let seen = g.get_or_insert_with(HashMap::new);+ blocker_from(&dialogs, seen, Instant::now())+}++/// Which window IS this phase's surface? Without this a stepShot could pin the project+/// manager as evidence for a footprint step (the 0.9.172 mislabel).+fn surface_kind(phase: &str) -> Option<WindowKind> {+ Some(match phase {+ "show_symbol" | "show_library" => WindowKind::SymbolEditor,+ "show_footprint" => WindowKind::FootprintEditor,+ "show_3d_chip" | "show_3d_board" => WindowKind::Viewer3d,+ "show_2d_board" => WindowKind::PcbEditor,+ "show_schematic" => WindowKind::SchematicEditor,+ _ => return None,+ })+}++/// A small base64 PNG of THIS phase's surface, at most one per step label.+fn stepshot_for(phase: &str, step_label: &str) -> Option<Value> {+ {+ let g = STEPSHOT.lock().unwrap_or_else(|e| e.into_inner());+ if let Some((label, shot)) = g.as_ref().and_then(|m| m.get(phase)) {+ if label == step_label {+ return Some(shot.clone());+ }+ }+ }+ let kind = surface_kind(phase)?;+ let w = surface_window(kind, None, "")?;+ let shot = win::screenshot_hwnd(w.hwnd, &format!("step-{phase}"), Some(SHOT_DEFAULT_MAX));+ if !is_true(&shot, "success") {+ return None;+ }+ let path = shot.get("safePath").and_then(Value::as_str).filter(|p| !p.is_empty()).or_else(|| shot.get("fullPath").and_then(Value::as_str))?.to_string();+ let bytes = std::fs::read(&path).ok()?;+ if bytes.len() as u64 > STEPSHOT_MAX_BYTES {+ return None;+ }+ let out = json!({"step": step_label, "title": w.title, "image": format!("data:{};base64,{}", mime_for(&path), win::b64_encode(&bytes))});+ let mut g = STEPSHOT.lock().unwrap_or_else(|e| e.into_inner());+ g.get_or_insert_with(HashMap::new).insert(phase.to_string(), (step_label.to_string(), out.clone()));+ Some(out)+}++/// kicad_progress: what is this bridge doing RIGHT NOW, as progress blocks (0.9.195). The+/// live half of the measured-progress contract, read-only, cheap, safe to poll every second,+/// and served on its own worker while the verb it describes is still running.+fn progress_verb(args: &Value) -> Value {+ let all = is_true(args, "all");+ let mut frames = progress::live(!all, None);+ if !frames.is_empty() {+ if let Some(b) = current_blocker() {+ for f in frames.iter_mut() {+ f["blockedBy"] = json!(b);+ f["stepLabel"] = json!(format!("waiting for you: {b}"));+ }+ }+ // stepShot: the step's own evidence thumbnail. Skipped while blocked: the surface+ // behind a modal is not what the user needs to look at, the modal is.+ for f in frames.iter_mut() {+ if f.get("blockedBy").map(|b| !b.is_null()).unwrap_or(false) {+ continue;+ }+ let phase = f["phase"].as_str().unwrap_or("").rsplit('.').next().unwrap_or("").to_string();+ let label = f["stepLabel"].as_str().unwrap_or("").to_string();+ if let Some(ss) = stepshot_for(&phase, &label) {+ f["stepShot"] = ss;+ }+ }+ }+ json!({+ "success": true,+ "busy": !frames.is_empty(),+ "active": frames,+ "history": progress::history(),+ "callerScoped": true,+ "_hint": "Scoped to YOU: `active` holds only the phases this caller started (wiki #46). Pass all:true for every in-flight phase on the box; each frame carries `caller` regardless. Poll this while a long verb runs (1s is fine). `active` holds one progress block per in-flight phase: stepLabel is the human line for a status bar, percent/etaSec drive the bar, and confidence says whether the estimate is measured on THIS machine or still a default. An empty `active` with busy:false means the bridge is idle - it does NOT mean a verb failed. blockedBy names a human-controlled wait (a modal dialog), where a bar should stop animating and say so.",+ "_next": ["kicad_status - bridge liveness plus operations.yours (your finished runs)", "kicad_describe - the progressContract this conforms to"],+ })+}++/// kicad_verb_times: measured run times per verb ON THIS BOX (fusion parity).+fn verb_times(args: &Value) -> Value {+ let limit = args.get("limit").and_then(Value::as_u64).filter(|n| *n > 0).unwrap_or(40) as usize;+ let rep = progress::verb_report(limit);+ let n = rep.as_object().map(|o| o.len()).unwrap_or(0);+ json!({+ "success": true, "verbs": rep, "verbCount": n,+ "_hint": "Per-verb count/p50/p90/max/last/failures from this machine's append-only run log, sorted by p90 descending. Sourced from every dispatch, so the numbers converge just by the bridge being used - no measurement pass. The rolling estimate window is separate; kicad_progress and the progress blocks draw from that.",+ })+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn catalog_matches_bridge_json_and_carries_no_em_dash() {+ let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();+ for n in ["kicad_show_project", "kicad_show_symbol", "kicad_show_footprint", "kicad_show_3d_chip", "kicad_show_schematic", "kicad_show_2d_board", "kicad_show_3d_board", "kicad_show_library", "kicad_progress", "kicad_verb_times"] {+ assert!(names.contains(&n), "{n} missing");+ }+ assert_eq!(names.len(), 10);+ for v in VERBS {+ let expect = match v.name {+ "kicad_show_3d_chip" | "kicad_show_3d_board" => 300,+ "kicad_progress" | "kicad_verb_times" => 10,+ _ => 130,+ };+ assert_eq!(v.timeout_sec, expect, "{}", v.name);+ let mech = if v.name.starts_with("kicad_show_") { Mechanism::Window } else { Mechanism::Local };+ assert_eq!(v.mechanism, mech, "{}", v.name);+ assert_eq!(v.risk, "read", "{}", v.name);+ for text in [v.summary, v.hint, v.input, v.example].into_iter().chain(v.pitfalls.iter().copied()) {+ assert!(!text.contains('\u{2014}'), "{} carries an em dash", v.name);+ }+ }+ }++ #[test]+ fn dispatch_claims_only_its_verbs() {+ let mut state = State::new();+ assert!(dispatch(&mut state, "kicad_open_board", &json!({})).is_none());+ assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());+ assert!(dispatch(&mut state, "kicad_status", &json!({})).is_none());+ }++ #[test]+ fn progress_and_verb_times_answer_anywhere() {+ let mut state = State::new();+ let r = dispatch(&mut state, "kicad_progress", &json!({})).unwrap();+ assert_eq!(r["success"], json!(true));+ assert_eq!(r["callerScoped"], json!(true));+ assert!(r["active"].is_array());+ assert!(r["busy"].is_boolean());+ assert!(r["history"].is_object());+ let r = dispatch(&mut state, "kicad_verb_times", &json!({"limit": 5})).unwrap();+ assert_eq!(r["success"], json!(true));+ assert!(r["verbs"].is_object());+ assert!(r["verbCount"].as_u64().unwrap() <= 5);+ }++ #[test]+ fn show_verbs_never_fake_success_and_argument_errors_are_cheap() {+ let mut state = State::new();+ std::env::remove_var("ADOM_DIRECT_API_URL");+ let t0 = Instant::now();+ let r = dispatch(&mut state, "kicad_show_library", &json!({})).unwrap();+ assert_eq!(r["success"], json!(false));+ assert_eq!(r["errorCode"], json!("missing_arg"));+ assert!(r["_hint"].as_str().unwrap().contains("kind"));+ assert!(t0.elapsed() < Duration::from_secs(2), "an arg error must not pay for the dialog sweep");+ if !native().capabilities().window_control {+ for (verb, args) in [+ ("kicad_show_symbol", json!({"symbolName": "R", "libraryName": "Device"})),+ ("kicad_show_footprint", json!({"footprintName": "R_0603_1608Metric", "library": "Resistor_SMD"})),+ ("kicad_show_3d_chip", json!({})),+ ("kicad_show_3d_board", json!({})),+ ("kicad_show_project", json!({})),+ ("kicad_show_2d_board", json!({})),+ ("kicad_show_schematic", json!({"filePath": "/definitely/not/here.kicad_sch"})),+ ] {+ let r = dispatch(&mut state, verb, &args).unwrap();+ assert_eq!(r["success"], json!(false), "{verb}: {r}");+ assert!(r.get("errorCode").is_some(), "{verb}: {r}");+ assert!(r.get("evidence").is_none(), "{verb} must not carry evidence without a window");+ }+ }+ }++ #[test]+ fn shows_block_verifies_the_exact_footprint() {+ let (v, ok, lib, fp) = shows_block("QFN-56", "Adom:QFN-56 \u{2014} Footprint Editor");+ assert!(ok);+ assert_eq!(lib, "Adom");+ assert_eq!(fp, "QFN-56");+ assert_eq!(v["verified"], json!(true));+ assert_eq!(v["library"], json!("Adom"));+ let (v, ok, _, fp) = shows_block("QFN-56", "*Adom:s10b-ph-sm4-tb - Footprint Editor");+ assert!(!ok);+ assert_eq!(fp, "s10b-ph-sm4-tb");+ assert_eq!(v["verified"], json!(false));+ let (v, ok, _, _) = shows_block("", "Footprint Editor");+ assert!(!ok);+ assert_eq!(v["requested"], Value::Null);+ assert_eq!(v["footprint"], Value::Null);+ let (_, ok, _, _) = shows_block("qfn-56", "Adom:QFN-56 - Footprint Editor");+ assert!(ok, "case-insensitive like the Python");+ }++ #[test]+ fn blocked_by_needs_persistence_and_skips_progress_dialogs() {+ let mut seen = HashMap::new();+ let t0 = Instant::now();+ let dialogs = vec![(1u64, "Load PCB".to_string(), String::new()), (2u64, "Error".to_string(), "Cannot enumerate".to_string())];+ assert_eq!(blocker_from(&dialogs, &mut seen, t0), None, "first sighting is never blocking");+ assert_eq!(blocker_from(&dialogs, &mut seen, t0 + Duration::from_secs(2)), None);+ assert_eq!(blocker_from(&dialogs, &mut seen, t0 + Duration::from_secs(3)), Some("Error".to_string()), "the progress dialog is skipped even though it is older");+ // The error box went away: its first-seen entry is pruned and nothing blocks.+ let only_progress = vec![(1u64, "Loading Symbol Libraries".to_string(), String::new())];+ assert_eq!(blocker_from(&only_progress, &mut seen, t0 + Duration::from_secs(9)), None);+ assert!(!seen.contains_key(&2));+ assert!(seen.contains_key(&1));+ let untitled = vec![(3u64, " ".to_string(), String::new())];+ blocker_from(&untitled, &mut seen, t0);+ assert_eq!(blocker_from(&untitled, &mut seen, t0 + Duration::from_secs(4)), Some("a KiCad dialog".to_string()));+ }++ #[test]+ fn shot_helpers_clamp_and_name_the_mime() {+ assert_eq!(clamp_max_width(None), 640);+ assert_eq!(clamp_max_width(Some(&json!(10))), 120);+ assert_eq!(clamp_max_width(Some(&json!(99999))), 1600);+ assert_eq!(clamp_max_width(Some(&json!("800"))), 800);+ assert_eq!(clamp_max_width(Some(&json!("nope"))), 640);+ assert_eq!(mime_for("C:/x/y.safe.PNG"), "image/png");+ assert_eq!(mime_for("C:/x/y.bmp"), "image/bmp");+ assert_eq!(mime_for("C:/x/y.webp"), "image/webp");+ assert_eq!(stem_of("C:/Users/john/proj/Board.kicad_pcb"), "board");+ assert!(library_kind_is_footprint(&json!({"kind": "FP"})));+ assert!(!library_kind_is_footprint(&json!({})));+ assert_eq!(surface_kind("show_3d_board"), Some(WindowKind::Viewer3d));+ assert_eq!(surface_kind("show_project"), None);+ }+}
rust/crates/kicad-bridge/src/verbs_windows.rs+21−21@@ -291,7 +291,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> /// The server.py dispatch wrapper for a window verb: dialog sweep, foreground etiquette, /// the one-shot focus check, window labelling.-fn post_verb(state: &mut State, command: &str, args: &Value, out: &mut Value, fg_before: Option<u64>) {+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@@ -427,9 +427,9 @@ fn user_idle_seconds() -> Option<f64> { // ── Window finder (kicad_windows.py): ab first for z-order, local fallback ──── #[derive(Clone, Debug, Default)]-struct Win {- hwnd: u64,- title: String,+pub(crate) struct Win {+ pub(crate) hwnd: u64,+ pub(crate) title: String, class_name: String, /// x, y, width, height. rect: (i32, i32, i32, i32),@@ -472,7 +472,7 @@ static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), sour const CACHE_TTL: Duration = Duration::from_millis(250); /// Call after anything that opens, closes, or retitles a KiCad window.-fn invalidate() {+pub(crate) fn invalidate() { if let Ok(mut c) = CACHE.lock() { c.at = None; c.rows.clear();@@ -526,11 +526,11 @@ fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static st Ok((rows, src)) } -fn kicad_windows() -> Vec<Win> {+pub(crate) fn kicad_windows() -> Vec<Win> { find_windows(false, false).map(|(w, _)| w).unwrap_or_default() } -fn windows_of_kind(kind: WindowKind) -> Vec<Win> {+pub(crate) fn windows_of_kind(kind: WindowKind) -> Vec<Win> { kicad_windows().into_iter().filter(|w| w.kind() == kind).collect() } @@ -543,14 +543,14 @@ fn find_project_manager() -> Option<u64> { } /// A window's current title without hanging on a modal-blocked thread.-fn title_of(hwnd: u64) -> String {+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() } -fn window_alive(hwnd: u64) -> bool {+pub(crate) fn window_alive(hwnd: u64) -> bool { match native().window_info(hwnd) { Ok(Some(i)) => i.visible, Ok(None) => false,@@ -638,12 +638,12 @@ fn bring_to_user(hwnd: u64, reason: &str) -> bool { // ── Dialogs (close_windows.py) ──────────────────────────────────────────────── #[derive(Clone, Debug, Default)]-struct Dialog {- hwnd: u64,- title: String,+pub(crate) struct Dialog {+ pub(crate) hwnd: u64,+ pub(crate) title: String, class_name: String, owner: u64,- body: String,+ pub(crate) body: String, via_global_sweep: bool, } @@ -657,7 +657,7 @@ fn dialog_body_text(hwnd: u64) -> String { /// RELIABLE scan for EVERY dialog around a running KiCad: the KiCad-process windows that /// are dialogs (Win32 dialog class or owned), plus a GLOBAL #32770/error-title sweep so /// an error box owned by an untracked pcbnew/eeschema/orphan is caught too.-fn scan_dialogs() -> Vec<Dialog> {+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();@@ -1281,7 +1281,7 @@ fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static // ── kicad_launch ────────────────────────────────────────────────────────────── -fn launch(state: &mut State, args: &Value) -> Value {+pub(crate) fn launch(state: &mut State, args: &Value) -> Value { let existing = match find_windows(false, true) { Ok((w, _)) => w, Err(e) => return e,@@ -1343,7 +1343,7 @@ fn launch(state: &mut State, args: &Value) -> Value { // ── kicad_open_board / kicad_open_schematic ─────────────────────────────────── -fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {+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.");@@ -1476,7 +1476,7 @@ fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool { libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false) } -fn open_symbol_editor(state: &mut State, args: &Value) -> Value {+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.");@@ -1647,7 +1647,7 @@ fn merge(into: &mut Value, extra: Value) { // ── kicad_open_footprint_editor ─────────────────────────────────────────────── -fn open_footprint_editor(state: &mut State, args: &Value) -> Value {+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.");@@ -1784,7 +1784,7 @@ fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<O Ok(None) } -fn open_3d_viewer(args: &Value) -> Value {+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.");@@ -2153,7 +2153,7 @@ fn canvas_probe(hwnd: u64) -> Value { /// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull); /// the canvas verdict is always ours.-fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {+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."}),@@ -2218,7 +2218,7 @@ fn screenshot_all(state: &mut State) -> Value { // ── kicad_state ─────────────────────────────────────────────────────────────── -fn b64_encode(bytes: &[u8]) -> String {+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) {
rust/crates/kicad-core/src/install_kicad.rs+847−1@@ -1 +1,847 @@-//! Placeholder: KiCad silent install and upgrade, filled in by phase 3b.+//! kicad_upgrade: the write half of handlers/upgrade.py. Install or upgrade KiCad from the+//! OFFICIAL downloads.kicad.org installer, silently, the way the Python bridge does:+//!+//! 1. target version: `version` if pinned, else the latest stable (updates.rs);+//! 2. scope: `scope` if given, else `allusers` under an elevated token, else the zero-UAC+//! per-user install (`currentuser`, %LOCALAPPDATA%\Programs\KiCad);+//! 3. installer: a complete cached file is reused, otherwise a streamed download into+//! `<LOCALAPPDATA>/Adom Bridge/kicad-installers`, every byte counted against the+//! server's size so a dropped connection can never pass as "download complete";+//! 4. run: `<installer> /allusers|/currentuser /S` through the OS layer, 900 s deadline;+//! 5. verify: a FRESH detect, `kicad-cli version` compared as full X.Y.Z (the "10.0"+//! detection string could never match a "10.0.5" target, AdomLapper 2026-08-16);+//! 6. seed: the new version's first-run config (kicad_common::seed_first_run) and its lib+//! tables (install::ensure_user_config), so the first GUI open skips the wizard.+//!+//! `diagnoseOnly:true` returns the elevation and UAC ground truth plus the cache state+//! without downloading or running anything. The OS layer supplies four facts+//! (`elevation_info`, `run_installer`, `installer_kind`, `installer_download_url`);+//! everything here is OS-neutral. The terminal `progress` block is built here in the+//! Python shape (phase, stepLabel, percent, elapsedSec, measured{download, install, verify}).++use std::path::{Path, PathBuf};+use std::time::{Duration, Instant};++use serde_json::{json, Value};++use kicad_platform::{native, KicadInstall};++use crate::detect;+use crate::install;+use crate::kicad_common;+use crate::libraries::LibCtx;+use crate::updates::{self, version_tuple, Fetch};++/// handlers/upgrade.py: `proc.run([...], timeout=900)`.+pub const INSTALL_TIMEOUT_SECS: u64 = 900;+/// A real KiCad installer is a > 50 MB Windows PE.+pub const MIN_INSTALLER_BYTES: u64 = 50 * 1024 * 1024;+/// Total budget for receiving the installer body (the verb's own timeout is 900 s).+#[cfg(feature = "net")]+const DOWNLOAD_BUDGET: Duration = Duration::from_secs(900);+const SIZE_PROBE_TIMEOUT: Duration = Duration::from_secs(30);+/// Under `<LOCALAPPDATA>/Adom Bridge/`.+pub const CACHE_DIR_NAME: &str = "kicad-installers";+/// Progress phase name (Python PREFIX "kicad" + the verb).+pub const PHASE: &str = "kicad.upgrade";+/// The editors whose running instance blocks an install (files in use).+pub const KICAD_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe"];+#[cfg(feature = "net")]+const LOG_EVERY: u64 = 25 * 1024 * 1024;++/// NsisMultiUser exit codes (documented error levels).+pub const RC_INVALID_PARAMS: i32 = 666_660;+pub const RC_ELEVATION_RESTRICTED: i32 = 666_661;++fn log(msg: &str) {+ eprintln!("[kicad-bridge] kicad_upgrade: {msg}");+}++// ---------------------------------------------------------------------------+// Scope+// ---------------------------------------------------------------------------++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Scope {+ /// Program Files, HKLM; needs an elevated token (`/allusers`).+ AllUsers,+ /// %LOCALAPPDATA%\Programs\KiCad, HKCU; no admin, no UAC (`/currentuser`).+ CurrentUser,+}++impl Scope {+ pub fn as_str(self) -> &'static str {+ match self {+ Scope::AllUsers => "allusers",+ Scope::CurrentUser => "currentuser",+ }+ }+ /// The installer flag.+ pub fn flag(self) -> &'static str {+ match self {+ Scope::AllUsers => "/allusers",+ Scope::CurrentUser => "/currentuser",+ }+ }+ /// The Python bridge's spelling ("machine" | "user"), reported alongside for old readers.+ pub fn legacy(self) -> &'static str {+ match self {+ Scope::AllUsers => "machine",+ Scope::CurrentUser => "user",+ }+ }+}++/// "allusers" | "currentuser" (the installer's words) plus the Python bridge's+/// "machine" | "user"; case-insensitive, dashes and underscores ignored.+pub fn parse_scope(s: &str) -> Option<Scope> {+ let k: String = s.trim().to_ascii_lowercase().chars().filter(|c| *c != '-' && *c != '_' && *c != ' ').collect();+ match k.as_str() {+ "allusers" | "machine" | "permachine" | "system" => Some(Scope::AllUsers),+ "currentuser" | "user" | "peruser" => Some(Scope::CurrentUser),+ _ => None,+ }+}++/// The Python rule: an explicit scope wins; otherwise per-machine when this process is+/// elevated, else the zero-UAC per-user install (detection scans both locations).+pub fn choose_scope(requested: Option<&str>, is_admin: bool) -> Result<Scope, String> {+ match requested {+ Some(s) => parse_scope(s).ok_or_else(|| format!("unknown scope {s:?}: use \"allusers\" (Program Files, needs elevation) or \"currentuser\" (per-user, no UAC)")),+ None => Ok(if is_admin { Scope::AllUsers } else { Scope::CurrentUser }),+ }+}++// ---------------------------------------------------------------------------+// Target version+// ---------------------------------------------------------------------------++/// (version, source): the pinned `version` arg ("pinned"), else the latest stable+/// ("gitlab" | "fallback").+pub fn resolve_target(args: &Value, fetch: Fetch) -> (String, &'static str) {+ if let Some(v) = args.get("version").and_then(Value::as_str).map(str::trim).filter(|v| !v.is_empty()) {+ return (v.to_string(), "pinned");+ }+ updates::latest_stable_version(fetch)+}++/// The full version of an install (kicad-cli X.Y.Z, else the major.minor key).+pub fn full_version(i: &KicadInstall) -> &str {+ if i.cli_version.is_empty() {+ &i.version+ } else {+ &i.cli_version+ }+}++/// Python: `current >= target and not force` short-circuits with alreadyCurrent.+/// `installs` is newest first (detect's order).+pub fn already_current(installs: &[KicadInstall], target: &str, force: bool) -> Option<Value> {+ let current = installs.first().map(full_version)?;+ if force || version_tuple(current) < version_tuple(target) {+ return None;+ }+ Some(json!({+ "success": true,+ "alreadyCurrent": true,+ "currentVersion": current,+ "targetVersion": target,+ "_hint": format!("KiCad {current} >= {target}; nothing to do. Pass {{\"force\":true}} to reinstall."),+ }))+}++// ---------------------------------------------------------------------------+// Installer cache+// ---------------------------------------------------------------------------++/// `<LOCALAPPDATA>/Adom Bridge/kicad-installers`, falling back to+/// `<USERPROFILE>/AppData/Local/...` (ab's stripped environment) and `~/.local/share/...`.+pub fn cache_dir() -> Option<PathBuf> {+ let base = std::env::var_os("LOCALAPPDATA")+ .map(PathBuf::from)+ .or_else(|| std::env::var_os("USERPROFILE").map(|u| PathBuf::from(u).join("AppData").join("Local")))+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local").join("share")))?;+ Some(base.join("Adom Bridge").join(CACHE_DIR_NAME))+}++/// The installer's file name: the URL's last path segment ("kicad-10.0.6-x86_64.exe").+pub fn installer_file_name(url: &str, target: &str) -> String {+ url.trim_end_matches('/')+ .rsplit('/')+ .next()+ .filter(|s| !s.is_empty() && s.contains('.'))+ .map(String::from)+ .unwrap_or_else(|| format!("kicad-{target}-x86_64.exe"))+}++/// Python `_valid_installer`: exists, > 50 MB, and (for an NSIS exe) starts with "MZ".+pub fn valid_installer(p: &Path, kind: &str) -> bool {+ let Ok(meta) = std::fs::metadata(p) else { return false };+ if !meta.is_file() || meta.len() < MIN_INSTALLER_BYTES {+ return false;+ }+ if kind != "nsis-exe" {+ return true;+ }+ is_pe(p)+}++fn is_pe(p: &Path) -> bool {+ use std::io::Read;+ let Ok(mut f) = std::fs::File::open(p) else { return false };+ let mut magic = [0u8; 2];+ f.read_exact(&mut magic).is_ok() && &magic == b"MZ"+}++#[derive(Clone, Debug)]+pub struct Cached {+ pub path: PathBuf,+ pub bytes: u64,+ /// Size differs from the server's (None when the server's size is unknown).+ pub truncated: Option<bool>,+}++impl Cached {+ /// Safe to reuse: valid and, when the server's size is known, the same size.+ pub fn complete(&self) -> bool {+ self.truncated != Some(true)+ }+}++/// The first valid installer among `candidates` (a complete one wins over a truncated one).+pub fn find_cached(candidates: &[PathBuf], expected: Option<u64>, kind: &str) -> Option<Cached> {+ let mut found: Vec<Cached> = candidates+ .iter()+ .filter(|p| valid_installer(p, kind))+ .filter_map(|p| {+ let bytes = std::fs::metadata(p).ok()?.len();+ Some(Cached { path: p.clone(), bytes, truncated: expected.map(|e| e != bytes) })+ })+ .collect();+ found.sort_by_key(|c| !c.complete());+ found.into_iter().next()+}++// ---------------------------------------------------------------------------+// Download (streamed, size-verified)+// ---------------------------------------------------------------------------++/// GET `url` into `dest` (through `<dest>.part`, renamed on completion), 1 MiB reads,+/// a log line every 25 MiB. Returns (bytes written, the server's size if it said).+#[cfg(not(feature = "net"))]+pub fn stream_download(_url: &str, _dest: &Path, _expected: Option<u64>) -> Result<(u64, Option<u64>), String> {+ Err("the net feature is off in this build: no HTTPS client".into())+}++#[cfg(feature = "net")]+pub fn stream_download(url: &str, dest: &Path, expected: Option<u64>) -> Result<(u64, Option<u64>), String> {+ use std::io::{Read, Write};+ let mut resp = ureq::get(url)+ .header("User-Agent", "kicad-bridge")+ .config()+ .timeout_connect(Some(Duration::from_secs(30)))+ .timeout_recv_response(Some(Duration::from_secs(60)))+ .timeout_recv_body(Some(DOWNLOAD_BUDGET))+ .build()+ .call()+ .map_err(|e| e.to_string())?;+ let expected = expected.or_else(|| resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|s| s.trim().parse::<u64>().ok()));+ let part = part_path(dest);+ let mut file = std::fs::File::create(&part).map_err(|e| format!("cannot create {}: {e}", part.display()))?;+ let mut reader = resp.body_mut().with_config().limit(u64::MAX).reader();+ let mut buf = vec![0u8; 1 << 20];+ let mut total: u64 = 0;+ let mut next_log = LOG_EVERY;+ loop {+ let n = match reader.read(&mut buf) {+ Ok(0) => break,+ Ok(n) => n,+ Err(e) => {+ drop(file);+ let _ = std::fs::remove_file(&part);+ return Err(format!("read failed after {total} bytes: {e}"));+ }+ };+ if let Err(e) = file.write_all(&buf[..n]) {+ drop(file);+ let _ = std::fs::remove_file(&part);+ return Err(format!("write failed at {total} bytes: {e}"));+ }+ total += n as u64;+ if total >= next_log {+ log(&format!("downloaded {} MB...", total / (1024 * 1024)));+ next_log += LOG_EVERY;+ }+ }+ file.flush().map_err(|e| e.to_string())?;+ drop(file);+ let _ = std::fs::remove_file(dest);+ std::fs::rename(&part, dest).map_err(|e| format!("cannot move {} into place: {e}", part.display()))?;+ Ok((total, expected))+}++#[cfg(any(feature = "net", test))]+fn part_path(dest: &Path) -> PathBuf {+ let mut name = dest.file_name().map(|n| n.to_os_string()).unwrap_or_default();+ name.push(".part");+ dest.with_file_name(name)+}++// ---------------------------------------------------------------------------+// Progress (terminal frame, Python handlers/progress.py `finish` shape)+// ---------------------------------------------------------------------------++#[derive(Clone, Copy, Debug, Default)]+pub struct Measured {+ pub download: Option<f64>,+ pub install: Option<f64>,+ pub verify: Option<f64>,+}++fn round1(x: f64) -> f64 {+ (x * 10.0).round() / 10.0+}+fn round2(x: f64) -> f64 {+ (x * 100.0).round() / 100.0+}++/// The terminal `progress` block: percent 100 with the actuals on success, percent null+/// (an honest "did not finish") on failure, exactly like the Python's `finish(ok=False)`.+pub fn progress_block(elapsed_sec: f64, ok: bool, step_label: &str, m: &Measured) -> Value {+ let mut b = json!({+ "phase": PHASE,+ "stepLabel": step_label,+ "percent": if ok { json!(100) } else { Value::Null },+ "elapsedSec": round1(elapsed_sec),+ "estimatedSec": round1(elapsed_sec),+ "etaSec": if ok { json!(0.0) } else { Value::Null },+ "confidence": "measured",+ "startKind": "cold",+ });+ if ok {+ b["measured"] = json!({+ "phase": PHASE,+ "seconds": round2(elapsed_sec),+ "startKind": "cold",+ "download": m.download.map(round2),+ "install": m.install.map(round2),+ "verify": m.verify.map(round2),+ });+ }+ b+}++// ---------------------------------------------------------------------------+// Running KiCad+// ---------------------------------------------------------------------------++/// (running, pids, why_unknown). `running` is None where the OS layer cannot list+/// processes; the verb then says so instead of guessing.+pub fn kicad_running() -> (Option<bool>, Vec<u32>, Option<String>) {+ match native().processes(KICAD_EXES) {+ Ok(ps) => {+ let pids: Vec<u32> = ps.iter().map(|p| p.pid).collect();+ (Some(!pids.is_empty()), pids, None)+ }+ Err(e) => (None, Vec::new(), Some(e)),+ }+}++// ---------------------------------------------------------------------------+// The verb+// ---------------------------------------------------------------------------++pub const DIAGNOSE_HINT: &str = "Read tokenElevationTypeMeaning + uacEnableLUA together: EnableLUA=0 means UAC is disabled machine-wide, so an admin account always runs with a full token and NO UAC prompt ever appears (IsUserAnAdmin=true without anyone clicking anything). tokenElevated=1 with an rc=666660 install failure means elevation is NOT the cause (look at SmartScreen/AV or the installer's silent flag instead). Nothing was downloaded or run.";++struct Probe {+ facts: Value,+ is_admin: bool,+ target: String,+ target_source: &'static str,+ url: Option<String>,+ kind: &'static str,+ cache_dir: Option<PathBuf>,+ candidates: Vec<PathBuf>,+ expected: Option<u64>,+ cached: Option<Cached>,+ running: (Option<bool>, Vec<u32>, Option<String>),+}++fn probe(args: &Value, fetch: Fetch) -> Probe {+ let facts = match native().elevation_info() {+ Ok(v) => v,+ Err(e) => json!({"platform": native().capabilities().os, "error": e, "isAdmin": Value::Null, "uacEnabled": Value::Null, "consentPrompt": Value::Null, "canInstallAllUsersSilently": Value::Null}),+ };+ let is_admin = facts.get("isAdmin").and_then(Value::as_bool).unwrap_or(false);+ let (target, target_source) = resolve_target(args, fetch);+ let url = native().installer_download_url(&target);+ let kind = native().installer_kind();+ let cache_dir = cache_dir();+ let name = url.as_deref().map(|u| installer_file_name(u, &target)).unwrap_or_else(|| format!("kicad-{target}-x86_64.exe"));+ let mut candidates: Vec<PathBuf> = Vec::new();+ if let Some(d) = &cache_dir {+ candidates.push(d.join(&name));+ }+ // The Python bridge cached in %TEMP%; a box it already downloaded to still saves the gigabyte.+ candidates.push(std::env::temp_dir().join(&name));+ let expected = url.as_deref().and_then(|u| updates::remote_installer_size(u, SIZE_PROBE_TIMEOUT));+ let cached = find_cached(&candidates, expected, kind);+ Probe { facts, is_admin, target, target_source, url, kind, cache_dir, candidates, expected, cached, running: kicad_running() }+}++fn diagnose(p: &Probe, installs: &[KicadInstall], args: &Value) -> Value {+ let mut out = json!({"success": true, "diagnoseOnly": true});+ if let (Some(o), Some(f)) = (out.as_object_mut(), p.facts.as_object()) {+ for (k, v) in f {+ o.insert(k.clone(), v.clone());+ }+ }+ let scope = choose_scope(args.get("scope").and_then(Value::as_str), p.is_admin);+ let installed: Vec<&str> = installs.iter().map(full_version).collect();+ out["cachedInstaller"] = json!(p.cached.as_ref().map(|c| c.path.to_string_lossy().replace('\\', "/")));+ out["cachedInstallerBytes"] = json!(p.cached.as_ref().map(|c| c.bytes).unwrap_or(0));+ out["serverInstallerBytes"] = json!(p.expected);+ out["cachedInstallerTruncated"] = json!(p.cached.as_ref().map(|c| c.truncated == Some(true)).unwrap_or(false));+ out["cacheDir"] = json!(p.cache_dir.as_ref().map(|d| d.to_string_lossy().replace('\\', "/")));+ out["cacheCandidates"] = json!(p.candidates.iter().map(|c| c.to_string_lossy().replace('\\', "/")).collect::<Vec<_>>());+ out["targetVersion"] = json!(p.target);+ out["targetSource"] = json!(p.target_source);+ out["downloadUrl"] = json!(p.url);+ out["installerKind"] = json!(p.kind);+ out["silentInstallSupported"] = json!(p.url.is_some());+ out["installedVersions"] = json!(installed);+ out["alreadyCurrent"] = json!(already_current(installs, &p.target, false).is_some());+ out["scopeWouldBe"] = match &scope {+ Ok(s) => json!(s.as_str()),+ Err(e) => json!(format!("error: {e}")),+ };+ out["kicadRunning"] = json!(p.running.0);+ out["kicadPids"] = json!(p.running.1);+ if let Some(why) = &p.running.2 {+ out["kicadRunningUnknownBecause"] = json!(why);+ }+ out["_hint"] = json!(DIAGNOSE_HINT);+ out+}++fn fail(code: &str, msg: String) -> Value {+ json!({"success": false, "errorCode": code, "error": msg})+}++fn norm(p: &Path) -> String {+ p.to_string_lossy().replace('\\', "/")+}++/// The verb. `installs` is the bridge's current detection (newest first); the result is+/// built from a FRESH detect after the installer exits.+pub fn handle(installs: &[KicadInstall], args: &Value, fetch: Fetch) -> Value {+ let started = Instant::now();+ let diagnose_only = args.get("diagnoseOnly").and_then(Value::as_bool).unwrap_or(false);+ let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+ let re_download = args.get("reDownload").and_then(Value::as_bool).unwrap_or(false);++ let p = probe(args, fetch);+ if diagnose_only {+ return diagnose(&p, installs, args);+ }++ let os = native().capabilities().os;+ let Some(url) = p.url.clone() else {+ let mut v = fail(+ "unsupported_platform",+ format!("kicad_upgrade drives the Windows NSIS installer silently; on {os} KiCad installs go through the OS package path ({}), which the bridge does not run.", p.kind),+ );+ v["installerKind"] = json!(p.kind);+ v["latestVersion"] = json!(p.target);+ v["platform"] = json!(os);+ v["_hint"] = json!(match p.kind {+ "dmg" => "Install KiCad from the .dmg at https://www.kicad.org/download/macos/ (or `brew install --cask kicad`), then rerun kicad_readiness.",+ _ => "Install KiCad with the distro's package manager (apt/dnf/pacman, the KiCad PPA, snap or flatpak), then rerun kicad_readiness.",+ });+ return v;+ };++ if let Some(v) = already_current(installs, &p.target, force) {+ return v;+ }++ let (running, pids, unknown) = p.running.clone();+ if running == Some(true) {+ let mut v = fail("kicad_running", format!("KiCad is running (pids {:?}); the installer would fail on files in use.", pids));+ v["kicadPids"] = json!(pids);+ v["targetVersion"] = json!(p.target);+ v["_hint"] = json!("Ask the user to save their work, run kicad_close, then rerun kicad_upgrade. Never update mid-design-work.");+ return v;+ }+ if let Some(why) = &unknown {+ log(&format!("cannot tell whether KiCad is running on this OS ({why}); proceeding"));+ }++ let scope = match choose_scope(args.get("scope").and_then(Value::as_str), p.is_admin) {+ Ok(s) => s,+ Err(e) => {+ let mut v = fail("invalid_scope", e);+ v["_hint"] = json!("scope is \"allusers\" (Program Files, needs an elevated bridge) or \"currentuser\" (per-user, no admin, no UAC). Omit it to pick automatically.");+ return v;+ }+ };+ let current = installs.first().map(|i| full_version(i).to_string());+ let previous_version = current.clone();+ let mut measured = Measured::default();+ let mut timings = json!({});++ // ---- installer: cached or downloaded+ log(&format!("server reports installer size {} bytes", p.expected.map(|n| n.to_string()).unwrap_or_else(|| "unknown".into())));+ let Some(cache) = p.cache_dir.clone() else {+ return fail("no_cache_dir", "cannot resolve a cache directory (LOCALAPPDATA, USERPROFILE and HOME are all unset)".into());+ };+ if let Err(e) = std::fs::create_dir_all(&cache) {+ return fail("no_cache_dir", format!("cannot create {}: {e}", cache.display()));+ }+ let dest = cache.join(installer_file_name(&url, &p.target));+ let reuse = p.cached.as_ref().filter(|c| c.complete() && !re_download).cloned();+ if let Some(c) = p.cached.as_ref().filter(|c| c.truncated == Some(true)) {+ log(&format!("cached installer is TRUNCATED ({} of {} bytes): discarding and re-downloading", c.bytes, p.expected.unwrap_or(0)));+ if c.path.starts_with(&cache) {+ let _ = std::fs::remove_file(&c.path);+ }+ }+ let (installer, total, reused) = match reuse {+ Some(c) => {+ log(&format!("reusing cached installer {} ({} MB, size verified); pass reDownload:true to force a fresh fetch", norm(&c.path), c.bytes / (1024 * 1024)));+ measured.download = Some(0.0);+ (c.path, c.bytes, true)+ }+ None => {+ log(&format!("downloading {url} -> {}", norm(&dest)));+ let t = Instant::now();+ let (total, expected) = match stream_download(&url, &dest, p.expected) {+ Ok(x) => x,+ Err(e) => {+ log(&format!("DOWNLOAD FAILED: {e}"));+ let mut v = fail("download_failed", format!("download failed: {e}"));+ v["url"] = json!(url);+ v["progress"] = progress_block(started.elapsed().as_secs_f64(), false, "download failed", &measured);+ return v;+ }+ };+ measured.download = Some(t.elapsed().as_secs_f64());+ timings["download"] = json!(round2(t.elapsed().as_secs_f64()));+ log(&format!("download finished ({total} of {} bytes) -> {}", expected.map(|n| n.to_string()).unwrap_or_else(|| "unknown".into()), norm(&dest)));+ if let Some(exp) = expected {+ if exp != total {+ log(&format!("TRUNCATED download ({total} of {exp}): deleting"));+ let _ = std::fs::remove_file(&dest);+ let mut v = fail("download_truncated", format!("download truncated: got {total} of {exp} bytes (connection dropped mid-transfer). The file was deleted."));+ v["url"] = json!(url);+ v["_hint"] = json!("Re-run kicad_upgrade; it will re-download. If this repeats, the mirror or the VM's link is flaky; try again later.");+ v["progress"] = progress_block(started.elapsed().as_secs_f64(), false, "download truncated", &measured);+ return v;+ }+ }+ (dest.clone(), total, false)+ }+ };++ // ---- sanity: > 50 MB Windows PE+ if total < MIN_INSTALLER_BYTES {+ let mut v = fail("download_incomplete", format!("download too small ({total} bytes): not a full installer"));+ v["url"] = json!(url);+ v["path"] = json!(norm(&installer));+ return v;+ }+ if p.kind == "nsis-exe" && !is_pe(&installer) {+ let _ = std::fs::remove_file(&installer);+ let mut v = fail("download_corrupt", "downloaded file is not a Windows executable (deleted)".into());+ v["path"] = json!(norm(&installer));+ return v;+ }++ // ---- silent run+ log(&format!("elevated={}; launching installer {} /S (scope={})...", p.is_admin, scope.flag(), scope.as_str()));+ let t = Instant::now();+ let rc = match native().run_installer(&installer, scope == Scope::AllUsers, INSTALL_TIMEOUT_SECS) {+ Ok(rc) => rc,+ Err(e) => {+ log(&format!("INSTALLER LAUNCH/RUN FAILED: {e}"));+ measured.install = Some(t.elapsed().as_secs_f64());+ let mut v = fail("install_failed", format!("installer launch failed: {e}"));+ v["elevated"] = json!(p.is_admin);+ v["installScope"] = json!(scope.as_str());+ v["path"] = json!(norm(&installer));+ v["_hint"] = json!("If a UAC prompt appeared, approve it; or run the downloaded installer manually.");+ v["progress"] = progress_block(started.elapsed().as_secs_f64(), false, "install failed", &measured);+ return v;+ }+ };+ measured.install = Some(t.elapsed().as_secs_f64());+ timings["install"] = json!(round2(t.elapsed().as_secs_f64()));+ log(&format!("installer exited rc={rc}"));++ // ---- verify with a fresh detect (kicad-cli version, full X.Y.Z)+ let t = Instant::now();+ let after = detect::detect();+ let installed: Vec<String> = after.installs.iter().map(|i| full_version(i).to_string()).collect();+ let target_t = version_tuple(&p.target);+ let now_has = installed.iter().any(|v| version_tuple(v) >= target_t);+ measured.verify = Some(t.elapsed().as_secs_f64());+ timings["verify"] = json!(round2(t.elapsed().as_secs_f64()));++ // ---- first-run seeding for the new version (lib tables first: its freshness test+ // must see the config dir before seed_first_run creates it)+ let mut seeded = Value::Null;+ if now_has {+ if let Some(inst) = after.installs.iter().find(|i| version_tuple(full_version(i)) >= target_t) {+ seeded = seed_new_install(inst);+ }+ }++ let hint = if now_has {+ format!("KiCad {} installed ({} scope). Config seeded; the first GUI open skips the first-run wizard.", p.target, scope.as_str())+ } else {+ let why = if rc == RC_INVALID_PARAMS || rc == RC_ELEVATION_RESTRICTED {+ "rc=666660 is NsisMultiUser 'invalid command-line parameters' and 666661 is 'elevation restricted': retry with the other scope: {\"scope\":\"currentuser\"} needs NO admin/UAC (installs to %LOCALAPPDATA%\\Programs\\KiCad); {\"scope\":\"allusers\"} needs an elevated bridge. "+ } else {+ "Check SmartScreen/AV blocking the .exe or a pending reboot. "+ };+ format!(+ "Installer ran (exit {rc}) but {} wasn't detected. elevated={}, scope={}. {why}Installed: {}.",+ p.target,+ p.is_admin,+ scope.as_str(),+ if installed.is_empty() { "none".to_string() } else { installed.join(", ") }+ )+ };+ let label = if now_has { format!("KiCad {} installed", p.target) } else { format!("installer exited rc={rc}, {} not detected", p.target) };+ json!({+ "success": now_has,+ "upgraded": now_has,+ "targetVersion": p.target,+ "targetSource": p.target_source,+ "previousVersion": previous_version,+ "installerExit": rc,+ "elevated": p.is_admin,+ "installScope": scope.as_str(),+ "installScopeLegacy": scope.legacy(),+ "installerFlag": scope.flag(),+ "installedVersions": installed,+ "downloadUrl": url,+ "installerPath": norm(&installer),+ "installerBytes": total,+ "cachedInstallerReused": reused,+ "kicadRunning": running,+ "firstRunSeeded": seeded,+ "timingsSec": timings,+ "progress": progress_block(started.elapsed().as_secs_f64(), now_has, &label, &measured),+ "_hint": hint,+ })+}++/// server.py after a successful upgrade: `ensure_win_user_config` (dirs and lib tables+/// from the install template) and the first-run JSON documents, for the NEW version.+fn seed_new_install(inst: &KicadInstall) -> Value {+ let config_dir = native().config_dir(&inst.version);+ let ctx = LibCtx {+ version: inst.version.clone(),+ base_dir: Some(inst.base_dir.clone()).filter(|b| !b.is_empty()),+ config_dir: config_dir.clone(),+ user_dir: native().user_dir(&inst.version),+ kicad_cli: Some(PathBuf::from(&inst.kicad_cli)).filter(|p| p.is_file()),+ env_overrides: Default::default(),+ };+ let lib_tables = match install::ensure_user_config(&ctx) {+ Ok((c, u)) => json!({"ok": true, "configDir": norm(&c), "userDir": norm(&u)}),+ Err(e) => json!({"ok": false, "error": e}),+ };+ let Some(cfg) = config_dir else {+ return json!({"version": inst.version, "configDir": Value::Null, "libTables": lib_tables, "error": "no config dir for this version on this OS"});+ };+ let s = kicad_common::seed_first_run(&cfg);+ json!({+ "version": inst.version,+ "configDir": norm(&cfg),+ "written": s.written,+ "skipped": s.skipped,+ "errors": s.errors,+ "libTables": lib_tables,+ })+}++#[cfg(test)]+mod tests {+ use super::*;++ struct Tmp(PathBuf);+ impl Drop for Tmp {+ fn drop(&mut self) {+ let _ = std::fs::remove_dir_all(&self.0);+ }+ }+ fn tmp(name: &str) -> Tmp {+ let p = std::env::temp_dir().join(format!("kicad-core-upgrade-{}-{name}", std::process::id()));+ let _ = std::fs::remove_dir_all(&p);+ std::fs::create_dir_all(&p).unwrap();+ Tmp(p)+ }+ /// A sparse file of `len` bytes starting with `head` (no gigabyte on disk).+ fn sparse(p: &Path, len: u64, head: &[u8]) {+ use std::io::Write;+ let mut f = std::fs::File::create(p).unwrap();+ f.set_len(len).unwrap();+ f.write_all(head).unwrap();+ }+ fn offline(_: &str, _: Duration) -> Result<Vec<u8>, String> {+ Err("offline".into())+ }+ fn inst(cli_version: &str) -> KicadInstall {+ KicadInstall { version: updates::version_tuple(cli_version).iter().take(2).map(|n| n.to_string()).collect::<Vec<_>>().join("."), cli_version: cli_version.into(), ..Default::default() }+ }++ #[test]+ fn scope_rules_match_python() {+ assert_eq!(parse_scope("allusers"), Some(Scope::AllUsers));+ assert_eq!(parse_scope("AllUsers"), Some(Scope::AllUsers));+ assert_eq!(parse_scope("all-users"), Some(Scope::AllUsers));+ assert_eq!(parse_scope("machine"), Some(Scope::AllUsers));+ assert_eq!(parse_scope("currentuser"), Some(Scope::CurrentUser));+ assert_eq!(parse_scope("current_user"), Some(Scope::CurrentUser));+ assert_eq!(parse_scope("user"), Some(Scope::CurrentUser));+ assert_eq!(parse_scope("global"), None);+ // Explicit wins; default follows the token.+ assert_eq!(choose_scope(Some("currentuser"), true), Ok(Scope::CurrentUser));+ assert_eq!(choose_scope(Some("allusers"), false), Ok(Scope::AllUsers));+ assert_eq!(choose_scope(None, true), Ok(Scope::AllUsers));+ assert_eq!(choose_scope(None, false), Ok(Scope::CurrentUser));+ assert!(choose_scope(Some("bogus"), false).unwrap_err().contains("bogus"));+ assert_eq!(Scope::AllUsers.flag(), "/allusers");+ assert_eq!(Scope::CurrentUser.flag(), "/currentuser");+ assert_eq!(Scope::AllUsers.legacy(), "machine");+ assert_eq!(Scope::CurrentUser.legacy(), "user");+ }++ #[test]+ fn target_pinned_or_latest() {+ assert_eq!(resolve_target(&json!({"version": "10.0.5"}), &offline), ("10.0.5".to_string(), "pinned"));+ assert_eq!(resolve_target(&json!({"version": " "}), &offline), (updates::FALLBACK_LATEST.to_string(), "fallback"));+ assert_eq!(resolve_target(&json!({}), &offline), (updates::FALLBACK_LATEST.to_string(), "fallback"));+ let online = |_: &str, _: Duration| Ok::<Vec<u8>, String>(br#"[{"name":"10.0.7"},{"name":"10.99.0"}]"#.to_vec());+ assert_eq!(resolve_target(&json!({}), &online), ("10.0.7".to_string(), "gitlab"));+ }++ #[test]+ fn already_current_compares_full_versions() {+ let installs = vec![inst("10.0.6"), inst("9.0.9")];+ let v = already_current(&installs, "10.0.6", false).unwrap();+ assert_eq!(v["alreadyCurrent"], json!(true));+ assert_eq!(v["currentVersion"], json!("10.0.6"));+ assert!(v["_hint"].as_str().unwrap().contains("force"));+ assert!(already_current(&installs, "10.0.7", false).is_none());+ assert!(already_current(&installs, "10.0.6", true).is_none(), "force reinstalls");+ assert!(already_current(&[], "10.0.6", false).is_none(), "nothing installed: install");+ // The "10.0" detection string alone never blocks a point release (AdomLapper bug).+ let major_only = vec![KicadInstall { version: "10.0".into(), ..Default::default() }];+ assert!(already_current(&major_only, "10.0.5", false).is_none());+ }++ #[test]+ fn installer_names_and_cache_dir() {+ assert_eq!(installer_file_name("https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-10.0.6-x86_64.exe", "10.0.6"), "kicad-10.0.6-x86_64.exe");+ assert_eq!(installer_file_name("https://x/y/", "1.2.3"), "kicad-1.2.3-x86_64.exe");+ assert_eq!(installer_file_name("", "1.2.3"), "kicad-1.2.3-x86_64.exe");+ let d = cache_dir().expect("HOME or LOCALAPPDATA is set in tests");+ assert!(d.ends_with(Path::new("Adom Bridge").join(CACHE_DIR_NAME)), "{}", d.display());+ assert_eq!(part_path(Path::new("/a/b/kicad-10.0.6-x86_64.exe")), PathBuf::from("/a/b/kicad-10.0.6-x86_64.exe.part"));+ }++ #[test]+ fn installer_validity_and_cache_lookup() {+ let t = tmp("cache");+ let small = t.0.join("small.exe");+ std::fs::write(&small, b"MZ tiny").unwrap();+ assert!(!valid_installer(&small, "nsis-exe"), "under 50 MB");+ let not_pe = t.0.join("notpe.exe");+ sparse(¬_pe, MIN_INSTALLER_BYTES + 5, b"PK");+ assert!(!valid_installer(¬_pe, "nsis-exe"));+ assert!(valid_installer(¬_pe, "dmg"), "the MZ check is NSIS-only");+ let good = t.0.join("kicad-10.0.6-x86_64.exe");+ sparse(&good, MIN_INSTALLER_BYTES + 5, b"MZ");+ assert!(valid_installer(&good, "nsis-exe"));+ assert!(!valid_installer(&t.0.join("missing.exe"), "nsis-exe"));++ // Complete when the server agrees, truncated when it does not, unknown when it is silent.+ let c = find_cached(&[t.0.join("missing.exe"), good.clone()], Some(MIN_INSTALLER_BYTES + 5), "nsis-exe").unwrap();+ assert_eq!(c.path, good);+ assert_eq!(c.truncated, Some(false));+ assert!(c.complete());+ let c = find_cached(&[good.clone()], Some(MIN_INSTALLER_BYTES + 999), "nsis-exe").unwrap();+ assert_eq!(c.truncated, Some(true));+ assert!(!c.complete());+ let c = find_cached(&[good.clone()], None, "nsis-exe").unwrap();+ assert_eq!(c.truncated, None);+ assert!(c.complete(), "no server size: the MZ + 50 MB checks are all we have, like the Python");+ assert!(find_cached(&[small], Some(7), "nsis-exe").is_none());+ // A complete copy elsewhere beats a truncated one first in the list.+ let other = t.0.join("other").join("kicad-10.0.6-x86_64.exe");+ std::fs::create_dir_all(other.parent().unwrap()).unwrap();+ sparse(&other, MIN_INSTALLER_BYTES + 999, b"MZ");+ let c = find_cached(&[good.clone(), other.clone()], Some(MIN_INSTALLER_BYTES + 999), "nsis-exe").unwrap();+ assert_eq!(c.path, other);+ }++ #[test]+ fn progress_block_shapes() {+ let m = Measured { download: Some(12.345), install: Some(60.0), verify: Some(0.5) };+ let b = progress_block(73.21, true, "KiCad 10.0.6 installed", &m);+ assert_eq!(b["phase"], json!("kicad.upgrade"));+ assert_eq!(b["percent"], json!(100));+ assert_eq!(b["elapsedSec"], json!(73.2));+ assert_eq!(b["etaSec"], json!(0.0));+ assert_eq!(b["confidence"], json!("measured"));+ assert_eq!(b["measured"]["download"], json!(12.35));+ assert_eq!(b["measured"]["install"], json!(60.0));+ assert_eq!(b["measured"]["verify"], json!(0.5));+ assert_eq!(b["measured"]["seconds"], json!(73.21));+ let f = progress_block(3.0, false, "download failed", &Measured::default());+ assert_eq!(f["percent"], Value::Null);+ assert!(f.get("measured").is_none(), "a failed run does not feed the history");+ assert_eq!(f["stepLabel"], json!("download failed"));+ }++ #[test]+ fn diagnose_only_never_downloads_or_runs() {+ // On this (Linux) test box the OS layer has no installer URL, so no network at+ // all is touched: the probe skips the size check and the running check is unknown.+ let r = handle(&[inst("10.0.5")], &json!({"diagnoseOnly": true, "version": "10.0.6"}), &offline);+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["diagnoseOnly"], json!(true));+ assert_eq!(r["targetVersion"], json!("10.0.6"));+ assert_eq!(r["targetSource"], json!("pinned"));+ assert_eq!(r["cachedInstallerBytes"], json!(0));+ assert_eq!(r["cachedInstallerTruncated"], json!(false));+ assert_eq!(r["alreadyCurrent"], json!(false));+ assert_eq!(r["installedVersions"], json!(["10.0.5"]));+ assert!(r.get("isAdmin").is_some() && r.get("canInstallAllUsersSilently").is_some(), "{r}");+ assert!(r["_hint"].as_str().unwrap().contains("uacEnableLUA"));+ let os = native().capabilities().os;+ if os != "windows" {+ assert_eq!(r["downloadUrl"], Value::Null);+ assert_eq!(r["silentInstallSupported"], json!(false));+ assert_eq!(r["serverInstallerBytes"], Value::Null);+ assert_eq!(r["kicadRunning"], Value::Null);+ assert!(r["kicadRunningUnknownBecause"].as_str().unwrap().contains("not implemented"));+ assert_ne!(r["installerKind"], json!("nsis-exe"));+ }+ }++ #[test]+ fn real_path_refuses_where_there_is_no_installer() {+ if native().capabilities().os == "windows" {+ return; // the real path downloads a gigabyte; John runs that by hand+ }+ let r = handle(&[], &json!({"version": "10.0.6"}), &offline);+ assert_eq!(r["success"], json!(false));+ assert_eq!(r["errorCode"], json!("unsupported_platform"));+ assert!(r["error"].as_str().unwrap().contains("OS package path"), "{r}");+ assert_eq!(r["latestVersion"], json!("10.0.6"));+ }+}
rust/crates/kicad-core/src/progress.rs+823−1@@ -1 +1,823 @@-//! Placeholder: operation progress registry, filled in by phase 3b.+//! The operation progress registry, ported from `handlers/progress.py` and the dispatch+//! wrapper in `server.py` (the MEASURED-progress contract, wiki issue #32).+//!+//! John's rule, verbatim from the issue: every slow verb reports measured progress, never+//! "can take a couple of minutes". A shrug is not an estimate: it cannot be drawn as a+//! bar, it never narrows, and it hides the difference between slow and wedged.+//!+//! This module owns four things, all process-wide and thread-safe (the server is+//! multithreaded: a `kicad_progress` poll runs while a long verb is in flight):+//!+//! 1. A rolling, PER-MACHINE history of how long each phase actually took, split by cold+//! vs warm start, persisted at `~/.adom/kicad-bridge-timings.json`. Estimates come from+//! this machine's own p50, never from a hardcoded literal.+//! 2. Honest CONFIDENCE. 0 samples and no seed -> "unknown"; a seeded default or 1-2+//! samples -> "typical"; 3 or more -> "measured".+//! 3. The LIVE frame per in-flight (caller, phase) pair (wiki #46: two callers driving one+//! box never see each other's steps), read by `active()` / `live()`; percent and ETA are+//! recomputed at READ time so a poller sees a bar that keeps moving, and an explicit+//! step percent is a monotonic FLOOR, never a freeze.+//! 4. The per-verb run log (`~/.adom/kicad-verb-times.jsonl`, append-only, one generation+//! of rotation at 4 MB): every dispatch with its outcome, which is what `kicad_status`+//! hands a poller after ab's budget ran out (#87) and what `kicad_verb_times` aggregates.+//!+//! Phase ids are namespaced (`kicad.show_symbol`) so a UI can group histories across+//! bridges, and `blockedBy` names a human-controlled wait (a modal) so a UI can say+//! "waiting for you" instead of animating a bar through something that cannot progress.+//!+//! Integration (verbs.rs dispatch): `let mut h = progress::begin(command, || kicad_running);`+//! before the verb runs, `h.attach(&mut out)` after. `attach` logs the run, retires the+//! live frame and, for an instrumented verb, inserts the terminal `progress` block.++use std::collections::BTreeMap;+use std::io::Write;+use std::path::PathBuf;+use std::sync::Mutex;+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};++use serde_json::{json, Map, Value};++pub const PREFIX: &str = "kicad";+/// Rolling window per key (fusion parity, John 2026-08-21).+const KEEP: usize = 10;+const RUNLOG_MAX_BYTES: u64 = 4 * 1024 * 1024;+/// The identity a frame carries when the request was anonymous (Python `_caller()`).+pub const ANONYMOUS: &str = "unknown";++/// Shipped defaults, used ONLY to seed a machine with no history of its own. Every one+/// of these is a measured figure from this fleet, not a guess.+fn default_seconds(phase: &str) -> Option<f64> {+ Some(match phase {+ "cold_start" => 25.0, // kicad.exe from nothing, 10.0.5+ "warm_start" => 1.0, // KiCad already running+ "show_symbol" => 12.0,+ "show_footprint" => 20.0, // the Footprint Editor loads its libraries as it opens+ "show_3d_chip" => 25.0,+ "show_library" => 12.0,+ "show_schematic" => 15.0,+ "show_2d_board" => 15.0,+ "show_3d_board" => 30.0,+ "show_project" => 10.0,+ "install_plugin" => 6.0,+ "upgrade" => 240.0, // download + silent install+ "export" => 8.0,+ "demo" => 210.0,+ _ => return None,+ })+}++/// server.py `_PHASES`: which verbs are instrumented, and under which phase.+pub fn phase_for_verb(verb: &str) -> Option<&'static str> {+ Some(match bare(verb) {+ "launch" => "cold_start",+ "show_symbol" | "open_symbol_editor" => "show_symbol",+ "show_footprint" | "open_footprint_editor" => "show_footprint",+ "show_3d_chip" | "open_3d_viewer" => "show_3d_chip",+ "show_library" => "show_library",+ "show_schematic" | "open_schematic" => "show_schematic",+ "show_2d_board" | "open_board" => "show_2d_board",+ "show_3d_board" => "show_3d_board",+ "show_project" => "show_project",+ "install_plugin" => "install_plugin",+ "upgrade" => "upgrade",+ "export_gerber" | "export_gerbers" | "export_step" | "export_pdf" | "export_svg" | "export_part" | "make_part_project" => "export",+ "demo" => "demo",+ _ => return None,+ })+}++/// The verb name without the `kicad_` prefix (the run log and the catalog use bare names).+pub fn bare(verb: &str) -> &str {+ verb.strip_prefix("kicad_").unwrap_or(verb)+}++/// cold = nothing running yet; warm = KiCad is already up. The single most useful fact in+/// the whole block: these differ by 10-50x.+pub fn start_kind(kicad_running: bool) -> &'static str {+ if kicad_running { "warm" } else { "cold" }+}++/// Who is asking (Python `_caller()`): the forwarded thread name, else "unknown".+pub fn caller_name() -> String {+ let t = crate::ab::thread_name();+ if t.trim().is_empty() { ANONYMOUS.to_string() } else { t }+}++// ── Registry ──────────────────────────────────────────────────────────────────++#[derive(Clone, Debug)]+struct Live {+ started_at: Instant,+ start_kind: String,+ step_label: String,+ percent: Option<i64>,+ blocked_by: Option<String>,+ seq: u64,+}++struct Registry {+ /// Keyed on (caller, phase) (wiki #46).+ live: BTreeMap<(String, String), Live>,+ seq: u64,+ /// Rolling samples per "phase:kind" key, mirrored to disk on every write.+ timings: Option<BTreeMap<String, Vec<f64>>>,+ /// Test hook: where the two state files live (default `~/.adom`).+ state_dir: Option<PathBuf>,+}++static REG: Mutex<Registry> = Mutex::new(Registry { live: BTreeMap::new(), seq: 0, timings: None, state_dir: None });++fn reg() -> std::sync::MutexGuard<'static, Registry> {+ REG.lock().unwrap_or_else(|e| e.into_inner())+}++fn default_state_dir() -> PathBuf {+ let base = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from).unwrap_or_else(|| std::env::temp_dir());+ base.join(".adom")+}++/// Point the registry at another directory (tests). Drops the in-memory timings cache so+/// the next read comes from that directory.+pub fn set_state_dir(dir: Option<PathBuf>) {+ let mut r = reg();+ r.state_dir = dir;+ r.timings = None;+}++fn state_dir_of(r: &Registry) -> PathBuf {+ r.state_dir.clone().unwrap_or_else(default_state_dir)+}++fn timings_path(r: &Registry) -> PathBuf {+ state_dir_of(r).join("kicad-bridge-timings.json")+}++fn runlog_path(r: &Registry) -> PathBuf {+ state_dir_of(r).join("kicad-verb-times.jsonl")+}++fn load_timings(r: &mut Registry) {+ if r.timings.is_some() {+ return;+ }+ let mut map = BTreeMap::new();+ if let Ok(text) = std::fs::read_to_string(timings_path(r)) {+ if let Ok(Value::Object(o)) = serde_json::from_str::<Value>(&text) {+ for (k, v) in o {+ let samples: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();+ map.insert(k, samples);+ }+ }+ }+ r.timings = Some(map);+}++fn save_timings(r: &Registry) {+ let Some(t) = &r.timings else { return };+ let path = timings_path(r);+ if let Some(p) = path.parent() {+ let _ = std::fs::create_dir_all(p);+ }+ let obj: Map<String, Value> = t.iter().map(|(k, v)| (k.clone(), json!(v))).collect();+ if let Ok(text) = serde_json::to_string_pretty(&Value::Object(obj)) {+ let _ = std::fs::write(path, text);+ }+}++fn key(phase: &str, start_kind: &str) -> String {+ format!("{phase}:{}", if start_kind.is_empty() { "warm" } else { start_kind })+}++fn round(v: f64, digits: i32) -> f64 {+ let f = 10f64.powi(digits);+ (v * f).round() / f+}++/// Python `_pct`: nearest-rank percentile on the sorted samples.+fn pct(samples: &[f64], q: f64) -> f64 {+ if samples.is_empty() {+ return 0.0;+ }+ let mut s = samples.to_vec();+ s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));+ let i = ((q * (s.len() as f64 - 1.0)).round() as i64).clamp(0, s.len() as i64 - 1) as usize;+ round(s[i], 2)+}++fn epoch_secs() -> u64 {+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)+}++// ── History and estimates ─────────────────────────────────────────────────────++/// Add one real measurement. This is what makes confidence "measured".+pub fn record(phase: &str, seconds: f64, start_kind: &str) {+ if !(seconds >= 0.0) {+ return;+ }+ let mut r = reg();+ load_timings(&mut r);+ let k = key(phase, start_kind);+ let t = r.timings.as_mut().unwrap();+ let samples = t.entry(k).or_default();+ samples.push(round(seconds, 2));+ if samples.len() > KEEP {+ let n = samples.len() - KEEP;+ samples.drain(..n);+ }+ save_timings(&r);+}++fn estimate_in(r: &mut Registry, phase: &str, start_kind: &str) -> (f64, &'static str) {+ load_timings(r);+ let samples = r.timings.as_ref().unwrap().get(&key(phase, start_kind)).cloned().unwrap_or_default();+ if samples.len() >= 3 {+ return (pct(&samples, 0.5), "measured");+ }+ let default = default_seconds(phase);+ if !samples.is_empty() {+ // Blend the shipped default with what little we have measured.+ let base = default.unwrap_or(*samples.last().unwrap());+ let mean = samples.iter().sum::<f64>() / samples.len() as f64;+ return (round((mean + base) / 2.0, 2), "typical");+ }+ if let Some(d) = default {+ // A phase-SPECIFIC seeded literal is "typical" per the ladder (measured > typical+ // [1-2 samples, or a seeded literal] > unknown).+ return (d, "typical");+ }+ // No samples AND no seed: genuinely unknown. 0 so block() renders an indeterminate+ // marquee instead of borrowing the generic warm default and looking overdue at 92%.+ (0.0, "unknown")+}++/// `(estimatedSec, confidence)` for this phase ON THIS MACHINE.+pub fn estimate(phase: &str, start_kind: &str) -> (f64, &'static str) {+ let mut r = reg();+ estimate_in(&mut r, phase, start_kind)+}++/// Everything we know, for describe/status: p50 + p90 + sample counts per phase and kind.+pub fn history() -> Value {+ let mut r = reg();+ load_timings(&mut r);+ let mut out = Map::new();+ for (k, samples) in r.timings.as_ref().unwrap() {+ if samples.is_empty() {+ continue;+ }+ let (phase, kind) = k.split_once(':').unwrap_or((k.as_str(), ""));+ let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));+ entry[kind] = json!({"samples": samples.len(), "p50": pct(samples, 0.5), "p90": pct(samples, 0.9)});+ }+ Value::Object(out)+}++// ── The block ─────────────────────────────────────────────────────────────────++/// Build the progress block. `percent` may be None while genuinely unknown; the contract+/// allows it as long as stepLabel keeps moving. Adopted from fusion's timings.py: the+/// pre-overdue and overdue formulas MEET at elapsed == est0 so percent never walks+/// backwards at the crossover, and `est = elapsed * 1.25` is only ever the overdue+/// revision, never the base estimate (that alone pins a bar at 80 forever).+pub fn block(phase: &str, step_label: &str, elapsed_sec: f64, percent: Option<i64>, start_kind: &str, blocked_by: Option<&str>, done: bool) -> Value {+ let elapsed = elapsed_sec.max(0.0);+ let (est0, mut confidence) = estimate(phase, start_kind);+ let mut est = est0;+ let overdue = est0 > 0.0 && elapsed > est0;+ if overdue {+ est = round(elapsed * 1.25, 1); // revised, so eta never lies at 0+ if confidence == "measured" {+ confidence = "typical"; // past what this box ever measured+ }+ if elapsed > 2.0 * est0 {+ confidence = "unknown"; // stop claiming to know at all+ }+ }+ let mut percent = percent;+ if done {+ percent = Some(100);+ est = round(elapsed, 2);+ } else if percent.is_none() {+ percent = if est0 <= 0.0 {+ None // unknown -> indeterminate, never 0%+ } else if !overdue {+ Some((80.0 * elapsed / est0) as i64) // 0 -> 80 across the estimate+ } else {+ let over = elapsed - est0;+ Some((80.0 + 15.0 * (over / (over + est0))) as i64) // 80 -> 95, asymptotic+ };+ }+ let eta = percent.map(|_| round((est - elapsed).max(0.0), 1));+ let mut label = step_label.to_string();+ let mut out = json!({+ "phase": format!("{PREFIX}.{phase}"),+ "stepLabel": label,+ "percent": percent,+ "elapsedSec": round(elapsed, 1),+ "estimatedSec": round(est, 1),+ "etaSec": eta,+ "confidence": confidence,+ "startKind": start_kind,+ });+ if !done && est0 > 0.0 {+ out["overdue"] = json!(overdue);+ if overdue && confidence == "unknown" {+ label = format!("{step_label} (taking far longer than this machine ever has)");+ out["stepLabel"] = json!(label);+ }+ }+ if let Some(b) = blocked_by.filter(|b| !b.is_empty()) {+ out["blockedBy"] = json!(b);+ out["stepLabel"] = json!(format!("waiting for you: {b}"));+ }+ if done {+ out["measured"] = json!({"phase": format!("{PREFIX}.{phase}"), "seconds": round(elapsed, 2), "startKind": start_kind});+ }+ out+}++/// Terminal frame: carries the ACTUALS and feeds the history a new sample (only a+/// successful run is a sample; a failure's duration measures the failure, not the phase).+pub fn finish(phase: &str, elapsed_sec: f64, start_kind: &str, ok: bool, step_label: &str) -> Value {+ let elapsed = elapsed_sec.max(0.0);+ if ok {+ record(phase, elapsed, start_kind);+ }+ let mut b = block(phase, step_label, elapsed, Some(100), start_kind, None, true);+ if !ok {+ b["percent"] = Value::Null;+ b["stepLabel"] = json!(step_label);+ if let Some(o) = b.as_object_mut() {+ o.remove("measured");+ }+ }+ b+}++// ── LIVE, in-flight progress ──────────────────────────────────────────────────++/// Mark a phase as in flight, SCOPED TO THE CALLER (wiki #46).+pub fn begin_phase(phase: &str, start_kind: &str, step_label: &str, caller: &str) {+ let mut r = reg();+ r.seq += 1;+ let seq = r.seq;+ r.live.insert((caller.to_string(), phase.to_string()), Live { started_at: Instant::now(), start_kind: start_kind.to_string(), step_label: step_label.to_string(), percent: None, blocked_by: None, seq });+}++/// Advance the visible sub-step of an in-flight phase for the current caller. A handler+/// thread that carries no caller falls back to the ONLY in-flight entry for the phase,+/// and never guesses when several callers run the same phase (a wrong attribution is+/// the bug #46 is about). Percent never goes backwards.+pub fn step(phase: &str, step_label: &str, percent: Option<i64>, blocked_by: Option<&str>) {+ step_for(&caller_name(), phase, step_label, percent, blocked_by)+}++pub fn step_for(caller: &str, phase: &str, step_label: &str, percent: Option<i64>, blocked_by: Option<&str>) {+ let mut r = reg();+ let k = (caller.to_string(), phase.to_string());+ let target = if r.live.contains_key(&k) {+ Some(k)+ } else {+ let matches: Vec<(String, String)> = r.live.keys().filter(|(_, p)| p == phase).cloned().collect();+ if matches.len() == 1 { Some(matches[0].clone()) } else { None }+ };+ let Some(t) = target else { return };+ if let Some(cur) = r.live.get_mut(&t) {+ cur.step_label = step_label.to_string();+ cur.blocked_by = blocked_by.filter(|b| !b.is_empty()).map(str::to_string);+ if let Some(p) = percent {+ cur.percent = Some(p.max(cur.percent.unwrap_or(0)));+ }+ }+}++/// Retire a live frame (the caller's entry for the phase).+pub fn end_phase(phase: &str, caller: &str) {+ let mut r = reg();+ r.live.remove(&(caller.to_string(), phase.to_string()));+}++/// Every phase currently in flight, as full progress blocks, percent recomputed at READ+/// time with the step's explicit percent applied as a monotonic floor. By default a+/// caller sees ONLY ITS OWN phases (#46); `mine_only = false` is the operator view. Each+/// frame carries `caller` and `seq` regardless.+pub fn live(mine_only: bool, asker: Option<&str>) -> Vec<Value> {+ let asker = asker.map(str::to_string).unwrap_or_else(caller_name);+ let snapshot: Vec<((String, String), Live)> = reg().live.iter().map(|(k, v)| (k.clone(), v.clone())).collect();+ let mut out = Vec::new();+ for ((who, phase), cur) in snapshot {+ if mine_only && who != asker {+ continue;+ }+ let mut b = block(&phase, &cur.step_label, cur.started_at.elapsed().as_secs_f64(), None, &cur.start_kind, cur.blocked_by.as_deref(), false);+ if let Some(floor) = cur.percent {+ b["percent"] = json!(match b["percent"].as_i64() { Some(p) => p.max(floor), None => floor });+ }+ b["seq"] = json!(cur.seq);+ b["caller"] = json!(who);+ out.push(b);+ }+ out.sort_by_key(|b| b["seq"].as_u64().unwrap_or(0));+ out+}++/// Operator view of everything in flight (`kicad_status.operations.active`).+pub fn active() -> Vec<Value> {+ live(false, None)+}++// ── Per-verb run times ────────────────────────────────────────────────────────++/// Append one verb run to the historical log AND the rolling window. Called at the+/// dispatch chokepoint for EVERY verb. Never fails loudly. `note` is the error text of a+/// failed run (#87: "did MY operation finish, and how?").+pub fn log_run(verb: &str, seconds: f64, ok: bool, note: &str, caller: Option<&str>) {+ if !(0.0..=7200.0).contains(&seconds) {+ return; // a clock jump is not a sample+ }+ let verb = bare(verb).to_string();+ record(&format!("verb:{verb}"), seconds, "warm");+ let caller = caller.map(str::to_string).unwrap_or_else(caller_name);+ let row = json!({+ "at": epoch_secs(), "verb": verb, "seconds": round(seconds, 2), "ok": ok,+ "note": note.chars().take(120).collect::<String>(),+ "caller": caller.chars().take(80).collect::<String>(),+ });+ let r = reg();+ let p = runlog_path(&r);+ if let Some(d) = p.parent() {+ let _ = std::fs::create_dir_all(d);+ }+ if let Ok(m) = std::fs::metadata(&p) {+ if m.len() > RUNLOG_MAX_BYTES {+ let _ = std::fs::rename(&p, p.with_extension("jsonl.1")); // one generation back, never unbounded+ }+ }+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {+ let _ = writeln!(f, "{row}");+ }+}++fn read_runlog_tail(max_bytes: u64) -> Vec<Value> {+ let p = runlog_path(®());+ let Ok(bytes) = std::fs::read(&p) else { return Vec::new() };+ let start = bytes.len().saturating_sub(max_bytes as usize);+ let tail = String::from_utf8_lossy(&bytes[start..]);+ let mut rows = Vec::new();+ for (i, line) in tail.lines().enumerate() {+ if i == 0 && start > 0 {+ continue; // a cut line+ }+ if let Ok(v) = serde_json::from_str::<Value>(line) {+ rows.push(v);+ }+ }+ rows+}++/// The last `limit` finished runs, newest first, optionally one caller's: which verb,+/// when it finished, how long, whether it succeeded, and the error text when not.+pub fn recent(limit: usize, caller: Option<&str>) -> Vec<Value> {+ let rows = read_runlog_tail(64 * 1024);+ let mut out: Vec<Value> = rows+ .iter()+ .filter(|r| caller.map(|c| r["caller"].as_str() == Some(c)).unwrap_or(true))+ .map(|r| {+ let ok = r["ok"].as_bool().unwrap_or(false);+ json!({+ "verb": r["verb"], "finishedAt": r["at"], "seconds": r["seconds"], "ok": ok,+ "error": if ok { Value::Null } else { r.get("note").filter(|n| n.as_str().map(|s| !s.is_empty()).unwrap_or(false)).cloned().unwrap_or(Value::Null) },+ "caller": r["caller"],+ })+ })+ .collect();+ out.reverse();+ out.truncate(limit);+ out+}++/// Aggregate the whole run log: per verb count / p50 / p90 / max / last / failures,+/// sorted by p90 descending (p50 15s beside p90 400s is two code paths wearing one name).+pub fn verb_report(limit: usize) -> Value {+ let p = runlog_path(®());+ let Ok(text) = std::fs::read_to_string(&p) else { return json!({}) };+ let mut runs: BTreeMap<String, (Vec<f64>, u64, f64)> = BTreeMap::new();+ for line in text.lines() {+ let Ok(r) = serde_json::from_str::<Value>(line) else { continue };+ let verb = r["verb"].as_str().unwrap_or("?").to_string();+ let secs = r["seconds"].as_f64().unwrap_or(0.0);+ let e = runs.entry(verb).or_insert((Vec::new(), 0, 0.0));+ e.0.push(secs);+ if !r["ok"].as_bool().unwrap_or(false) {+ e.1 += 1;+ }+ e.2 = secs;+ }+ let mut rows: Vec<(String, Value)> = runs+ .into_iter()+ .map(|(verb, (s, fail, last))| {+ let max = s.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+ (verb, json!({"count": s.len(), "p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "max": if s.is_empty() { Value::Null } else { json!(max) }, "last": last, "failures": fail}))+ })+ .collect();+ rows.sort_by(|a, b| b.1["p90"].as_f64().unwrap_or(0.0).partial_cmp(&a.1["p90"].as_f64().unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal));+ rows.truncate(limit);+ Value::Object(rows.into_iter().collect())+}++// ── The kicad_status `operations` block (#87) ─────────────────────────────────++pub const OPERATIONS_HINT: &str = "After a timeout: look for your verb in operations.yours.recent (ok/error, finishedAt) or operations.yours.active (still running, with stepLabel/percent). Not there yet means still running or never started: keep polling; do not resend a mutation blindly. kicad_progress is the same live view with ETAs; plugins[] below is liveness, not progress.";++/// `{active, recent, yours: {active, recent}, busy}` for `kicad_status`: every phase in+/// flight (all callers, each frame named), the last finished runs with their outcome,+/// and the asker's own slice of both.+pub fn operations(asker: Option<&str>, recent_limit: usize) -> Value {+ let me = asker.map(str::to_string).unwrap_or_else(caller_name);+ let active = live(false, Some(&me));+ let recent_all = recent(recent_limit, None);+ let mine_active: Vec<Value> = active.iter().filter(|a| a["caller"].as_str() == Some(me.as_str())).cloned().collect();+ let mine_recent: Vec<Value> = recent_all.iter().filter(|r| r["caller"].as_str() == Some(me.as_str())).cloned().collect();+ json!({+ "active": active,+ "recent": recent_all,+ "yours": {"active": mine_active, "recent": mine_recent},+ "busy": !active.is_empty(),+ })+}++// ── The dispatch handle ───────────────────────────────────────────────────────++/// One verb run as the dispatch chokepoint sees it. Created with `begin` before the verb+/// runs; `attach` (or `finish`) afterwards logs the run with its outcome, retires the+/// live frame and produces the terminal `progress` block for an instrumented verb. A+/// handle dropped without finishing (the verb panicked) logs a failed run and retires+/// the frame, so a poller never sees a bar that never completes for a call long over.+pub struct Handle {+ verb: String,+ phase: Option<String>,+ caller: String,+ start_kind: &'static str,+ t0: Instant,+ finished: bool,+}++/// Start tracking `verb` for the current caller. `kicad_running` is only consulted for an+/// instrumented verb (server.py probes the window list only when `_PHASES` has the verb),+/// so a status poll never pays for a window enumeration.+pub fn begin(verb: &str, kicad_running: impl FnOnce() -> bool) -> Handle {+ begin_as(verb, &caller_name(), kicad_running)+}++pub fn begin_as(verb: &str, caller: &str, kicad_running: impl FnOnce() -> bool) -> Handle {+ let verb = bare(verb).to_string();+ let mut phase = phase_for_verb(&verb).map(str::to_string);+ let mut kind = "warm";+ if let Some(p) = phase.as_mut() {+ kind = start_kind(kicad_running());+ if p == "cold_start" && kind == "warm" {+ *p = "warm_start".into(); // cold vs warm differ by 10-50x+ }+ begin_phase(p, kind, &format!("{verb} starting"), caller);+ }+ Handle { verb, phase, caller: caller.to_string(), start_kind: kind, t0: Instant::now(), finished: false }+}++impl Handle {+ pub fn phase(&self) -> Option<&str> {+ self.phase.as_deref()+ }+ pub fn start_kind(&self) -> &'static str {+ self.start_kind+ }+ pub fn elapsed(&self) -> Duration {+ self.t0.elapsed()+ }+ /// Advance this run's visible sub-step (no-op for an uninstrumented verb).+ pub fn step(&self, label: &str, percent: Option<i64>, blocked_by: Option<&str>) {+ if let Some(p) = &self.phase {+ step_for(&self.caller, p, label, percent, blocked_by);+ }+ }+ /// Log the run, retire the live frame, and return the terminal block (Some only for an+ /// instrumented verb). `note` is the error text of a failed run.+ pub fn finish(&mut self, ok: bool, note: &str) -> Option<Value> {+ if self.finished {+ return None;+ }+ self.finished = true;+ let secs = self.t0.elapsed().as_secs_f64();+ log_run(&self.verb, secs, ok, note, Some(&self.caller));+ let phase = self.phase.take()?;+ end_phase(&phase, &self.caller);+ let label = if ok { "done".to_string() } else { note.chars().take(80).collect() };+ Some(finish(&phase, secs, self.start_kind, ok, &label))+ }+ /// The server.py wrapper in one call: success from `out.success` (absent counts as+ /// success, like Python's `is not False`), note from `out.error` / `out.errorCode`, and+ /// `progress` inserted only when the verb did not set one itself.+ pub fn attach(&mut self, out: &mut Value) {+ let ok = out.get("success").and_then(Value::as_bool) != Some(false);+ let note = if ok { String::new() } else { out.get("error").and_then(Value::as_str).or_else(|| out.get("errorCode").and_then(Value::as_str)).unwrap_or("failed").to_string() };+ if let Some(block) = self.finish(ok, ¬e) {+ if let Some(o) = out.as_object_mut() {+ o.entry("progress").or_insert(block);+ }+ }+ }+}++impl Drop for Handle {+ fn drop(&mut self) {+ if !self.finished {+ let _ = self.finish(false, "handler raised");+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ /// The registry is process-wide; tests that touch the state files take this lock and+ /// point the registry at their own directory.+ static TEST_LOCK: Mutex<()> = Mutex::new(());++ fn scratch() -> (std::sync::MutexGuard<'static, ()>, PathBuf) {+ let g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());+ let dir = crate::install::unique_temp_dir("kicad-progress-test-");+ std::fs::create_dir_all(&dir).unwrap();+ set_state_dir(Some(dir.clone()));+ (g, dir)+ }++ #[test]+ fn phases_and_start_kind_follow_server_py() {+ assert_eq!(phase_for_verb("kicad_show_symbol"), Some("show_symbol"));+ assert_eq!(phase_for_verb("open_board"), Some("show_2d_board"));+ assert_eq!(phase_for_verb("export_gerbers"), Some("export"));+ assert_eq!(phase_for_verb("kicad_status"), None);+ assert_eq!(start_kind(true), "warm");+ assert_eq!(start_kind(false), "cold");+ assert_eq!(bare("kicad_progress"), "progress");+ let _s = scratch();+ let mut probed = false;+ let h = begin_as("kicad_status", "t", || { probed = true; true });+ assert!(h.phase().is_none());+ drop(h);+ assert!(!probed, "an uninstrumented verb must never pay for the window probe");+ let h = begin_as("kicad_launch", "t", || true);+ assert_eq!(h.phase(), Some("warm_start"));+ assert_eq!(h.start_kind(), "warm");+ let h2 = begin_as("kicad_launch", "t2", || false);+ assert_eq!(h2.phase(), Some("cold_start"));+ }++ #[test]+ fn confidence_ladder_unknown_typical_measured() {+ let (_g, _d) = scratch();+ assert_eq!(estimate("never_heard_of", "warm"), (0.0, "unknown"));+ assert_eq!(estimate("show_symbol", "warm"), (12.0, "typical"));+ record("show_symbol", 4.0, "warm");+ let (e, c) = estimate("show_symbol", "warm");+ assert_eq!(c, "typical");+ assert_eq!(e, 8.0); // (4 + 12) / 2+ record("show_symbol", 6.0, "warm");+ record("show_symbol", 5.0, "warm");+ assert_eq!(estimate("show_symbol", "warm"), (5.0, "measured"));+ assert_eq!(estimate("show_symbol", "cold"), (12.0, "typical"), "cold and warm are separate histories");+ let h = history();+ assert_eq!(h["show_symbol"]["warm"]["samples"], json!(3));+ assert_eq!(h["show_symbol"]["warm"]["p50"], json!(5.0));+ // Rolling window keeps the last KEEP samples.+ for i in 0..20 {+ record("show_symbol", 10.0 + i as f64, "warm");+ }+ assert_eq!(history()["show_symbol"]["warm"]["samples"], json!(KEEP));+ // Persisted: a fresh cache reads the same numbers back.+ set_state_dir(Some(_d.clone()));+ assert_eq!(history()["show_symbol"]["warm"]["samples"], json!(KEEP));+ assert_eq!(pct(&[3.0, 1.0, 2.0], 0.5), 2.0);+ assert_eq!(pct(&[], 0.5), 0.0);+ }++ #[test]+ fn block_math_meets_at_the_crossover_and_never_retreats() {+ let (_g, _d) = scratch();+ // Seeded phase: show_2d_board = 15 s typical.+ let before = block("show_2d_board", "loading", 14.99, None, "warm", None, false);+ let after = block("show_2d_board", "loading", 15.01, None, "warm", None, false);+ assert_eq!(before["percent"], json!(79));+ assert_eq!(after["percent"], json!(80));+ assert_eq!(before["overdue"], json!(false));+ assert_eq!(after["overdue"], json!(true));+ assert_eq!(after["estimatedSec"], json!(18.8));+ assert_eq!(after["confidence"], json!("typical"));+ let far = block("show_2d_board", "loading", 40.0, None, "warm", None, false);+ assert_eq!(far["confidence"], json!("unknown"));+ assert!(far["stepLabel"].as_str().unwrap().contains("taking far longer"));+ assert!(far["percent"].as_i64().unwrap() > 80 && far["percent"].as_i64().unwrap() < 95);+ // Unknown phase: indeterminate, never 0%.+ let u = block("mystery", "x", 5.0, None, "warm", None, false);+ assert_eq!(u["percent"], Value::Null);+ assert_eq!(u["etaSec"], Value::Null);+ assert_eq!(u["confidence"], json!("unknown"));+ assert!(u.get("overdue").is_none());+ // blockedBy rewrites the label.+ let b = block("show_2d_board", "loading", 1.0, Some(10), "warm", Some("Save changes?"), false);+ assert_eq!(b["blockedBy"], json!("Save changes?"));+ assert_eq!(b["stepLabel"], json!("waiting for you: Save changes?"));+ assert_eq!(b["percent"], json!(10));+ assert_eq!(b["phase"], json!("kicad.show_2d_board"));+ // Terminal frames.+ let f = finish("show_2d_board", 7.3, "warm", true, "done");+ assert_eq!(f["percent"], json!(100));+ assert_eq!(f["measured"]["seconds"], json!(7.3));+ assert_eq!(f["estimatedSec"], json!(7.3));+ let f = finish("show_2d_board", 7.3, "warm", false, "editor exited");+ assert_eq!(f["percent"], Value::Null);+ assert_eq!(f["stepLabel"], json!("editor exited"));+ assert!(f.get("measured").is_none());+ assert_eq!(history()["show_2d_board"]["warm"]["samples"], json!(1), "only the successful run is a sample");+ }++ #[test]+ fn live_frames_are_caller_scoped_and_percent_is_a_floor() {+ let (_g, _d) = scratch();+ let h = begin_as("kicad_show_symbol", "alice", || true);+ let h2 = begin_as("kicad_show_symbol", "bob", || true);+ h.step("navigating to R", Some(40), None);+ h.step("still navigating", Some(20), None); // must not retreat+ // A handler thread with no caller and TWO callers on the phase: never guess.+ step_for("unknown", "show_symbol", "wrong attribution", Some(99), None);+ let mine = live(true, Some("alice"));+ assert_eq!(mine.len(), 1);+ assert_eq!(mine[0]["caller"], json!("alice"));+ assert_eq!(mine[0]["stepLabel"], json!("still navigating"));+ assert_eq!(mine[0]["percent"], json!(40));+ assert_eq!(mine[0]["startKind"], json!("warm"));+ let bobs = live(true, Some("bob"));+ assert_eq!(bobs[0]["stepLabel"], json!("show_symbol starting"));+ assert!(active().iter().filter(|f| f["phase"] == json!("kicad.show_symbol")).count() >= 2);+ drop(h2);+ // Now bob is gone: the anonymous fallback finds the single entry.+ step_for("unknown", "show_symbol", "one left", None, Some("Error"));+ let mine = live(true, Some("alice"));+ assert_eq!(mine[0]["blockedBy"], json!("Error"));+ assert_eq!(mine[0]["stepLabel"], json!("waiting for you: Error"));+ let ops = operations(Some("alice"), 10);+ assert_eq!(ops["busy"], json!(true));+ assert_eq!(ops["yours"]["active"].as_array().unwrap().len(), 1);+ drop(h);+ assert!(live(true, Some("alice")).is_empty());+ }++ #[test]+ fn handle_logs_runs_and_attaches_the_terminal_frame() {+ let (_g, _d) = scratch();+ let mut h = begin_as("kicad_show_footprint", "carol", || false);+ let mut out = json!({"success": true, "hwnd": 5});+ h.attach(&mut out);+ assert_eq!(out["progress"]["phase"], json!("kicad.show_footprint"));+ assert_eq!(out["progress"]["percent"], json!(100));+ assert_eq!(out["progress"]["startKind"], json!("cold"));+ assert!(out["progress"]["measured"].is_object());+ assert!(live(false, None).iter().all(|f| f["caller"] != json!("carol")));+ // A verb that set its own progress keeps it (setdefault).+ let mut h = begin_as("kicad_show_footprint", "carol", || true);+ let mut out = json!({"success": false, "error": "navigation failed", "progress": {"mine": true}});+ h.attach(&mut out);+ assert_eq!(out["progress"], json!({"mine": true}));+ // Uninstrumented verbs log a run but get no block.+ let mut h = begin_as("kicad_status", "carol", || true);+ let mut out = json!({"success": true});+ h.attach(&mut out);+ assert!(out.get("progress").is_none());+ // A dropped (panicked) handle logs a failure.+ drop(begin_as("kicad_show_symbol", "carol", || true));+ let rec = recent(10, Some("carol"));+ assert_eq!(rec.len(), 4);+ assert_eq!(rec[0]["verb"], json!("show_symbol"));+ assert_eq!(rec[0]["ok"], json!(false));+ assert_eq!(rec[0]["error"], json!("handler raised"));+ assert_eq!(rec[1]["verb"], json!("status"));+ assert_eq!(rec[1]["error"], Value::Null);+ assert_eq!(rec[2]["error"], json!("navigation failed"));+ assert_eq!(rec[3]["ok"], json!(true));+ assert!(recent(10, Some("nobody")).is_empty());+ let rep = verb_report(40);+ assert_eq!(rep["show_footprint"]["count"], json!(2));+ assert_eq!(rep["show_footprint"]["failures"], json!(1));+ assert!(rep["show_footprint"]["last"].is_number());+ assert_eq!(rep["status"]["failures"], json!(0));+ let ops = operations(Some("carol"), 2);+ assert_eq!(ops["recent"].as_array().unwrap().len(), 2);+ assert_eq!(ops["yours"]["recent"].as_array().unwrap().len(), 2);+ assert_eq!(ops["busy"], json!(false));+ // Out-of-range durations are not samples.+ log_run("x", -1.0, true, "", Some("carol"));+ log_run("x", 9000.0, true, "", Some("carol"));+ assert!(verb_report(40).get("x").is_none());+ }+}
rust/crates/kicad-platform/src/lib.rs+19@@ -193,6 +193,25 @@ pub trait Platform: Sync + Send { fn seconds_since_input(&self) -> Result<f64, String> { nope("seconds_since_input") } /// Set process DPI awareness once at startup (no-op where irrelevant). fn init_process(&self) {}++ // ---- Phase 3b: silent KiCad install (kicad_upgrade). The core orchestrates+ // (target version, scope, download, verify, seed); the OS file supplies the four+ // facts below. Defaults are honest: an OS with no installer path says so.++ /// Elevation and UAC facts for THIS process. Always carries the four summary keys+ /// `isAdmin`, `uacEnabled`, `consentPrompt`, `canInstallAllUsersSilently` (null where+ /// the OS has no such notion) plus the OS's raw evidence (Windows: the token+ /// elevation type and flag, EnableLUA, ConsentPromptBehaviorAdmin).+ fn elevation_info(&self) -> Result<Value, String> { nope("elevation_info") }+ /// Run a downloaded KiCad installer silently with the given scope and a deadline;+ /// the exit code comes back. On timeout the installer is killed and this is Err.+ fn run_installer(&self, _path: &Path, _all_users: bool, _timeout_secs: u64) -> Result<i32, String> { nope("run_installer") }+ /// What a KiCad installer is on this OS: "nsis-exe" (Windows), "dmg" (macOS),+ /// "distro-package" (Linux). Only nsis-exe has a silent path the bridge drives.+ fn installer_kind(&self) -> &'static str { "unknown" }+ /// The official downloads.kicad.org installer for `version` on this OS, or None+ /// when installs go through the OS package path instead (macOS, Linux).+ fn installer_download_url(&self, _version: &str) -> Option<String> { None } } /// Standard "this build cannot do that here" reply.
rust/crates/kicad-platform/src/linux.rs+29@@ -6,6 +6,8 @@ use std::path::PathBuf; use std::process::Command; +use serde_json::{json, Value};+ use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform}; pub struct Native;@@ -77,4 +79,31 @@ impl Platform for Native { fn process_alive(&self, pid: u32) -> Option<bool> { Some(std::path::Path::new(&format!("/proc/{pid}")).exists()) }++ // ---- Phase 3b: kicad_upgrade. No silent installer path here: KiCad on Linux comes+ // from the distro package, the PPA, snap or flatpak. The verb explains that; these four keep the facts honest.++ fn elevation_info(&self) -> Result<Value, String> {+ let mut cmd = Command::new("id");+ cmd.arg("-u");+ self.quiet_command(&mut cmd);+ let euid: Option<u32> = cmd.output().ok().filter(|o| o.status.success()).and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok());+ let root = euid.map(|u| u == 0);+ Ok(json!({+ "platform": "linux",+ "user": std::env::var("USER").or_else(|_| std::env::var("LOGNAME")).unwrap_or_default(),+ "euid": euid,+ "isAdmin": root,+ "uacEnabled": Value::Null,+ "consentPrompt": Value::Null,+ "canInstallAllUsersSilently": root,+ "note": "no UAC on Linux; isAdmin is euid == 0. Package installs need sudo, which the bridge never runs",+ }))+ }+ fn installer_kind(&self) -> &'static str {+ "distro-package"+ }+ fn installer_download_url(&self, _version: &str) -> Option<String> {+ None+ } }
rust/crates/kicad-platform/src/macos.rs+29@@ -6,6 +6,8 @@ use std::path::PathBuf; use std::process::Command; +use serde_json::{json, Value};+ use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform}; pub struct Native;@@ -68,4 +70,31 @@ impl Platform for Native { self.quiet_command(&mut cmd); Some(cmd.output().ok()?.status.success()) }++ // ---- Phase 3b: kicad_upgrade. No silent installer path here: KiCad on macOS comes+ // from the .dmg on downloads.kicad.org or Homebrew. The verb explains that; these four keep the facts honest.++ fn elevation_info(&self) -> Result<Value, String> {+ let mut cmd = Command::new("id");+ cmd.arg("-u");+ self.quiet_command(&mut cmd);+ let euid: Option<u32> = cmd.output().ok().filter(|o| o.status.success()).and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok());+ let root = euid.map(|u| u == 0);+ Ok(json!({+ "platform": "darwin",+ "user": std::env::var("USER").or_else(|_| std::env::var("LOGNAME")).unwrap_or_default(),+ "euid": euid,+ "isAdmin": root,+ "uacEnabled": Value::Null,+ "consentPrompt": Value::Null,+ "canInstallAllUsersSilently": root,+ "note": "no UAC on macOS; isAdmin is euid == 0 (a root shell), which a normal Hydrogen session never is",+ }))+ }+ fn installer_kind(&self) -> &'static str {+ "dmg"+ }+ fn installer_download_url(&self, _version: &str) -> Option<String> {+ None+ } }
rust/crates/kicad-platform/src/win/install.rsadded+176@@ -0,0 +1,176 @@+//! Silent KiCad install primitives (phase 3b), ported from handlers/upgrade.py:+//! `_elevation_facts` (token elevation query plus the two UAC registry values) and the+//! `proc.run([installer, scope, "/S"], timeout=900)` call with a kill on the deadline.+//!+//! The NsisMultiUser installer KiCad 10 ships REQUIRES a scope flag next to `/S`: bare+//! `/S` is "invalid command-line parameters" and exits instantly with rc=666660 (its+//! sibling 666661 is "elevation restricted"). `/allusers` needs an elevated token;+//! `/currentuser` installs to %LOCALAPPDATA%\Programs\KiCad with no admin and no UAC.++use std::path::Path;+use std::process::{Command, Stdio};+use std::time::{Duration, Instant};++use serde_json::{json, Value};+use windows::Win32::Foundation::{CloseHandle, HANDLE};+use windows::Win32::Security::{GetTokenInformation, TokenElevation, TokenElevationType, TOKEN_ELEVATION, TOKEN_QUERY};+use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};+use windows::Win32::UI::Shell::IsUserAnAdmin;++/// The official downloads.kicad.org x86_64 NSIS installer (handlers/upgrade.py `_DL_URL`).+pub const DL_URL: &str = "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-{v}-x86_64.exe";++pub fn download_url(version: &str) -> String {+ DL_URL.replace("{v}", version)+}++/// TokenElevationType (18) and TokenElevation (20) of this process's token, as+/// (elevation_type, is_elevated). Err carries the failing call.+fn token_facts() -> Result<(u32, u32), String> {+ let mut token = HANDLE::default();+ // SAFETY: GetCurrentProcess is a pseudo handle; `token` outlives the call and is+ // closed below.+ unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.map_err(|e| format!("OpenProcessToken failed: {e}"))?;+ let query = |class| -> Result<u32, String> {+ let mut val: u32 = 0;+ let mut len: u32 = 0;+ // SAFETY: both TOKEN_ELEVATION_TYPE and TOKEN_ELEVATION are a single DWORD;+ // `val` and `len` outlive the call.+ unsafe { GetTokenInformation(token, class, Some(&mut val as *mut u32 as *mut _), std::mem::size_of::<u32>() as u32, &mut len) }+ .map(|()| val)+ .map_err(|e| format!("GetTokenInformation failed: {e}"))+ };+ let kind = query(TokenElevationType);+ let elevated = query(TokenElevation);+ // SAFETY: the handle came from OpenProcessToken above and is not used afterwards.+ let _ = unsafe { CloseHandle(token) };+ debug_assert_eq!(std::mem::size_of::<TOKEN_ELEVATION>(), std::mem::size_of::<u32>());+ Ok((kind?, elevated?))+}++fn uac_registry() -> (Value, Value) {+ use winreg::enums::HKEY_LOCAL_MACHINE;+ use winreg::RegKey;+ let root = RegKey::predef(HKEY_LOCAL_MACHINE);+ match root.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System") {+ Ok(k) => {+ let lua: Value = match k.get_value::<u32, _>("EnableLUA") {+ Ok(v) => json!(v),+ Err(e) => json!(format!("error: {e}")),+ };+ // Absent means the Windows default (5: prompt for consent on the secure desktop).+ let consent: Value = match k.get_value::<u32, _>("ConsentPromptBehaviorAdmin") {+ Ok(v) => json!(v),+ Err(_) => json!("not set (default 5: prompt)"),+ };+ (lua, consent)+ }+ Err(e) => (json!(format!("error: {e}")), Value::Null),+ }+}++/// handlers/upgrade.py `_elevation_facts`, plus the four summary keys the trait promises.+pub fn elevation_info() -> Result<Value, String> {+ let mut facts = json!({+ "platform": "win32",+ "user": std::env::var("USERNAME").unwrap_or_default(),+ });+ // SAFETY: shell32 query with no arguments.+ let shell_admin = unsafe { IsUserAnAdmin() }.as_bool();+ facts["isUserAnAdmin"] = json!(shell_admin);+ let mut token_elevated: Option<bool> = None;+ match token_facts() {+ Ok((kind, elevated)) => {+ facts["tokenElevationType"] = json!(kind);+ facts["tokenElevated"] = json!(elevated);+ facts["tokenElevationTypeMeaning"] = json!(match kind {+ 1 => "Default: no split token (UAC disabled machine-wide, or built-in Administrator): full admin, NO UAC prompt ever",+ 2 => "Full: elevated via an approved UAC prompt (or elevated parent)",+ 3 => "Limited: standard filtered token, NOT admin",+ _ => "unknown",+ });+ token_elevated = Some(elevated != 0);+ }+ Err(e) => facts["tokenError"] = json!(e),+ }+ let (lua, consent) = uac_registry();+ facts["uacEnableLUA"] = lua.clone();+ facts["uacConsentPromptBehaviorAdmin"] = consent.clone();++ // Summary. isAdmin is the token's own word when we could read it, else shell32's.+ let is_admin = token_elevated.unwrap_or(shell_admin);+ let uac_enabled: Value = match lua.as_u64() {+ Some(v) => json!(v != 0),+ None => Value::Null,+ };+ facts["isAdmin"] = json!(is_admin);+ facts["uacEnabled"] = uac_enabled;+ facts["consentPrompt"] = match consent.as_u64() {+ Some(v) => json!(v),+ None => Value::Null,+ };+ // /allusers /S runs to completion without a prompt only under an elevated token;+ // a limited token gets NsisMultiUser's 666661 (elevation restricted) in silent mode.+ facts["canInstallAllUsersSilently"] = json!(is_admin);+ Ok(facts)+}++/// `<installer> /allusers|/currentuser /S`, hidden, no console, blocked until it exits or+/// `timeout_secs` passes (then killed). Returns the exit code.+pub fn run_installer(path: &Path, all_users: bool, timeout_secs: u64) -> Result<i32, String> {+ if !path.is_file() {+ return Err(format!("{} does not exist", path.display()));+ }+ let scope = if all_users { "/allusers" } else { "/currentuser" };+ let mut cmd = Command::new(path);+ cmd.arg(scope).arg("/S");+ cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());+ {+ use std::os::windows::process::CommandExt;+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;+ cmd.creation_flags(CREATE_NO_WINDOW);+ }+ let mut child = cmd.spawn().map_err(|e| format!("installer launch failed: {e}"))?;+ let deadline = Instant::now() + Duration::from_secs(timeout_secs);+ loop {+ match child.try_wait() {+ Ok(Some(status)) => return Ok(status.code().unwrap_or(-1)),+ Ok(None) => {}+ Err(e) => return Err(format!("waiting on the installer failed: {e}")),+ }+ if Instant::now() >= deadline {+ let _ = child.kill();+ let _ = child.wait();+ return Err(format!("installer did not finish within {timeout_secs} s; killed. A silent NSIS run that stalls this long is usually waiting on a UAC prompt nobody can see, or SmartScreen/AV holding the exe"));+ }+ std::thread::sleep(Duration::from_millis(250));+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn url_matches_python() {+ assert_eq!(download_url("10.0.6"), "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-10.0.6-x86_64.exe");+ }++ #[test]+ fn elevation_facts_are_concrete() {+ let f = elevation_info().unwrap();+ assert!(f["isAdmin"].is_boolean());+ assert!(f["canInstallAllUsersSilently"].is_boolean());+ assert!(f["isUserAnAdmin"].is_boolean());+ assert!(f.get("uacEnableLUA").is_some());+ assert!(f.get("uacConsentPromptBehaviorAdmin").is_some());+ // The token is ours to read; a failure here is a real regression.+ assert!(f["tokenElevationType"].is_u64(), "{f}");+ assert!(f["tokenElevated"].is_u64(), "{f}");+ }++ #[test]+ fn missing_installer_is_refused() {+ assert!(run_installer(Path::new(r"C:\nope\kicad-0.0.0-x86_64.exe"), false, 5).is_err());+ }+}
rust/crates/kicad-platform/src/windows.rs+15−1@@ -99,7 +99,7 @@ impl Platform for Native { dialog_sweep: true, focus_etiquette: false, // phase 4 ipc_api: false, // phase 2- silent_install: false, // phase 1b+ silent_install: true, // phase 3b: kicad_upgrade (win/install.rs) } } fn cli_file_name(&self) -> &'static str {@@ -235,6 +235,20 @@ impl Platform for Native { fn seconds_since_input(&self) -> Result<f64, String> { win::messages::seconds_since_input() }+ // ---- Phase 3b: silent install. See win/install.rs.++ fn elevation_info(&self) -> Result<serde_json::Value, String> {+ win::install::elevation_info()+ }+ fn run_installer(&self, path: &Path, all_users: bool, timeout_secs: u64) -> Result<i32, String> {+ win::install::run_installer(path, all_users, timeout_secs)+ }+ fn installer_kind(&self) -> &'static str {+ "nsis-exe"+ }+ fn installer_download_url(&self, version: &str) -> Option<String> {+ Some(win::install::download_url(version))+ } fn init_process(&self) { // Per-monitor DPI awareness so every rect is in physical pixels (kicad_ui.py did // this at import). Fails harmlessly if the manifest or an earlier call already set it.