← Commit history
rust/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(&not_pe, MIN_INSTALLER_BYTES + 5, b"PK");+        assert!(!valid_installer(&not_pe, "nsis-exe"));+        assert!(valid_installer(&not_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(&reg());+    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(&reg());+    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, &note) {+            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.