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.50: 1.0.50: the background guard (backgroundGuard) is OFF by default. John, after watching it live: 'you just are t…
cad51ac
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
//! The bridge's own user-visible settings (`kicad_get_settings` / `kicad_set_settings`),
//! ported from `handlers/settings.py`. These are NOT KiCad's settings: they are the
//! preferences the dashboard renders (taskbar overlay badges, activity indicator),
//! persisted per box in `<home>/.adom/kicad-bridge-settings.json`.
//!
//! The Python writes the file in place with no backup and no KiCad-running check;
//! the port keeps that contract but writes through a sibling temp file and a
//! rename so a crash mid-write cannot leave a half-written JSON behind.
use std::path::PathBuf;
use serde_json::{json, Map, Value};
/// Every settable key with its default. Order is the order `_hint` lists them.
pub fn defaults() -> Vec<(&'static str, Value)> {
vec![
("overlayBadges", json!(true)),
// How the taskbar shows "Adom is driving KiCad": progress paints a bar from
// the measured verb timings and suppresses the OS flash; flash leaves the
// Windows attention flash alone; both; none.
("activityIndicator", json!("progress")),
// A foreground the calling AI asked for (foreground:true with a reason) announces
// itself with a toast, which ab mirrors to the user's other boxes. One toast per
// this many minutes; later foregrounds inside the window keep only the 3 s
// on-screen caption on the driven box. 0 turns the toast off (John, 2026-09-19:
// one per verb "makes them more disruptive than you foregrounding kicad").
("foregroundToastMinutes", json!(10)),
// The A/B switch John asked for (2026-09-21): true = a KiCad window the bridge is opening is
// sent behind the user's window; false = the bridge never moves or pushes any KiCad window,
// so whatever KiCad does with the foreground is what the user sees. DEFAULT OFF from 1.0.50
// (John, 2026-09-21: "you just are too aggressive with it and kicad acts weird when you have
// it on"); wiki #114 tracks the work before it may be on by default again.
("backgroundGuard", json!(false)),
]
}
pub const ACTIVITY_CHOICES: &[&str] = &["progress", "flash", "both", "none"];
pub fn defaults_map() -> Map<String, Value> {
defaults().into_iter().map(|(k, v)| (k.to_string(), v)).collect()
}
/// `<USERPROFILE or HOME>/.adom/kicad-bridge-settings.json`. Creates `.adom/`.
pub fn settings_path() -> PathBuf {
let base = std::env::var("USERPROFILE").ok().filter(|s| !s.is_empty())
.or_else(|| std::env::var("HOME").ok().filter(|s| !s.is_empty()))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let d = base.join(".adom");
let _ = std::fs::create_dir_all(&d);
d.join("kicad-bridge-settings.json")
}
/// Defaults overlaid with whatever the file holds (unknown keys are kept, like the
/// Python `dict.update`). An unreadable or invalid file yields the defaults.
pub fn get_settings() -> Map<String, Value> {
let mut out = defaults_map();
let file = read_file_map();
let chosen_guard = file.get(GUARD_CHOSEN).and_then(Value::as_bool).unwrap_or(false);
for (k, v) in file {
if k == GUARD_CHOSEN || (k == "backgroundGuard" && !chosen_guard) {
continue;
}
out.insert(k, v);
}
out
}
/// Marker written beside `backgroundGuard` when someone sets it on purpose. Before 1.0.50 every
/// save wrote EVERY default into the file, so a stored `backgroundGuard:true` may be nobody's
/// choice; without this marker it is ignored and the (now off) default applies.
const GUARD_CHOSEN: &str = "backgroundGuardChosen";
fn read_file_map() -> Map<String, Value> {
std::fs::read_to_string(settings_path()).ok()
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.and_then(|v| match v { Value::Object(m) => Some(m), _ => None })
.unwrap_or_default()
}
/// Type a raw value by its default's type. The first Python cut bool()'d everything,
/// so `activityIndicator:"flash"` persisted as true; this is the fix carried over.
pub fn coerce(key: &str, raw: &Value) -> Result<Value, String> {
if key == "activityIndicator" {
let v = value_text(raw).trim().to_lowercase();
if !ACTIVITY_CHOICES.contains(&v.as_str()) {
return Err(format!("{key} must be one of {}", ACTIVITY_CHOICES.join(", ")));
}
return Ok(json!(v));
}
let default = defaults().into_iter().find(|(k, _)| *k == key).map(|(_, v)| v).unwrap_or(Value::Null);
if default.is_number() {
let n = match raw {
Value::Number(n) => n.as_f64(),
Value::String(t) => t.trim().parse::<f64>().ok(),
Value::Bool(false) | Value::Null => Some(0.0),
_ => None,
};
return match n {
Some(f) if f.is_finite() && f >= 0.0 => Ok(json!((f.min(1440.0)).round() as u64)),
_ => Err(format!("{key} must be a number of minutes, 0 or more (0 turns it off)")),
};
}
if default.is_boolean() {
return Ok(json!(match raw {
Value::String(s) => matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "on" | "yes"),
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
Value::Null => false,
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}));
}
Ok(raw.clone())
}
fn value_text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Bool(b) => if *b { "True".into() } else { "False".into() },
other => other.to_string(),
}
}
/// Python `handle_set_settings`: `restoreDefaults` first, then every known key
/// present in `args`. Returns the full verb response.
pub fn set_settings(args: &Value) -> Value {
let mut s = get_settings();
let mut changed = Map::new();
if args.get("restoreDefaults").and_then(truthy).unwrap_or(false) {
for (k, v) in defaults() {
if s.get(k) != Some(&v) {
changed.insert(k.to_string(), v.clone());
}
s.insert(k.to_string(), v);
}
}
for (k, _) in defaults() {
if let Some(raw) = args.get(k) {
let v = match coerce(k, raw) {
Ok(v) => v,
Err(e) => return json!({"success": false, "error": e}),
};
if s.get(k) != Some(&v) {
changed.insert(k.to_string(), v.clone());
}
s.insert(k.to_string(), v);
}
}
let keys: Vec<&str> = defaults().into_iter().map(|(k, _)| k).collect();
if changed.is_empty() {
return json!({"success": true, "settings": s, "changed": {},
"output": "no settings changed",
"_hint": format!("Settable keys: {}.", keys.join(", "))});
}
// Persist only what someone CHOSE: the keys already in the file plus the ones set now.
// Writing the whole map (as before 1.0.50) froze every default into the file, so changing a
// default in a release reached nobody who had ever saved a setting.
let mut file = read_file_map();
if args.get("restoreDefaults").and_then(truthy).unwrap_or(false) {
for (k, _) in defaults() {
file.remove(k);
}
file.remove(GUARD_CHOSEN);
}
for (k, _) in defaults() {
if args.get(k).is_some() {
if let Some(v) = s.get(k) {
file.insert(k.to_string(), v.clone());
}
if k == "backgroundGuard" {
file.insert(GUARD_CHOSEN.to_string(), json!(true));
}
}
}
let text = serde_json::to_string_pretty(&Value::Object(file)).unwrap_or_default();
if let Err(e) = crate::libraries::write_atomic(&settings_path(), text.as_bytes()) {
return json!({"success": false, "error": format!("could not persist settings: {e}")});
}
json!({"success": true, "settings": s, "changed": changed,
"output": format!("settings updated: {}", python_repr(&changed))})
}
/// A Python-flavoured truthiness for the `restoreDefaults` flag.
fn truthy(v: &Value) -> Option<bool> {
Some(match v {
Value::Bool(b) => *b,
Value::Null => false,
Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
})
}
/// `{'overlayBadges': False}`: the Python `output` line prints the dict with repr.
fn python_repr(m: &Map<String, Value>) -> String {
let parts: Vec<String> = m
.iter()
.map(|(k, v)| {
let val = match v {
Value::Bool(true) => "True".to_string(),
Value::Bool(false) => "False".to_string(),
Value::Null => "None".to_string(),
Value::String(s) => format!("'{s}'"),
other => other.to_string(),
};
format!("'{k}': {val}")
})
.collect();
format!("{{{}}}", parts.join(", "))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_chosen_settings_are_saved_and_an_unchosen_guard_does_not_count() {
let dir = std::env::temp_dir().join(format!("kb-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".adom")).unwrap();
let old = std::env::var_os("USERPROFILE");
std::env::set_var("USERPROFILE", &dir);
// a file the old save-everything code wrote: guard true, nobody chose it
std::fs::write(settings_path(), r#"{"overlayBadges":true,"activityIndicator":"progress","foregroundToastMinutes":10,"backgroundGuard":true}"#).unwrap();
assert_eq!(get_settings().get("backgroundGuard"), Some(&json!(false)));
// changing another setting does not freeze the guard into the file
set_settings(&json!({"foregroundToastMinutes": 0}));
let f: Value = serde_json::from_str(&std::fs::read_to_string(settings_path()).unwrap()).unwrap();
assert_eq!(f["foregroundToastMinutes"], json!(0));
assert!(f.get(GUARD_CHOSEN).is_none());
// someone turns it on on purpose: it sticks
set_settings(&json!({"backgroundGuard": true}));
assert_eq!(get_settings().get("backgroundGuard"), Some(&json!(true)));
// restore defaults: back to off, marker gone
set_settings(&json!({"restoreDefaults": true}));
assert_eq!(get_settings().get("backgroundGuard"), Some(&json!(false)));
match old { Some(v) => std::env::set_var("USERPROFILE", v), None => std::env::remove_var("USERPROFILE") }
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn coerce_types_by_default() {
assert_eq!(coerce("overlayBadges", &json!("off")).unwrap(), json!(false));
assert_eq!(coerce("overlayBadges", &json!("Yes")).unwrap(), json!(true));
assert_eq!(coerce("overlayBadges", &json!(0)).unwrap(), json!(false));
assert_eq!(coerce("activityIndicator", &json!("Flash")).unwrap(), json!("flash"));
assert!(coerce("activityIndicator", &json!("blink")).is_err());
assert!(coerce("activityIndicator", &json!(true)).is_err());
}
#[test]
fn repr_matches_python() {
let mut m = Map::new();
m.insert("overlayBadges".into(), json!(false));
m.insert("activityIndicator".into(), json!("flash"));
assert_eq!(python_repr(&m), "{'activityIndicator': 'flash', 'overlayBadges': False}");
}
#[test]
fn defaults_have_every_key() {
let d = defaults_map();
assert_eq!(d.get("overlayBadges"), Some(&json!(true)));
assert_eq!(d.get("activityIndicator"), Some(&json!("progress")));
}
#[test]
fn foreground_toast_minutes_is_a_clamped_count() {
assert_eq!(coerce("foregroundToastMinutes", &json!("15")).unwrap(), json!(15));
assert_eq!(coerce("foregroundToastMinutes", &json!(0)).unwrap(), json!(0));
assert_eq!(coerce("foregroundToastMinutes", &json!(false)).unwrap(), json!(0));
assert_eq!(coerce("foregroundToastMinutes", &json!(99999)).unwrap(), json!(1440));
assert!(coerce("foregroundToastMinutes", &json!(-5)).is_err());
assert!(coerce("foregroundToastMinutes", &json!("soon")).is_err());
}
}