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
rust: netlist, lint, libraries/settings/design rules, model_check/pcm/updates groups (33 verbs); net feature gate; serv…
2a09607
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
//! kicad_check_for_updates: the read-only half of handlers/upgrade.py.
//!
//! Installed KiCad versus the latest STABLE release, resolved from KiCad's GitLab tags
//! (baked fallback on a network blip), plus the "your KiCad predates the IPC API,
//! upgrade" nudge and the official downloads.kicad.org installer URL. The installer
//! itself (kicad_upgrade: download, silent NSIS install, elevation facts) is a later
//! phase; `remote_installer_size` is here already because it is the anti-truncation
//! ground truth that phase needs.
//!
//! TLS is rustls with bundled roots, so the CA-store problem the Python bridge had on
//! ab's portable interpreter cannot recur.
use std::time::Duration;
use serde_json::{json, Value};
/// Baked fallback if the GitLab tag query fails. Bump on new stable.
pub const FALLBACK_LATEST: &str = "10.0.6";
pub const TAGS_URL: &str = "https://gitlab.com/api/v4/projects/kicad%2Fcode%2Fkicad/repository/tags?per_page=30";
const DL_URL: &str = "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-{v}-x86_64.exe";
const UA: &str = "kicad-bridge";
/// The oldest KiCad whose IPC API our routing verbs drive (docs/rust-port-plan.md).
const IPC_MIN: [u32; 3] = [10, 0, 1];
pub type Fetch<'a> = &'a dyn Fn(&str, Duration) -> Result<Vec<u8>, String>;
/// GET a URL with the bridge user agent (rustls, bundled roots).
#[cfg(not(feature = "net"))]
pub fn http_fetch(_url: &str, _timeout: Duration) -> Result<Vec<u8>, String> {
Err("the net feature is off in this build: no HTTPS client".into())
}
#[cfg(feature = "net")]
pub fn http_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {
let mut resp = ureq::get(url)
.header("User-Agent", UA)
.config()
.timeout_global(Some(timeout))
.build()
.call()
.map_err(|e| e.to_string())?;
resp.body_mut().with_config().limit(8 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())
}
/// Parse "10.0" / "9.0.9.1" / "8.0.4-rc1" into a sortable vector (kicad_detect._version_tuple):
/// pieces split on `.`, `-`, `_`; leading digits or 0.
pub fn version_tuple(v: &str) -> Vec<u32> {
let parts: Vec<u32> = v
.split(['.', '-', '_'])
.map(|piece| {
let digits: String = piece.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse::<u32>().unwrap_or(0)
})
.collect();
if parts.is_empty() {
vec![0]
} else {
parts
}
}
/// True for a real stable tag like 10.0.4 / 9.0.9.1. Drops `-rcN` prereleases and
/// KiCad's `X.99.*` nightly tags (minor component == 99).
pub fn is_stable(tag: &str) -> bool {
if tag.is_empty() {
return false;
}
let parts: Vec<&str> = tag.split('.').collect();
if parts.len() < 2 || parts.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) {
return false;
}
parts[1] != "99"
}
/// The newest stable tag in a GitLab tags API response body, if any.
pub fn latest_stable_from_tags(body: &[u8]) -> Option<String> {
let tags: Value = serde_json::from_slice(body).ok()?;
let mut stables: Vec<String> = tags
.as_array()?
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.filter(|t| is_stable(t))
.map(String::from)
.collect();
stables.sort_by(|a, b| version_tuple(b).cmp(&version_tuple(a)));
stables.into_iter().next()
}
/// (version, source) where source is "gitlab" or "fallback".
pub fn latest_stable_version(fetch: Fetch) -> (String, &'static str) {
if let Ok(body) = fetch(TAGS_URL, Duration::from_secs(10)) {
if let Some(v) = latest_stable_from_tags(&body) {
return (v, "gitlab");
}
}
(FALLBACK_LATEST.to_string(), "fallback")
}
pub fn download_url(version: &str) -> String {
DL_URL.replace("{v}", version)
}
/// The server's size for the installer, redirects followed. The installer phase
/// compares a cached or downloaded file against this so a dropped connection cannot
/// pass as a complete download. HEAD first; downloads.kicad.org answers 405 to HEAD,
/// so the fallback is a one-byte ranged GET and the total in Content-Range (the
/// s3.cern.ch mirror honours ranges). None when both probes fail.
#[cfg(not(feature = "net"))]
pub fn remote_installer_size(_url: &str, _timeout: Duration) -> Option<u64> {
None
}
#[cfg(feature = "net")]
pub fn remote_installer_size(url: &str, timeout: Duration) -> Option<u64> {
let head = ureq::head(url).header("User-Agent", UA).config().timeout_global(Some(timeout)).build().call();
if let Ok(resp) = head {
if let Some(n) = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|s| s.trim().parse::<u64>().ok()) {
return Some(n);
}
}
let resp = ureq::get(url)
.header("User-Agent", UA)
.header("Range", "bytes=0-0")
.config()
.timeout_global(Some(timeout))
.build()
.call()
.ok()?;
if resp.status().as_u16() == 206 {
return content_range_total(resp.headers().get("content-range")?.to_str().ok()?);
}
// A server that ignores Range answers 200 with the whole body; its Content-Length is the size.
resp.headers().get("content-length")?.to_str().ok()?.trim().parse().ok()
}
/// "bytes 0-0/967765696" -> 967765696.
pub fn content_range_total(value: &str) -> Option<u64> {
value.trim().rsplit('/').next()?.trim().parse().ok().filter(|n: &u64| *n > 0)
}
/// Does this KiCad version carry the IPC API our routing verbs need?
pub fn ipc_api_available(version: &str) -> bool {
version_tuple(version) >= IPC_MIN.to_vec()
}
pub const HINT: &str = "Run kicad_upgrade '{}' to install the latest (downloads the OFFICIAL downloads.kicad.org installer + silent /S install, NOT winget, which lags). Pass {\"version\":\"X.Y.Z\"} to pin a specific build.";
/// kicad_check_for_updates: is a newer KiCad available, and does the current one have the IPC API?
/// `current` is the installed kicad-cli X.Y.Z (None when KiCad is not installed);
/// `installer_bytes` is an optional HEAD probe result to report as downloadBytes.
pub fn check_for_updates(current: Option<&str>, fetch: Fetch, installer_bytes: Option<Option<u64>>) -> Value {
let (latest, src) = latest_stable_version(fetch);
let cur_t = current.map(version_tuple).unwrap_or_else(|| vec![0]);
let lat_t = version_tuple(&latest);
let upgrade_available = current.is_none() || lat_t > cur_t;
let ipc = current.map(ipc_api_available).unwrap_or(false);
let rec = match current {
None => format!("KiCad isn't installed. Run kicad_upgrade to install {latest} from the official downloads.kicad.org installer."),
Some(c) if upgrade_available && !ipc => {
format!("Your KiCad {c} predates the modern IPC / kipy automation API: upgrade to {latest} to unlock it (plus the latest fixes). Run kicad_upgrade.")
}
Some(c) if upgrade_available => format!("KiCad {latest} is available (you're on {c}). Run kicad_upgrade to update."),
Some(c) => format!("KiCad {c} is up to date (latest stable is {latest})."),
};
let mut out = json!({
"success": true,
"currentVersion": current,
"latestVersion": latest,
"latestSource": src,
"upgradeAvailable": upgrade_available,
"ipcApiAvailable": ipc,
"downloadUrl": download_url(&latest),
"recommendation": rec,
"_hint": HINT,
});
if let Some(bytes) = installer_bytes {
out["downloadBytes"] = json!(bytes);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const TAGS: &str = r#"[{"name":"10.99.0-rc1"},{"name":"10.0.6"},{"name":"10.0.6-rc1"},{"name":"9.0.9.1"},{"name":"10.0.10"},{"name":"10.99.5"},{"name":"v1"}]"#;
#[test]
fn version_tuples_sort_like_python() {
assert_eq!(version_tuple("10.0"), vec![10, 0]);
assert_eq!(version_tuple("9.0.9.1"), vec![9, 0, 9, 1]);
assert_eq!(version_tuple("8.0.4-rc1"), vec![8, 0, 4, 0]);
assert_eq!(version_tuple(""), vec![0]);
assert!(version_tuple("10.0.10") > version_tuple("10.0.6"));
assert!(version_tuple("10.0") < version_tuple("10.0.5"));
assert!(ipc_api_available("10.0.6"));
assert!(ipc_api_available("10.0.1"));
assert!(!ipc_api_available("10.0.0"));
assert!(!ipc_api_available("9.0.9"));
}
#[test]
fn stable_tags_only() {
assert!(is_stable("10.0.4"));
assert!(is_stable("9.0.9.1"));
assert!(!is_stable("10.0.4-rc1"));
assert!(!is_stable("10.99.0"));
assert!(!is_stable("v10"));
assert!(!is_stable("10"));
assert!(!is_stable(""));
assert_eq!(latest_stable_from_tags(TAGS.as_bytes()), Some("10.0.10".into()));
assert_eq!(latest_stable_from_tags(b"[]"), None);
assert_eq!(latest_stable_from_tags(b"not json"), None);
}
#[test]
fn latest_falls_back_when_offline() {
let offline = |_: &str, _: Duration| Err::<Vec<u8>, String>("no network in tests".into());
assert_eq!(latest_stable_version(&offline), (FALLBACK_LATEST.to_string(), "fallback"));
let online = |url: &str, _: Duration| {
assert_eq!(url, TAGS_URL);
Ok::<Vec<u8>, String>(TAGS.as_bytes().to_vec())
};
assert_eq!(latest_stable_version(&online), ("10.0.10".to_string(), "gitlab"));
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 content_range_totals() {
assert_eq!(content_range_total("bytes 0-0/967765696"), Some(967765696));
assert_eq!(content_range_total(" bytes 0-0/12 "), Some(12));
assert_eq!(content_range_total("bytes */0"), None);
assert_eq!(content_range_total("bytes 0-0/*"), None);
assert_eq!(content_range_total(""), None);
}
#[test]
fn check_for_updates_shapes() {
let online = |_: &str, _: Duration| Ok::<Vec<u8>, String>(TAGS.as_bytes().to_vec());
let r = check_for_updates(Some("10.0.6"), &online, None);
assert_eq!(r["success"], json!(true));
assert_eq!(r["currentVersion"], json!("10.0.6"));
assert_eq!(r["latestVersion"], json!("10.0.10"));
assert_eq!(r["latestSource"], json!("gitlab"));
assert_eq!(r["upgradeAvailable"], json!(true));
assert_eq!(r["ipcApiAvailable"], json!(true));
assert!(r["recommendation"].as_str().unwrap().starts_with("KiCad 10.0.10 is available (you're on 10.0.6)"));
assert!(r.get("downloadBytes").is_none());
let r = check_for_updates(Some("10.0.10"), &online, Some(Some(1234)));
assert_eq!(r["upgradeAvailable"], json!(false));
assert!(r["recommendation"].as_str().unwrap().contains("up to date"));
assert_eq!(r["downloadBytes"], json!(1234));
let r = check_for_updates(Some("9.0.9"), &online, None);
assert_eq!(r["ipcApiAvailable"], json!(false));
assert!(r["recommendation"].as_str().unwrap().contains("predates the modern IPC"));
let offline = |_: &str, _: Duration| Err::<Vec<u8>, String>("offline".into());
let r = check_for_updates(None, &offline, None);
assert_eq!(r["currentVersion"], Value::Null);
assert_eq!(r["latestSource"], json!("fallback"));
assert_eq!(r["upgradeAvailable"], json!(true));
assert!(r["recommendation"].as_str().unwrap().starts_with("KiCad isn't installed"));
assert_eq!(r["downloadUrl"], json!(download_url(FALLBACK_LATEST)));
}
}