app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
1.0.58: Linux build in the same zip (wiki #127/#128: Flatpak KiCad, kicad-cli verbs, IPC live verbs; window verbs not y…
064152f
25d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006
//! 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)),
}
}
/// What a library, PCM or settings verb should report about a running KiCad:
/// `(kicadRunning, restartNeeded, whyUnknown, pids)`.
///
/// KiCad reads sym-/fp-lib-table and kicad_common.json at startup only, so what the caller
/// actually needs to know is whether the user has to restart. A KiCad that is NOT running
/// needs no restart at all, one that IS running does, and one the OS layer cannot see is
/// reported as unknown with `restartNeeded` true, which is the safe answer (wiki #63).
pub fn running_report() -> (Value, bool, Option<String>, Vec<u32>) {
running_report_from(kicad_running())
}
fn running_report_from(r: (Option<bool>, Vec<u32>, Option<String>)) -> (Value, bool, Option<String>, Vec<u32>) {
match r.0 {
Some(true) => (json!(true), true, None, r.1),
Some(false) => (json!(false), false, None, Vec::new()),
None => (Value::Null, true, r.2, Vec::new()),
}
}
/// The sentence a verb puts in its hint about restarting, given `running_report`'s first value.
/// `what` names what the user is waiting to see, e.g. "the library".
pub fn restart_sentence(running: &Value, pids: &[u32], what: &str) -> String {
match running.as_bool() {
Some(true) => format!("KiCad is running (pid(s) {pids:?}) and reads its tables at startup only, so the user must restart it before {what} appears. Never close their KiCad for them."),
Some(false) => format!("No KiCad is running, so there is nothing to restart: {what} is there the next time KiCad starts."),
None => format!("This build cannot tell whether KiCad is running (kicadRunning is null), and KiCad reads its tables at startup only, so if it was open during this call the user must restart it before {what} appears. Never close their KiCad for them."),
}
}
// ---------------------------------------------------------------------------
// The verb
// ---------------------------------------------------------------------------
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;
if os == "linux" {
return flatpak_install(installs, args, &p, started);
}
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,
})
}
/// Run a command with a deadline; (exit code, last lines of stdout+stderr).
fn run_capped(cmd: &mut std::process::Command, timeout: Duration) -> Result<(i32, String), String> {
let dir = std::env::temp_dir().join(format!("kicad-flatpak-{}-{}", std::process::id(), crate::schematic::new_uuid()));
let f = std::fs::File::create(&dir).map_err(|e| e.to_string())?;
let f2 = f.try_clone().map_err(|e| e.to_string())?;
let mut child = cmd.stdin(std::process::Stdio::null()).stdout(f).stderr(f2).spawn().map_err(|e| e.to_string())?;
let deadline = Instant::now() + timeout;
let code = loop {
if let Some(st) = child.try_wait().map_err(|e| e.to_string())? {
break st.code().unwrap_or(-1);
}
if Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&dir);
return Err(format!("timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(Duration::from_millis(250));
};
let text = std::fs::read_to_string(&dir).unwrap_or_default();
let _ = std::fs::remove_file(&dir);
let tail: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
Ok((code, tail[tail.len().saturating_sub(12)..].join("\n")))
}
/// Wiki #127: on Linux the bridge installs KiCad from Flathub into the user's own Flatpak
/// installation (`--user`: no sudo, and it lives under home, so it survives a box whose
/// system packages are rebuilt). An existing user install is updated in place.
fn flatpak_install(installs: &[KicadInstall], args: &Value, p: &Probe, started: Instant) -> Value {
const APP: &str = "org.kicad.KiCad";
let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
let has_flatpak = std::process::Command::new("flatpak").arg("--version").output().map(|o| o.status.success()).unwrap_or(false);
if !has_flatpak {
let mut v = fail("flatpak_missing", "flatpak is not installed on this computer, and installing it needs root, which the bridge never uses.".into());
v["_hint"] = json!("Ask the user to install flatpak once (Debian/Ubuntu: sudo apt install flatpak), or to install KiCad from the distro (sudo apt install kicad). Then rerun kicad_upgrade or kicad_readiness.");
return v;
}
if !p.target.is_empty() {
if let Some(v) = already_current(installs, &p.target, force) {
return v;
}
}
let (running, pids, _) = p.running.clone();
if running == Some(true) && !force {
let mut v = fail("kicad_running", format!("KiCad is running (pids {:?}); updating the Flatpak under it can break the open session.", pids));
v["kicadPids"] = json!(pids);
v["_hint"] = json!("Ask the user to save their work, run kicad_close, then rerun kicad_upgrade. A fresh --user install next to a running system KiCad is safe: pass force:true.");
return v;
}
let home = std::env::var("HOME").unwrap_or_default();
let user_has = Path::new(&home).join(".local/share/flatpak/app").join(APP).join("current").exists();
let previous = installs.first().map(|i| full_version(i).to_string());
let mut steps = Vec::new();
let remote = run_capped(
std::process::Command::new("flatpak").args(["remote-add", "--user", "--if-not-exists", "flathub", "https://dl.flathub.org/repo/flathub.flatpakrepo"]),
Duration::from_secs(120),
);
steps.push(json!({"step": "remote-add flathub (--user)", "result": match &remote { Ok((c, t)) => json!({"exit": c, "tail": t}), Err(e) => json!({"error": e}) }}));
let action = if user_has { "update" } else { "install" };
let mut cmd = std::process::Command::new("flatpak");
if user_has {
cmd.args(["update", "--user", "-y", "--noninteractive", APP]);
} else {
cmd.args(["install", "--user", "-y", "--noninteractive", "flathub", APP]);
}
log(&format!("flatpak {action} --user {APP}"));
let t0 = Instant::now();
let ran = run_capped(&mut cmd, Duration::from_secs(INSTALL_TIMEOUT_SECS));
let install_secs = round1(t0.elapsed().as_secs_f64());
steps.push(json!({"step": format!("flatpak {action} --user {APP}"), "seconds": install_secs, "result": match &ran { Ok((c, t)) => json!({"exit": c, "tail": t}), Err(e) => json!({"error": e}) }}));
let fresh = detect::detect();
let user_now = Path::new(&home).join(".local/share/flatpak/app").join(APP).join("current").exists();
let primary = fresh.installs.first().cloned();
let ok = user_now && primary.is_some() && matches!(ran, Ok((0, _)));
let seeded = primary.as_ref().filter(|_| ok).map(seed_new_install);
json!({
"success": ok,
"upgraded": ok,
"method": "flatpak --user",
"action": action,
"app": APP,
"previousVersion": previous,
"installedVersion": primary.as_ref().map(|i| full_version(i).to_string()),
"kicadCli": primary.as_ref().map(|i| i.kicad_cli.clone()),
"source": primary.as_ref().map(|i| i.source.clone()),
"latestVersion": p.target,
"steps": steps,
"firstRunSeeded": seeded,
"elapsedSec": round1(started.elapsed().as_secs_f64()),
"_hint": if ok { "KiCad is installed in the user's Flatpak (under home). The bridge runs it through its own launchers (kicadCli), which share the host's files and /tmp with the sandbox; kicad_readiness confirms." } else { "The Flatpak step failed: steps[].tail carries flatpak's own message (network, disk space, or a Flathub outage). Rerun kicad_upgrade; flatpak resumes a partial download." },
})
}
/// 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,
"merged": s.merged,
"skipped": s.skipped,
"errors": s.errors,
"libTables": lib_tables,
})
}
#[cfg(test)]
mod tests {
use super::*;
struct Tmp(PathBuf);
impl Drop for Tmp {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn tmp(name: &str) -> Tmp {
let p = std::env::temp_dir().join(format!("kicad-core-upgrade-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
Tmp(p)
}
/// A sparse file of `len` bytes starting with `head` (no gigabyte on disk).
fn sparse(p: &Path, len: u64, head: &[u8]) {
use std::io::Write;
let mut f = std::fs::File::create(p).unwrap();
f.set_len(len).unwrap();
f.write_all(head).unwrap();
}
fn offline(_: &str, _: Duration) -> Result<Vec<u8>, String> {
Err("offline".into())
}
fn inst(cli_version: &str) -> KicadInstall {
KicadInstall { version: updates::version_tuple(cli_version).iter().take(2).map(|n| n.to_string()).collect::<Vec<_>>().join("."), cli_version: cli_version.into(), ..Default::default() }
}
#[test]
fn scope_rules_match_python() {
assert_eq!(parse_scope("allusers"), Some(Scope::AllUsers));
assert_eq!(parse_scope("AllUsers"), Some(Scope::AllUsers));
assert_eq!(parse_scope("all-users"), Some(Scope::AllUsers));
assert_eq!(parse_scope("machine"), Some(Scope::AllUsers));
assert_eq!(parse_scope("currentuser"), Some(Scope::CurrentUser));
assert_eq!(parse_scope("current_user"), Some(Scope::CurrentUser));
assert_eq!(parse_scope("user"), Some(Scope::CurrentUser));
assert_eq!(parse_scope("global"), None);
// Explicit wins; default follows the token.
assert_eq!(choose_scope(Some("currentuser"), true), Ok(Scope::CurrentUser));
assert_eq!(choose_scope(Some("allusers"), false), Ok(Scope::AllUsers));
assert_eq!(choose_scope(None, true), Ok(Scope::AllUsers));
assert_eq!(choose_scope(None, false), Ok(Scope::CurrentUser));
assert!(choose_scope(Some("bogus"), false).unwrap_err().contains("bogus"));
assert_eq!(Scope::AllUsers.flag(), "/allusers");
assert_eq!(Scope::CurrentUser.flag(), "/currentuser");
assert_eq!(Scope::AllUsers.legacy(), "machine");
assert_eq!(Scope::CurrentUser.legacy(), "user");
}
#[test]
fn target_pinned_or_latest() {
assert_eq!(resolve_target(&json!({"version": "10.0.5"}), &offline), ("10.0.5".to_string(), "pinned"));
assert_eq!(resolve_target(&json!({"version": " "}), &offline), (updates::FALLBACK_LATEST.to_string(), "fallback"));
assert_eq!(resolve_target(&json!({}), &offline), (updates::FALLBACK_LATEST.to_string(), "fallback"));
let online = |_: &str, _: Duration| Ok::<Vec<u8>, String>(br#"[{"name":"10.0.7"},{"name":"10.99.0"}]"#.to_vec());
assert_eq!(resolve_target(&json!({}), &online), ("10.0.7".to_string(), "gitlab"));
}
#[test]
fn already_current_compares_full_versions() {
let installs = vec![inst("10.0.6"), inst("9.0.9")];
let v = already_current(&installs, "10.0.6", false).unwrap();
assert_eq!(v["alreadyCurrent"], json!(true));
assert_eq!(v["currentVersion"], json!("10.0.6"));
assert!(v["_hint"].as_str().unwrap().contains("force"));
assert!(already_current(&installs, "10.0.7", false).is_none());
assert!(already_current(&installs, "10.0.6", true).is_none(), "force reinstalls");
assert!(already_current(&[], "10.0.6", false).is_none(), "nothing installed: install");
// The "10.0" detection string alone never blocks a point release (AdomLapper bug).
let major_only = vec![KicadInstall { version: "10.0".into(), ..Default::default() }];
assert!(already_current(&major_only, "10.0.5", false).is_none());
}
#[test]
fn installer_names_and_cache_dir() {
assert_eq!(installer_file_name("https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-10.0.6-x86_64.exe", "10.0.6"), "kicad-10.0.6-x86_64.exe");
assert_eq!(installer_file_name("https://x/y/", "1.2.3"), "kicad-1.2.3-x86_64.exe");
assert_eq!(installer_file_name("", "1.2.3"), "kicad-1.2.3-x86_64.exe");
let d = cache_dir().expect("HOME or LOCALAPPDATA is set in tests");
assert!(d.ends_with(Path::new("Adom Bridge").join(CACHE_DIR_NAME)), "{}", d.display());
assert_eq!(part_path(Path::new("/a/b/kicad-10.0.6-x86_64.exe")), PathBuf::from("/a/b/kicad-10.0.6-x86_64.exe.part"));
}
#[test]
fn installer_validity_and_cache_lookup() {
let t = tmp("cache");
let small = t.0.join("small.exe");
std::fs::write(&small, b"MZ tiny").unwrap();
assert!(!valid_installer(&small, "nsis-exe"), "under 50 MB");
let not_pe = t.0.join("notpe.exe");
sparse(¬_pe, MIN_INSTALLER_BYTES + 5, b"PK");
assert!(!valid_installer(¬_pe, "nsis-exe"));
assert!(valid_installer(¬_pe, "dmg"), "the MZ check is NSIS-only");
let good = t.0.join("kicad-10.0.6-x86_64.exe");
sparse(&good, MIN_INSTALLER_BYTES + 5, b"MZ");
assert!(valid_installer(&good, "nsis-exe"));
assert!(!valid_installer(&t.0.join("missing.exe"), "nsis-exe"));
// Complete when the server agrees, truncated when it does not, unknown when it is silent.
let c = find_cached(&[t.0.join("missing.exe"), good.clone()], Some(MIN_INSTALLER_BYTES + 5), "nsis-exe").unwrap();
assert_eq!(c.path, good);
assert_eq!(c.truncated, Some(false));
assert!(c.complete());
let c = find_cached(&[good.clone()], Some(MIN_INSTALLER_BYTES + 999), "nsis-exe").unwrap();
assert_eq!(c.truncated, Some(true));
assert!(!c.complete());
let c = find_cached(&[good.clone()], None, "nsis-exe").unwrap();
assert_eq!(c.truncated, None);
assert!(c.complete(), "no server size: the MZ + 50 MB checks are all we have, like the Python");
assert!(find_cached(&[small], Some(7), "nsis-exe").is_none());
// A complete copy elsewhere beats a truncated one first in the list.
let other = t.0.join("other").join("kicad-10.0.6-x86_64.exe");
std::fs::create_dir_all(other.parent().unwrap()).unwrap();
sparse(&other, MIN_INSTALLER_BYTES + 999, b"MZ");
let c = find_cached(&[good.clone(), other.clone()], Some(MIN_INSTALLER_BYTES + 999), "nsis-exe").unwrap();
assert_eq!(c.path, other);
}
#[test]
fn progress_block_shapes() {
let m = Measured { download: Some(12.345), install: Some(60.0), verify: Some(0.5) };
let b = progress_block(73.21, true, "KiCad 10.0.6 installed", &m);
assert_eq!(b["phase"], json!("kicad.upgrade"));
assert_eq!(b["percent"], json!(100));
assert_eq!(b["elapsedSec"], json!(73.2));
assert_eq!(b["etaSec"], json!(0.0));
assert_eq!(b["confidence"], json!("measured"));
assert_eq!(b["measured"]["download"], json!(12.35));
assert_eq!(b["measured"]["install"], json!(60.0));
assert_eq!(b["measured"]["verify"], json!(0.5));
assert_eq!(b["measured"]["seconds"], json!(73.21));
let f = progress_block(3.0, false, "download failed", &Measured::default());
assert_eq!(f["percent"], Value::Null);
assert!(f.get("measured").is_none(), "a failed run does not feed the history");
assert_eq!(f["stepLabel"], json!("download failed"));
}
#[test]
fn diagnose_only_never_downloads_or_runs() {
// On this (Linux) test box the OS layer has no installer URL, so no network at
// all is touched: the probe skips the size check and the running check is unknown.
let r = handle(&[inst("10.0.5")], &json!({"diagnoseOnly": true, "version": "10.0.6"}), &offline);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["diagnoseOnly"], json!(true));
assert_eq!(r["targetVersion"], json!("10.0.6"));
assert_eq!(r["targetSource"], json!("pinned"));
assert_eq!(r["cachedInstallerBytes"], json!(0));
assert_eq!(r["cachedInstallerTruncated"], json!(false));
assert_eq!(r["alreadyCurrent"], json!(false));
assert_eq!(r["installedVersions"], json!(["10.0.5"]));
assert!(r.get("isAdmin").is_some() && r.get("canInstallAllUsersSilently").is_some(), "{r}");
assert!(r["_hint"].as_str().unwrap().contains("uacEnableLUA"));
let os = native().capabilities().os;
if os != "windows" {
assert_eq!(r["downloadUrl"], Value::Null);
assert_eq!(r["silentInstallSupported"], json!(false));
assert_eq!(r["serverInstallerBytes"], Value::Null);
if r["kicadRunning"].is_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
}
if native().capabilities().os == "linux" {
// Linux installs through flatpak --user; never run a real install from a test.
if std::process::Command::new("flatpak").arg("--version").output().is_ok_and(|o| o.status.success()) {
return;
}
let r = handle(&[], &json!({"version": "10.0.6"}), &offline);
assert_eq!(r["errorCode"], json!("flatpak_missing"), "{r}");
return;
}
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"));
}
// wiki #63: the restart answer follows the process scan, and a KiCad that is not running
// is told plainly that nothing needs restarting instead of the old always-true guess.
#[test]
fn running_report_follows_the_scan() {
let up = running_report_from((Some(true), vec![4120, 5008], None));
assert_eq!(up.0, json!(true));
assert!(up.1, "a running KiCad has to be restarted");
assert_eq!(up.3, vec![4120, 5008]);
assert!(restart_sentence(&up.0, &up.3, "the library").contains("4120"));
let down = running_report_from((Some(false), vec![], None));
assert_eq!(down.0, json!(false));
assert!(!down.1, "nothing to restart when KiCad is not running");
let says = restart_sentence(&down.0, &down.3, "the library");
assert!(says.contains("nothing to restart"), "{says}");
let unknown = running_report_from((None, vec![], Some("processes: not supported on this platform".into())));
assert!(unknown.0.is_null());
assert!(unknown.1, "unknown stays on the safe answer");
assert_eq!(unknown.2.as_deref(), Some("processes: not supported on this platform"));
assert!(restart_sentence(&unknown.0, &unknown.3, "the library").contains("cannot tell"));
}
}