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.
← Commit history
Merge PR #433: 1.0.104: close verbs keep another AI thread's document open (closes #139)
8 files changed
+450−18
rust/Cargo.lock+3−3@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.103"+version = "1.0.104" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.103"+version = "1.0.104" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.103"+version = "1.0.104" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.103"+version = "1.0.104" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/main.rs+1−1@@ -74,7 +74,7 @@ pub fn caller_side(code: &str) -> bool { const EXACT: &[&str] = &["bad_arg", "bad_args", "file_not_found", "unsaved_changes", "stale_board", "revision_required", "drc_rejected", "no_model", "reason_required", "not_supported", "unsupported_platform", "unknown_verb", "unknown_layer", "unknown_rule", "unknown_key", "unknown_action", "wrong_file_type", "file_exists", "footprint_exists", "ambiguous", "ambiguous_board", "already_there", "already_flagged",- "needs_closed_board", "live_required", "board_open", "kicad_running", "project_open", "too_close", "same_point", "zero_length", "plan_invalid",+ "needs_closed_board", "documents_in_use", "live_required", "board_open", "kicad_running", "project_open", "too_close", "same_point", "zero_length", "plan_invalid", "invalid_path", "invalid_scope", "name_required", "engine_required", "foreground_required", "nothing_to_route", "nothing_in_box", "nothing_placed", "side_change_not_supported", "live_move_drops_units", "entry_off_bus", "bad_via_geometry"]; EXACT.contains(&code) || code.starts_with("missing_") || code.starts_with("no_such_") || code.ends_with("_not_found")
rust/crates/kicad-bridge/src/verbs.rs+21−1@@ -41,7 +41,27 @@ pub(crate) fn call(state: &mut State, command: &str, args: &Value) -> Value { run(state, command, args, &Caller { thread: "kicad-bridge".into(), container: String::new(), reason: "a step of a composite verb".into() }) } -fn run(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value {+/// The verbs that close documents: they forget them in doc_users rather than noting a use.+const CLOSES: &[&str] = &["kicad_close", "kicad_close_document", "kicad_close_all_documents"];++/// Run one verb as `caller`, then note which documents it drove in the live editor (wiki #139:+/// kicad_close_all_documents and kicad_close keep another AI thread's document open).+fn run(state: &mut State, command: &str, args: &Value, caller: &Caller) -> Value {+ let who = kicad_core::ab::caller_from(args, &caller.thread, &caller.container, &caller.reason);+ // a composite verb's inner step keeps the outer caller as the asker+ let inner = caller.thread == "kicad-bridge";+ let _asker = (!inner).then(|| kicad_core::doc_users::set_asker(&who.thread, &who.reason));+ let out = run_verb(state, command, args, caller);+ // the live editor: window and IPC verbs, plus the standard's document verbs catalogued as File+ let live = catalog::find(command).is_some_and(|v| matches!(v.mechanism, catalog::Mechanism::Window | catalog::Mechanism::Ipc))+ || matches!(command, "kicad_open_document" | "kicad_open_project" | "kicad_activate_document");+ if !inner && live && !CLOSES.contains(&command) && out.get("success").and_then(Value::as_bool) == Some(true) {+ kicad_core::doc_users::note(&who.thread, args);+ }+ out+}++fn run_verb(state: &mut State, command: &str, args: &Value, _caller: &Caller) -> Value { let Some(verb) = catalog::find(command) else { let names: Vec<&str> = catalog::all().iter().map(|v| v.name).collect(); return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", ")));
rust/crates/kicad-bridge/src/verbs_demo.rs+3−1@@ -982,7 +982,9 @@ fn restart_for_new_footprint(state: &mut State, prep: &Prep) -> Value { let closed = win::dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({})); adopt_identity(); let t0 = Instant::now();- while t0.elapsed() < Duration::from_secs(20) {+ // another AI thread's document stops the close outright (wiki #139): nothing to wait for+ let refused = closed["errorCode"] == json!("documents_in_use");+ while !refused && t0.elapsed() < Duration::from_secs(20) { win::invalidate(); if win::kicad_windows().is_empty() { break;
rust/crates/kicad-bridge/src/verbs_windows.rs+147−11@@ -151,11 +151,11 @@ pub static VERBS: &[Verb] = &[ name: "kicad_close", summary: "Close KiCad (editors first, then the project manager).", mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,- input: "{\"force\"?: bool, \"discardChanges\"?: bool}",+ input: "{\"force\"?: bool, \"discardChanges\"?: bool, \"reason\"?: \"...\"}", example: "kicad_close {}",- hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. Read the kicad-interaction skill before blind-closing.",- related: &["kicad_open_editors", "kicad_window_info"],- pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently"],+ hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. While another AI thread has a board or schematic open (opened or driven in the last 12 h) nothing is closed: errorCode documents_in_use lists documentsInUse [{name, openedBy}]; force:true with a reason closes past it. Read the kicad-interaction skill before blind-closing.",+ related: &["kicad_open_editors", "kicad_window_info", "kicad_close_document", "kicad_close_all_documents"],+ pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently", "KiCad is shared by every AI thread on this desktop: to close your own board use kicad_close_document {path}."], }, Verb { name: "kicad_close_symbol_editor",@@ -298,11 +298,11 @@ pub static VERBS: &[Verb] = &[ name: "kicad_close_all_documents", summary: "Close every open board, schematic and library editor without a save dialog (the standard's close_all_documents); unsaved work stays open and is listed unless save or discardUnsaved says what to do.", mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,- input: "{\"save\"?: false, \"discardUnsaved\"?: false, \"includeScratch\"?: false}",+ input: "{\"save\"?: false, \"discardUnsaved\"?: false, \"includeScratch\"?: false, \"force\"?: false, \"reason\"?: \"...\"}", example: "kicad_close_all_documents {}",- hint: "closed, saved and keptOpen [{name, why}]. KiCad's project manager stays up; kicad_close ends KiCad itself. A scratch document is one that has never been saved.",+ hint: "closed, saved and keptOpen [{name, why}]. KiCad's project manager stays up; kicad_close ends KiCad itself. A scratch document is one that has never been saved. A board or schematic another AI thread opened or drove in the last 12 h stays open, neither saved nor closed, with why \"opened by <thread>\" and openedBy; force:true with a reason closes it too (forcedPast lists them).", related: &["kicad_close_document", "kicad_close", "kicad_save"],- pitfalls: &["discardUnsaved loses changes that exist nowhere else: ask the person first."],+ pitfalls: &["discardUnsaved loses changes that exist nowhere else: ask the person first.", "To close only your own board, use kicad_close_document {path}: several AI threads often share one KiCad.", "force:true past another thread's document needs a reason and loses that thread's board: ask it or the person first."], }, Verb { name: "kicad_click",@@ -2800,12 +2800,29 @@ fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> { fn close_kicad(_state: &mut State, args: &Value) -> Value { let force = args.get("force").and_then(Value::as_bool).unwrap_or(false); let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);+ // Wiki #139: closing KiCad closes every thread's board, so another AI thread's open document+ // stops the close unless force:true comes with a reason.+ invalidate();+ let in_use = find_windows(false, true).map(|(w, _)| documents_of_other_threads(&w)).unwrap_or_default();+ if !in_use.is_empty() && (!force || force_reason(args).is_none()) {+ let names: Vec<&str> = in_use.iter().filter_map(|r| r["name"].as_str()).collect();+ let (code, error) = if force {+ ("reason_required", "force:true closes documents other AI threads are using: say why in reason".to_string())+ } else {+ ("documents_in_use", format!("another AI thread has {} open; nothing was closed", names.join(", ")))+ };+ let mut v = fail(code, error, OTHERS_HINT);+ v["documentsInUse"] = json!(in_use);+ return v;+ } // Wiki #48: a close is the last moment these windows exist, so learn where they are // before the geometry is gone. A kill in particular denies KiCad its own save. remember_placement(); if force {- return match force_kill_kicad() {- Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),+ let killed = force_kill_kicad();+ forget_closed();+ return match killed {+ Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}, "forcedPast": in_use}), Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}), Err(e) => e, };@@ -2872,6 +2889,7 @@ fn close_kicad(_state: &mut State, args: &Value) -> Value { "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.", }); }+ forget_closed(); let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}}); if !discard_log.is_empty() { out["discard"] = json!(discard_log);@@ -3688,6 +3706,10 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value { let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({})); close_how = "graceful"; if !closed["success"].as_bool().unwrap_or(false) {+ if closed["errorCode"] == json!("documents_in_use") {+ return json!({"success": false, "errorCode": "documents_in_use", "close": closed,+ "_hint": "Another AI thread has a document open in this KiCad, so nothing was closed or killed. Ask that thread or the person before restarting KiCad."});+ } if closed["errorCode"] == json!("unsaved_changes") { return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});@@ -3724,6 +3746,50 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value { } +#[cfg(test)]+mod doc_user_tests {+ use super::Win;+ use kicad_core::doc_users;+ use serde_json::json;++ fn win(title: &str) -> Win {+ Win { hwnd: 1, title: title.into(), ..Default::default() }+ }++ /// Wiki #139: two AI threads share one KiCad; the asker's own check copy may close, the other+ /// thread's board (and its schematic, which shares the name) is reported, a document closed+ /// some other way drops out, and force needs a reason.+ #[test]+ fn another_threads_board_is_in_use_and_mine_is_not() {+ let p = std::env::temp_dir().join(format!("kb-doc-users-{}.json", std::process::id()));+ let _ = std::fs::remove_file(&p);+ doc_users::set_state_path(Some(p.clone()));+ doc_users::note("LED strip 500mm aiflow", &json!({"filePath": "C:/w/led-strip-500/led-strip-500.kicad_pcb"}));+ doc_users::note("noah", &json!({"filePath": "C:/w/am32/am32-esc-v1-aiflow-check.kicad_pcb"}));+ doc_users::note("gone thread", &json!({"filePath": "C:/w/old/closed-by-hand.kicad_pcb"}));+ let wins = [+ win("am32-esc-v1-aiflow-check \u{2014} PCB Editor"),+ win("*led-strip-500 \u{2014} PCB Editor"),+ win("led-strip-500 [led-strip-500/] \u{2014} Schematic Editor"),+ ];+ let _me = doc_users::set_asker("noah", "");+ let in_use = super::documents_of_other_threads(&wins);+ assert_eq!(in_use, vec![json!({"name": "led-strip-500", "openedBy": ["LED strip 500mm aiflow"]})]);+ assert!(doc_users::others("closed-by-hand", "noah").is_empty(), "a document no editor holds is forgotten");+ assert!(super::force_reason(&json!({"force": true})).is_none());+ assert_eq!(super::force_reason(&json!({"force": true, "reason": "the LED thread said it is done"})).as_deref(), Some("the LED thread said it is done"));+ {+ let _with_header_reason = doc_users::set_asker("noah", "close boards after the check");+ assert!(super::force_reason(&json!({"force": true})).is_some());+ }+ // the other thread itself sees nothing in its way+ let _led = doc_users::set_asker("LED strip 500mm aiflow", "");+ let theirs = super::documents_of_other_threads(&wins);+ assert_eq!(theirs, vec![json!({"name": "am32-esc-v1-aiflow-check", "openedBy": ["noah"]})]);+ let _ = std::fs::remove_file(&p);+ }+}+ #[cfg(test)] mod linked_refresh_tests { #[test]@@ -3873,6 +3939,9 @@ fn close_document(args: &Value) -> Value { let mut st = CloseState { discard: true, ..Default::default() }; let gone = close_one(&mut st, &win); let discarded = gone.is_none();+ if gone != Some(false) {+ forget_closed();+ } if gone == Some(false) { let mut v = fail("close_failed", format!("{} did not close", win.title), "dialogsSeen lists what is up; kicad_check_dialogs reads them."); v["dialogsSeen"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());@@ -3882,15 +3951,63 @@ fn close_document(args: &Value) -> Value { json!({"success": true, "data": {"closed": true, "path": path, "discarded": discarded}, "roundtripMs": started.elapsed().as_millis() as u64}) } +/// The name of every open board and schematic editor (its title's document name).+fn open_document_names(wins: &[Win]) -> Vec<String> {+ wins.iter()+ .filter(|w| w.owner == 0 && matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor))+ .filter_map(|w| crate::verbs_std_more::parse_title(&w.title).map(|t| t.0))+ .collect()+}++/// Drop the notes of documents no editor holds any more (a board and its schematic share one note).+fn forget_closed() {+ invalidate();+ // a failed window listing proves nothing closed: keep every note+ if let Ok((wins, _)) = find_windows(false, true) {+ kicad_core::doc_users::keep_only(&open_document_names(&wins));+ }+}++/// Wiki #139: the open documents another AI thread opened or drove, as [{name, openedBy}], for the+/// asker of this verb. Records of documents no longer open are dropped on the way.+fn documents_of_other_threads(wins: &[Win]) -> Vec<Value> {+ let names = open_document_names(wins);+ kicad_core::doc_users::keep_only(&names);+ let me = kicad_core::doc_users::asker().thread;+ let mut out: Vec<Value> = Vec::new();+ for n in names {+ let threads = kicad_core::doc_users::others(&n, &me);+ if !threads.is_empty() && !out.iter().any(|v| v["name"] == json!(n)) {+ out.push(json!({"name": n, "openedBy": threads}));+ }+ }+ out+}++/// force:true past another thread's document needs a reason (the verb's own or the caller's).+fn force_reason(args: &Value) -> Option<String> {+ let own = arg_any(args, &["reason", "forceReason"]).map(str::to_string);+ own.or_else(|| Some(kicad_core::doc_users::asker().reason)).filter(|r| !r.trim().is_empty())+}++const OTHERS_HINT: &str = "Another AI thread opened or used these documents and may still be working on them. Close your own board with kicad_close_document {path}. To close theirs too, ask that thread or the person first, then pass force:true with a reason.";+ fn close_all_documents(state: &mut State, args: &Value) -> Value { let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(false);- let (save, discard, scratch) = (flag("save"), flag("discardUnsaved"), flag("includeScratch"));+ let (save, discard, scratch, force) = (flag("save"), flag("discardUnsaved"), flag("includeScratch"), flag("force")); invalidate(); let wins = match find_windows(false, true) { Ok((w, _)) => w, Err(e) => return e, };+ let in_use = documents_of_other_threads(&wins);+ if force && !in_use.is_empty() && force_reason(args).is_none() {+ let mut v = fail("reason_required", "force:true closes documents other AI threads are using: say why in reason", OTHERS_HINT);+ v["documentsInUse"] = json!(in_use);+ return v;+ } let (mut closed, mut saved, mut kept) = (Vec::new(), Vec::new(), Vec::new());+ let mut forced: Vec<Value> = Vec::new(); for w in wins.iter().filter(|w| w.owner == 0 && matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor | WindowKind::SymbolEditor | WindowKind::FootprintEditor)) { let name = crate::verbs_std_more::parse_title(&w.title).map(|t| t.0).unwrap_or_else(|| w.title.clone()); let dirty = w.title.starts_with('*');@@ -3899,6 +4016,16 @@ fn close_all_documents(state: &mut State, args: &Value) -> Value { kept.push(json!({"name": name, "why": "scratch"})); continue; }+ // Wiki #139: another thread's board is neither saved nor closed without force+ let doc = matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor);+ if let Some(row) = in_use.iter().find(|r| doc && r["name"] == json!(name)) {+ let threads: Vec<&str> = row["openedBy"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();+ if !force {+ kept.push(json!({"name": name, "why": format!("opened by {}", threads.join(", ")), "openedBy": threads, "closeYours": "kicad_close_document {path}"}));+ continue;+ }+ forced.push(row.clone());+ } if dirty && !never_saved && save { match save_editor(state, w, None) { Ok(_) => saved.push(name.clone()),@@ -3917,7 +4044,16 @@ fn close_all_documents(state: &mut State, args: &Value) -> Value { _ => closed.push(name), } }- json!({"success": true, "closed": closed, "saved": saved, "keptOpen": kept})+ forget_closed();+ let mut out = json!({"success": true, "closed": closed, "saved": saved, "keptOpen": kept});+ if kept.iter().any(|k| k.get("openedBy").is_some()) {+ out["_hint"] = json!(OTHERS_HINT);+ }+ if !forced.is_empty() {+ out["forcedPast"] = json!(forced);+ out["forceReason"] = json!(force_reason(args));+ }+ out } // ---------------------------------------------------------------------------
rust/crates/kicad-core/src/doc_users.rsadded+273@@ -0,0 +1,273 @@+//! Which AI threads opened or drove each KiCad document (wiki #139).+//!+//! Several AI threads share one desktop KiCad. A thread that closes "everything" must not+//! close a board another thread is working on, so every verb that drives the live editor on+//! a named document leaves a note here: document -> {thread -> when}. `kicad_close_all_documents`+//! and `kicad_close` read it and keep another thread's document open unless the caller forces+//! it with a reason.+//!+//! Documents are keyed on their lower-cased file stem, because that is all KiCad's window+//! titles give (`led-strip-500 — PCB Editor`); a board and its schematic share a stem and so+//! share a key. The table is mirrored to `~/.adom/kicad-bridge-doc-users.json` so a bridge+//! restart does not forget who has what open. A note older than `MAX_AGE_SECS` no longer+//! counts, and a document that is not open any more is dropped whenever a guard looks.+//!+//! The asker of the verb being served lives in a thread-local: each request runs on its own+//! worker thread, while the process-wide `ab` caller can be overwritten by a read verb that+//! another thread sends at the same moment.++use serde_json::Value;+use std::cell::RefCell;+use std::collections::BTreeMap;+use std::path::{Path, PathBuf};+use std::sync::Mutex;+use std::time::{SystemTime, UNIX_EPOCH};++/// A note older than this (12 h) no longer protects a document.+pub const MAX_AGE_SECS: u64 = 12 * 3600;++/// The argument keys a verb names its document with.+const PATH_KEYS: &[&str] = &["filePath", "path", "project", "boardPath", "schematicPath", "pcbPath", "file"];+const DOC_EXTS: &[&str] = &["kicad_pcb", "kicad_sch", "kicad_pro"];++/// Who is asking for the verb being served on this worker thread.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct Asker {+ pub thread: String,+ pub reason: String,+}++thread_local! {+ static ASKER: RefCell<Option<Asker>> = const { RefCell::new(None) };+}++/// Restores the previous asker when dropped, so a composite verb's inner call does not leak.+pub struct AskerScope(Option<Asker>);++impl Drop for AskerScope {+ fn drop(&mut self) {+ let prev = self.0.take();+ ASKER.with(|a| *a.borrow_mut() = prev);+ }+}++/// Make `thread` the asker on this worker thread until the returned scope drops.+pub fn set_asker(thread: &str, reason: &str) -> AskerScope {+ let prev = ASKER.with(|a| a.borrow_mut().replace(Asker { thread: thread.to_string(), reason: reason.to_string() }));+ AskerScope(prev)+}++/// The asker of the verb being served here (empty when none was set).+pub fn asker() -> Asker {+ ASKER.with(|a| a.borrow().clone()).unwrap_or_default()+}++/// A thread name that identifies nobody: no header, the bridge itself, or a placeholder.+pub fn is_anonymous(thread: &str) -> bool {+ let t = thread.trim();+ t.is_empty() || t == "?" || t.eq_ignore_ascii_case(crate::progress::ANONYMOUS) || t == "kicad-bridge" || t == crate::ab::SELF_THREAD+}++/// The key of a document path (`C:/p/led-strip-500.kicad_pcb`) or of a title's name (`led-strip-500`).+pub fn doc_key(path_or_name: &str) -> Option<String> {+ let s = path_or_name.trim().replace('\\', "/");+ if s.is_empty() {+ return None;+ }+ let p = Path::new(&s);+ let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);+ match ext.as_deref() {+ Some(e) if DOC_EXTS.contains(&e) => p.file_stem().map(|x| x.to_string_lossy().to_lowercase()),+ _ if !s.contains('/') => Some(s.to_lowercase()),+ _ => None,+ }+}++/// The documents a verb's arguments name (board, schematic or project paths only).+pub fn doc_keys_in(args: &Value) -> Vec<String> {+ let mut out: Vec<String> = Vec::new();+ for k in PATH_KEYS {+ if let Some(s) = args.get(*k).and_then(Value::as_str) {+ let has_ext = Path::new(&s.replace('\\', "/")).extension().and_then(|e| e.to_str()).is_some_and(|e| DOC_EXTS.contains(&e.to_ascii_lowercase().as_str()));+ if let (true, Some(key)) = (has_ext, doc_key(s)) {+ if !out.contains(&key) {+ out.push(key);+ }+ }+ }+ }+ out+}++type Table = BTreeMap<String, BTreeMap<String, u64>>;++struct Store {+ table: Option<Table>,+ path: Option<PathBuf>,+}++static STORE: Mutex<Store> = Mutex::new(Store { table: None, path: None });++fn store() -> std::sync::MutexGuard<'static, Store> {+ STORE.lock().unwrap_or_else(|e| e.into_inner())+}++fn default_path() -> 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").join("kicad-bridge-doc-users.json")+}++/// Point the table at another file (tests); the next read comes from there.+pub fn set_state_path(path: Option<PathBuf>) {+ let mut s = store();+ s.path = path;+ s.table = None;+}++fn now() -> u64 {+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)+}++fn with_table<R>(write: bool, f: impl FnOnce(&mut Table) -> R) -> R {+ let mut s = store();+ let path = s.path.clone().unwrap_or_else(default_path);+ if s.table.is_none() {+ let loaded: Table = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+ s.table = Some(loaded);+ }+ let table = s.table.as_mut().expect("loaded above");+ let r = f(table);+ if write {+ if let Some(dir) = path.parent() {+ let _ = std::fs::create_dir_all(dir);+ }+ if let Ok(text) = serde_json::to_string(table) {+ let _ = std::fs::write(&path, text);+ }+ }+ r+}++/// Record that `thread` drove the documents `args` names. Anonymous callers leave no note.+pub fn note(thread: &str, args: &Value) {+ let keys = doc_keys_in(args);+ if keys.is_empty() || is_anonymous(thread) {+ return;+ }+ let t = now();+ with_table(true, |tab| {+ for k in keys {+ tab.entry(k).or_default().insert(thread.to_string(), t);+ }+ tab.retain(|_, users| {+ users.retain(|_, at| t.saturating_sub(*at) < MAX_AGE_SECS);+ !users.is_empty()+ });+ });+}++/// The other named threads that opened or drove this document recently, most recent first.+pub fn others(name: &str, me: &str) -> Vec<String> {+ let Some(key) = doc_key(name) else { return Vec::new() };+ let t = now();+ with_table(false, |tab| {+ let mut v: Vec<(&String, &u64)> = tab.get(&key).map(|u| u.iter().filter(|(th, at)| th.as_str() != me && !is_anonymous(th) && t.saturating_sub(**at) < MAX_AGE_SECS).collect()).unwrap_or_default();+ v.sort_by(|a, b| b.1.cmp(a.1));+ v.into_iter().map(|(th, _)| th.clone()).collect()+ })+}++/// Forget a document (it was closed).+pub fn forget(name: &str) {+ if let Some(key) = doc_key(name) {+ with_table(true, |tab| {+ tab.remove(&key);+ });+ }+}++/// Forget every document that is not among `open` (closed some other way, by hand or by a crash).+pub fn keep_only(open: &[String]) {+ let keys: Vec<String> = open.iter().filter_map(|n| doc_key(n)).collect();+ with_table(true, |tab| tab.retain(|k, _| keys.contains(k)));+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ static TEST_LOCK: Mutex<()> = Mutex::new(());++ fn fresh(tag: &str) -> (std::sync::MutexGuard<'static, ()>, PathBuf) {+ let g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());+ let p = std::env::temp_dir().join(format!("kicad-doc-users-{tag}-{}.json", std::process::id()));+ let _ = std::fs::remove_file(&p);+ set_state_path(Some(p.clone()));+ (g, p)+ }++ #[test]+ fn keys_come_from_document_paths_and_titles() {+ assert_eq!(doc_key("C:\\boards\\LED-Strip-500.kicad_pcb").as_deref(), Some("led-strip-500"));+ assert_eq!(doc_key("C:/p/x.kicad_sch").as_deref(), Some("x"));+ assert_eq!(doc_key("led-strip-500").as_deref(), Some("led-strip-500"));+ assert_eq!(doc_key("C:/p/notes.txt"), None);+ let keys = doc_keys_in(&json!({"filePath": "C:/a/b.kicad_pcb", "path": "C:/a/b.kicad_sch", "project": "C:/a/out.step", "x": "C:/a/c.kicad_pcb"}));+ assert_eq!(keys, vec!["b".to_string()]);+ }++ #[test]+ fn another_threads_document_is_reported_and_mine_is_not() {+ let (_g, p) = fresh("others");+ note("LED strip 500mm aiflow", &json!({"filePath": "C:/w/led-strip-500.kicad_pcb"}));+ note("noah check", &json!({"filePath": "C:/w/am32-esc-v1-aiflow-check.kicad_pcb"}));+ assert_eq!(others("led-strip-500", "noah check"), vec!["LED strip 500mm aiflow".to_string()]);+ assert!(others("am32-esc-v1-aiflow-check", "noah check").is_empty());+ assert!(others("led-strip-500", "LED strip 500mm aiflow").is_empty());+ // an anonymous asker sees every named thread as someone else+ assert_eq!(others("am32-esc-v1-aiflow-check", "?"), vec!["noah check".to_string()]);+ // it survives a bridge restart+ set_state_path(Some(p.clone()));+ assert_eq!(others("led-strip-500", "noah check"), vec!["LED strip 500mm aiflow".to_string()]);+ forget("led-strip-500");+ assert!(others("led-strip-500", "noah check").is_empty());+ let _ = std::fs::remove_file(&p);+ }++ #[test]+ fn anonymous_callers_leave_no_note_and_closed_documents_drop_out() {+ let (_g, p) = fresh("anon");+ note("?", &json!({"filePath": "C:/w/a.kicad_pcb"}));+ note("kicad-bridge", &json!({"filePath": "C:/w/a.kicad_pcb"}));+ assert!(others("a", "me").is_empty());+ note("t1", &json!({"filePath": "C:/w/a.kicad_pcb"}));+ note("t2", &json!({"filePath": "C:/w/b.kicad_sch"}));+ keep_only(&["b".to_string()]);+ assert!(others("a", "me").is_empty());+ assert_eq!(others("b", "me"), vec!["t2".to_string()]);+ let _ = std::fs::remove_file(&p);+ }++ #[test]+ fn an_old_note_no_longer_counts() {+ let (_g, p) = fresh("age");+ let old = now() - MAX_AGE_SECS - 5;+ std::fs::write(&p, json!({"a": {"t-old": old, "t-new": now()}}).to_string()).unwrap();+ set_state_path(Some(p.clone()));+ assert_eq!(others("a", "me"), vec!["t-new".to_string()]);+ let _ = std::fs::remove_file(&p);+ }++ #[test]+ fn the_asker_is_per_thread_and_restored_after_an_inner_call() {+ let _outer = set_asker("thread A", "close mine");+ {+ let _inner = set_asker("thread B", "");+ assert_eq!(asker().thread, "thread B");+ }+ assert_eq!(asker(), Asker { thread: "thread A".into(), reason: "close mine".into() });+ let other = std::thread::spawn(asker).join().unwrap();+ assert_eq!(other, Asker::default());+ }+}
rust/crates/kicad-core/src/lib.rs+1@@ -29,6 +29,7 @@ pub mod zones; pub mod ipc; pub mod uninstall; pub mod ab;+pub mod doc_users; pub mod windows_model; pub mod window_memory; pub mod progress;