app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
rust: full uninstall port (all-artifacts and testResidue modes); platform roots and process liveness
8 files changed
+338−20
rust/crates/kicad-bridge/src/catalog.rs+5−5rust/crates/kicad-bridge/src/verbs.rs+1−15rust/crates/kicad-core/src/lib.rs+1rust/crates/kicad-core/src/uninstall.rs+276rust/crates/kicad-platform/src/lib.rs+8rust/crates/kicad-platform/src/linux.rs+13rust/crates/kicad-platform/src/macos.rs+15rust/crates/kicad-platform/src/windows.rs+19rust/crates/kicad-bridge/src/catalog.rs+5−5@@ -153,11 +153,11 @@ pub static VERBS: &[Verb] = &[ }, Verb { name: "kicad_uninstall",- summary: "Remove only this bridge's own artifacts (scratch dir, backups it made). Never KiCad, never user data.",- mechanism: Mechanism::Local, risk: "write", timeout_sec: 120,- input: "{\"cascade\"?: bool}", example: "kicad_uninstall {}",- hint: "ab calls this during bridge_uninstall; it is idempotent and non-interactive.",- related: &[], pitfalls: &[],+ summary: "Remove everything this bridge created: plugin payload, Adom symbol/footprint/3D libraries and their lib-table rows, Mesa fallback DLLs, discovery files, demo and staging projects. Never KiCad itself, never user projects.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+ input: "{\"dryRun\"?: bool, \"testResidue\"?: bool, \"cascade\"?: bool}", example: "kicad_uninstall {\"dryRun\": true}",+ hint: "ab calls this during bridge_uninstall (cascade). Idempotent and non-interactive; dryRun previews. testResidue removes only what the verb runner leaves behind (fixture dir, demo project, ADOM_VERBTEST symbol and footprint, AdomVerbTest rows, stale discovery files).",+ related: &["kicad_adom_library_status"], pitfalls: &["A running KiCad may hold the plugin's .pyc open; re-run after KiCad exits if anything failed."], }, ];
rust/crates/kicad-bridge/src/verbs.rs+1−15@@ -41,7 +41,7 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal "kicad_export_svg" => export(state, args, if is_sch(args) { "sch" } else { "pcb" }, "svg", "SVG"), "kicad_export_step" => export(state, args, "pcb", "step", "STEP"), "kicad_export_bom_csv" => export(state, args, "sch", "bom", "BOM CSV"),- "kicad_uninstall" => uninstall(),+ "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."), }; if let Some(o) = out.as_object_mut() {@@ -255,17 +255,3 @@ fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label: }) } -fn uninstall() -> Value {- let mut cleaned: Vec<String> = Vec::new();- for name in ["adom-kicad-drc", "adom-kicad-erc"] {- if let Ok(rd) = std::fs::read_dir(std::env::temp_dir()) {- for e in rd.flatten() {- let n = e.file_name().to_string_lossy().to_string();- if n.starts_with(name) && n.ends_with(".json") && std::fs::remove_file(e.path()).is_ok() {- cleaned.push(n);- }- }- }- }- json!({"success": true, "cleaned": cleaned, "_hint": "Only bridge scratch files are removed. KiCad, its settings and user libraries are untouched."})-}
rust/crates/kicad-core/src/lib.rs+1@@ -16,3 +16,4 @@ pub mod bridge_log; pub mod install; #[cfg(feature = "ipc")] pub mod ipc;+pub mod uninstall;
rust/crates/kicad-core/src/uninstall.rsadded+276@@ -0,0 +1,276 @@+//! kicad_uninstall: remove everything this bridge ever created (Bridge SDK uninstall+//! contract, kicad-bridge issue #18). Idempotent, non-interactive, best effort per item,+//! scoped to our own artifacts. Port of handlers/uninstall.py; `testResidue` is the+//! subset the verb runner leaves behind.++use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::libraries::LibTable;+use kicad_platform::native;++const MESA_DLLS: [&str; 3] = ["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];++struct Report {+ dry: bool,+ removed: Vec<String>,+ failed: Vec<Value>,+ absent: usize,+}++impl Report {+ fn rm_file(&mut self, p: &Path, label: &str) {+ if !p.exists() {+ self.absent += 1;+ return;+ }+ if !self.dry {+ if let Err(e) = std::fs::remove_file(p) {+ self.failed.push(json!({"item": format!("{label}: {}", p.display()), "error": e.to_string()}));+ return;+ }+ }+ self.removed.push(format!("{label}: {}", p.display()));+ }+ fn rm_tree(&mut self, p: &Path, label: &str) {+ if !p.exists() {+ self.absent += 1;+ return;+ }+ if !self.dry {+ if let Err(e) = remove_tree_force(p) {+ self.failed.push(json!({"item": format!("{label}: {}", p.display()), "error": e}));+ return;+ }+ }+ self.removed.push(format!("{label}: {}", p.display()));+ }+ fn remove_row(&mut self, table_path: &Path, nickname: &str) {+ if !table_path.exists() {+ self.absent += 1;+ return;+ }+ match LibTable::parse_file(table_path) {+ Ok(mut t) => {+ if t.has_library(nickname) {+ if !self.dry {+ t.remove_library(nickname);+ if let Err(e) = t.write_file(table_path) {+ self.failed.push(json!({"item": format!("'{nickname}' row in {}", table_path.display()), "error": e}));+ return;+ }+ }+ self.removed.push(format!("'{nickname}' row in {}", table_path.display()));+ } else {+ self.absent += 1;+ }+ }+ Err(e) => self.failed.push(json!({"item": format!("'{nickname}' row in {}", table_path.display()), "error": e})),+ }+ }+}++/// rmtree that clears read-only bits on the way (the Python's onerror chmod).+fn remove_tree_force(p: &Path) -> Result<(), String> {+ if let Err(first) = std::fs::remove_dir_all(p) {+ if let Ok(rd) = std::fs::read_dir(p) {+ for e in rd.flatten() {+ if let Ok(md) = e.metadata() {+ let mut perm = md.permissions();+ if perm.readonly() {+ #[allow(clippy::permissions_set_readonly_false)]+ perm.set_readonly(false);+ let _ = std::fs::set_permissions(e.path(), perm);+ }+ }+ }+ }+ std::fs::remove_dir_all(p).map_err(|e| format!("{first}; retry: {e}"))?;+ }+ Ok(())+}++fn subdirs(root: Option<PathBuf>) -> Vec<PathBuf> {+ let Some(root) = root else { return Vec::new() };+ let Ok(rd) = std::fs::read_dir(&root) else { return Vec::new() };+ rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.path()).collect()+}++fn glob_prefix(dir: &Path, prefix: &str, suffix: &str) -> Vec<PathBuf> {+ let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() };+ rd.flatten()+ .filter(|e| {+ let n = e.file_name().to_string_lossy().to_string();+ n.starts_with(prefix) && n.ends_with(suffix)+ })+ .map(|e| e.path())+ .collect()+}++/// Every KiCad bin dir this bridge may have dropped the Mesa fallback into.+fn bin_dirs(installs: &[crate::detect::KicadInstall]) -> Vec<PathBuf> {+ let mut out: Vec<PathBuf> = Vec::new();+ for i in installs {+ let b = PathBuf::from(&i.bin_dir);+ if b.is_dir() && !out.iter().any(|x| x.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())) {+ out.push(b);+ }+ }+ out+}++pub fn handle(installs: &[crate::detect::KicadInstall], args: &Value) -> Value {+ let dry = args.get("dryRun").and_then(Value::as_bool).unwrap_or(false);+ let test_residue = args.get("testResidue").and_then(Value::as_bool).unwrap_or(false);+ let mut r = Report { dry, removed: Vec::new(), failed: Vec::new(), absent: 0 };+ let plat = native();+ if test_residue {+ return remove_test_residue(&mut r);+ }+ for ver_dir in subdirs(plat.user_root()) {+ let site = ver_dir.join("3rdparty").join("Python311").join("site-packages");+ r.rm_file(&site.join("adom_bridge.py"), "plugin payload");+ r.rm_file(&site.join("usercustomize.py"), "plugin loader");+ let pyc = site.join("__pycache__");+ if pyc.is_dir() {+ for f in glob_prefix(&pyc, "adom_bridge", ".pyc").into_iter().chain(glob_prefix(&pyc, "usercustomize", ".pyc")) {+ r.rm_file(&f, "plugin pycache");+ }+ }+ r.rm_file(&ver_dir.join("symbols").join("Adom.kicad_sym"), "Adom symbol library");+ r.rm_tree(&ver_dir.join("footprints").join("Adom.pretty"), "Adom footprint library");+ r.rm_tree(&ver_dir.join("3dmodels").join("Adom"), "Adom 3D model library");+ }+ for cfg in subdirs(plat.config_root()) {+ for table in ["sym-lib-table", "fp-lib-table"] {+ r.remove_row(&cfg.join(table), "Adom");+ }+ }+ for b in bin_dirs(installs) {+ for dll in MESA_DLLS {+ r.rm_file(&b.join(dll), "mesa fallback");+ r.rm_file(&b.join(format!("{dll}.local")), "mesa fallback marker");+ }+ for loc in glob_prefix(&b, "", ".exe.local") {+ r.rm_file(&loc, "mesa dotlocal");+ }+ }+ for f in glob_prefix(&std::env::temp_dir(), "adom-kicad-bridge-", ".json") {+ r.rm_file(&f, "plugin discovery file");+ }+ r.rm_tree(Path::new("C:/tmp/adom-desktop-screenshots"), "screenshot cache");+ if let Some(docs) = plat.documents_dir() {+ r.rm_tree(&docs.join("adom-kicad-demo"), "demo project");+ r.rm_tree(&docs.join("adom-part-exports"), "one-off part exports");+ r.rm_tree(&docs.join("adom-part-staging"), "part install staging");+ }+ let ok = r.failed.is_empty();+ json!({+ "success": ok,+ "cascade": args.get("cascade").and_then(Value::as_bool).unwrap_or(false),+ "dryRun": dry,+ "removed": r.removed,+ "failed": r.failed,+ "alreadyClean": r.removed.is_empty() && ok,+ "summary": format!("{} {} item(s), {} failed, {} already absent", if dry { "would remove" } else { "removed" }, r.removed.len(), r.failed.len(), r.absent),+ "_hint": "Bridge-created artifacts only: KiCad itself, user projects, and other bridges are untouched. Idempotent: safe to re-run. A running KiCad may hold the plugin's .pyc open; re-run after KiCad exits if anything failed. Bridge removes its own bridges-cache during the cascade.",+ })+}++fn remove_test_residue(r: &mut Report) -> Value {+ let plat = native();+ if let Some(docs) = plat.documents_dir() {+ r.rm_tree(&docs.join("adom-kicad-verbtest"), "verbtest fixture dir");+ r.rm_tree(&docs.join("adom-kicad-demo"), "demo project");+ }+ for ver_dir in subdirs(plat.user_root()) {+ r.rm_file(&ver_dir.join("footprints").join("Adom.pretty").join("ADOM_VERBTEST.kicad_mod"), "test footprint");+ let sym = ver_dir.join("symbols").join("Adom.kicad_sym");+ if sym.exists() {+ match std::fs::read_to_string(&sym) {+ Ok(text) => match crate::libraries::extract_top_level_symbols(&text) {+ Ok(blocks) => {+ let hits: Vec<&String> = blocks.iter().filter(|(n, _)| n == "ADOM_VERBTEST").map(|(_, b)| b).collect();+ if !hits.is_empty() {+ if !r.dry {+ let mut out = text.clone();+ for b in &hits {+ out = out.replacen(b.as_str(), "", 1);+ }+ if let Err(e) = std::fs::write(&sym, out) {+ r.failed.push(json!({"item": format!("ADOM_VERBTEST in {}", sym.display()), "error": e.to_string()}));+ continue;+ }+ }+ r.removed.push(format!("ADOM_VERBTEST symbol in {}", sym.display()));+ }+ }+ Err(e) => r.failed.push(json!({"item": format!("ADOM_VERBTEST in {}", sym.display()), "error": e})),+ },+ Err(e) => r.failed.push(json!({"item": format!("ADOM_VERBTEST in {}", sym.display()), "error": e.to_string()})),+ }+ }+ }+ for cfg in subdirs(plat.config_root()) {+ for table in ["sym-lib-table", "fp-lib-table"] {+ let tp = cfg.join(table);+ if tp.exists() {+ r.remove_row(&tp, "AdomVerbTest");+ }+ }+ }+ for f in glob_prefix(&std::env::temp_dir(), "adom-kicad-bridge-", ".json") {+ let stem = f.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ match stem.rsplit('-').next().and_then(|p| p.parse::<u32>().ok()) {+ None => r.rm_file(&f, "legacy discovery file"),+ Some(pid) => {+ if plat.process_alive(pid) == Some(false) {+ r.rm_file(&f, "stale discovery file");+ }+ }+ }+ }+ let ok = r.failed.is_empty();+ json!({+ "success": ok, "testResidue": true, "dryRun": r.dry,+ "removed": r.removed, "failed": r.failed,+ "alreadyClean": r.removed.is_empty() && ok,+ "summary": format!("{} {} test-residue item(s), {} failed", if r.dry { "would remove" } else { "removed" }, r.removed.len(), r.failed.len()),+ "_hint": "Suite residue only: plugin, Adom libraries, KiCad and user files untouched. Run after every verb-suite pass; idempotent.",+ })+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn dry_run_reports_without_touching() {+ let tmp = std::env::temp_dir().join(format!("kb-uninstall-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&tmp);+ let demo = tmp.join("adom-kicad-demo");+ std::fs::create_dir_all(&demo).unwrap();+ std::fs::write(demo.join("x.txt"), "x").unwrap();+ let mut r = Report { dry: true, removed: vec![], failed: vec![], absent: 0 };+ r.rm_tree(&demo, "demo project");+ r.rm_file(&tmp.join("nope"), "absent");+ assert_eq!(r.removed.len(), 1);+ assert_eq!(r.absent, 1);+ assert!(demo.is_dir(), "dry run must not delete");+ let mut r = Report { dry: false, removed: vec![], failed: vec![], absent: 0 };+ r.rm_tree(&demo, "demo project");+ assert!(!demo.exists());+ let _ = std::fs::remove_dir_all(&tmp);+ }++ #[test]+ fn stale_discovery_pid_parsing() {+ let f = PathBuf::from("adom-kicad-bridge-12345.json");+ let stem = f.file_stem().unwrap().to_string_lossy().to_string();+ assert_eq!(stem.rsplit('-').next().unwrap().parse::<u32>().ok(), Some(12345));+ let f = PathBuf::from("adom-kicad-bridge-legacy.json");+ let stem = f.file_stem().unwrap().to_string_lossy().to_string();+ assert_eq!(stem.rsplit('-').next().unwrap().parse::<u32>().ok(), None);+ }+}
rust/crates/kicad-platform/src/lib.rs+8@@ -64,6 +64,14 @@ pub trait Platform: Sync + Send { fn user_dir(&self, version: &str) -> Option<PathBuf>; /// Make a child process invisible and detached from any console. fn quiet_command(&self, cmd: &mut Command);+ /// Parent of every per-version settings dir (all KiCad versions the user ever ran).+ fn config_root(&self) -> Option<PathBuf>;+ /// Parent of every per-version documents dir (Documents/KiCad).+ fn user_root(&self) -> Option<PathBuf>;+ /// The user's Documents folder (where the bridge keeps demo and staging projects).+ fn documents_dir(&self) -> Option<PathBuf>;+ /// Is a process with this id alive? None when the OS layer cannot tell.+ fn process_alive(&self, pid: u32) -> Option<bool>; } /// Standard "this build cannot do that here" reply.
rust/crates/kicad-platform/src/linux.rs+13@@ -64,4 +64,17 @@ impl Platform for Native { fn quiet_command(&self, cmd: &mut Command) { cmd.stdin(std::process::Stdio::null()); }+ fn config_root(&self) -> Option<PathBuf> {+ let base = std::env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from).or_else(|| home().map(|h| h.join(".config")))?;+ Some(base.join("kicad"))+ }+ fn user_root(&self) -> Option<PathBuf> {+ Some(home()?.join("Documents/KiCad"))+ }+ fn documents_dir(&self) -> Option<PathBuf> {+ Some(home()?.join("Documents"))+ }+ fn process_alive(&self, pid: u32) -> Option<bool> {+ Some(std::path::Path::new(&format!("/proc/{pid}")).exists())+ } }
rust/crates/kicad-platform/src/macos.rs+15@@ -53,4 +53,19 @@ impl Platform for Native { fn quiet_command(&self, cmd: &mut Command) { cmd.stdin(std::process::Stdio::null()); }+ fn config_root(&self) -> Option<PathBuf> {+ Some(home()?.join("Library/Preferences/kicad"))+ }+ fn user_root(&self) -> Option<PathBuf> {+ Some(home()?.join("Documents/KiCad"))+ }+ fn documents_dir(&self) -> Option<PathBuf> {+ Some(home()?.join("Documents"))+ }+ fn process_alive(&self, pid: u32) -> Option<bool> {+ let mut cmd = Command::new("kill");+ cmd.args(["-0", &pid.to_string()]);+ self.quiet_command(&mut cmd);+ Some(cmd.output().ok()?.status.success())+ } }
rust/crates/kicad-platform/src/windows.rs+19@@ -110,4 +110,23 @@ impl Platform for Native { cmd.stdin(std::process::Stdio::null()); cmd.creation_flags(CREATE_NO_WINDOW); }+ fn config_root(&self) -> Option<PathBuf> {+ Some(appdata()?.join("kicad"))+ }+ fn user_root(&self) -> Option<PathBuf> {+ Some(userprofile()?.join("Documents").join("KiCad"))+ }+ fn documents_dir(&self) -> Option<PathBuf> {+ Some(userprofile()?.join("Documents"))+ }+ fn process_alive(&self, pid: u32) -> Option<bool> {+ // Phase 3 replaces this with OpenProcess through the windows crate; until then a+ // quiet tasklist query (no console window) answers the question.+ let mut cmd = Command::new("tasklist");+ cmd.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]);+ self.quiet_command(&mut cmd);+ let out = cmd.output().ok()?;+ let text = String::from_utf8_lossy(&out.stdout);+ Some(text.contains(&format!("\"{pid}\"")))+ } }