← Commit history

0.2.25 source: a document that did not open is a failure with the reason (view_only_no_author_seat)

John Lauer ·3fac261884 ·1mo ago ·parent f5348f8
8 files changed +84−20
bridge-rs/Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "altium-bridge"-version = "0.2.24"+version = "0.2.25" dependencies = [  "getrandom",  "serde",
bridge-rs/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.24"+version = "0.2.25" edition = "2021" description = "Adom altium-bridge — the bridge process, in Rust. Calls ab's verbs for the desktop; speaks file IPC to the resident DelphiScript agent inside Altium." license = "MIT"
bridge-rs/src/library.rs+10−10
@@ -28,7 +28,7 @@ fn sch_lib_verb(args: &Value, cmd: &str, params: Vec<(&str, String)>, tdef: u64)         return g;     }     if let Some(p) = s(args, "libraryPath") {-        support::agent_open(&p, 60);+        if let Some(e) = support::must_open(&p, 60) { return e; }     }     agent::call(cmd, &params, None, timeout_of(args, tdef)) }@@ -207,7 +207,7 @@ pub fn xcopy_footprint(args: &Value) -> Value {         return g;     }     if let Some(p) = s(args, "toLib") {-        support::agent_open(&p, 60);+        if let Some(e) = support::must_open(&p, 60) { return e; }     }     let to = s(args, "to").or_else(|| s(args, "from")).unwrap_or_default();     agent::call("xcopy_footprint",@@ -220,7 +220,7 @@ pub fn swap_footprint(args: &Value) -> Value {         return g;     }     if let Some(p) = s(args, "pcbPath") {-        support::agent_open(&p, 60);+        if let Some(e) = support::must_open(&p, 60) { return e; }     }     let mut r = agent::call("swap_footprint",         &[("footprint", s_or(args, "footprintName", "")), ("assigns", s_or(args, "assigns", "")),@@ -237,7 +237,7 @@ pub fn symbol_mode(args: &Value) -> Value {         return g;     }     if let Some(p) = s(args, "libraryPath") {-        support::agent_open(&p, 60);+        if let Some(e) = support::must_open(&p, 60) { return e; }     }     let mode = args.get("mode").and_then(|v| v.as_u64()).unwrap_or(0);     agent::call("run_process", &[("process", "Sch:ToggleComponentDisplayMode".into()), ("params", "Action=Mode0".into())], None, Duration::from_secs(30));@@ -290,7 +290,7 @@ pub fn place_on_sch(args: &Value) -> Value {         Some(l) if !l.is_empty() => l,         _ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),     };-    support::agent_open(&lib, 60);+    if let Some(e) = support::must_open(&lib, 60) { return e; }     let title = s_or(args, "title", ""); let number = s_or(args, "number", ""); let revision = s_or(args, "revision", "");     let drawn = s_or(args, "drawnBy", ""); let desc = s_or(args, "description", "");     let mut r = with_shot(agent::call("place_on_sch",@@ -345,7 +345,7 @@ pub fn add_symbol_art(args: &Value) -> Value {     const MIL: f64 = 39.3700787;     let kr = format!("{:.0},{:.0},{:.0},{:.0}", rect.0 * MIL, rect.1 * MIL, rect.2 * MIL, rect.3 * MIL);     let payload: String = polys.iter().map(|pl| pl.iter().map(|(x, y)| format!("{:.0},{:.0}", x * MIL, y * MIL)).collect::<Vec<_>>().join(" ")).collect::<Vec<_>>().join("\n");-    support::agent_open(&lib, 60);+    if let Some(e) = support::must_open(&lib, 60) { return e; }     let mut r = agent::call("add_symbol_art", &[("libref", name.clone()), ("kicadRect", kr), ("mode", s_or(args, "mode", "lines")), ("clear", if args.get("clear").and_then(|b| b.as_bool()) == Some(false) { "0".into() } else { "1".into() })], Some(&payload), timeout_of(args, 240));     r["_hints"] = json!([         format!("{} polylines drawn into {name} from the KiCad symbol's baked outline; altium_save {{filePath}} the SchLib, then altium_show_symbol to check the chip sits between the pin-name columns.", polys.len()),@@ -360,7 +360,7 @@ pub fn symbol_prims(args: &Value) -> Value {         Some(l) if !l.is_empty() => l,         _ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),     };-    support::agent_open(&lib, 60);+    if let Some(e) = support::must_open(&lib, 60) { return e; }     agent::call("symbol_prims", &[("libref", s_or(args, "symbolName", "")), ("models", if args.get("models").and_then(|b| b.as_bool()) == Some(true) { "1".into() } else { "0".into() })], None, timeout_of(args, 60)) } @@ -373,7 +373,7 @@ pub fn set_models(args: &Value) -> Value {         Some(l) if !l.is_empty() => l,         _ => return json!({"success": false, "error": "libraryPath (.SchLib) required"}),     };-    support::agent_open(&lib, 60);+    if let Some(e) = support::must_open(&lib, 60) { return e; }     let mut r = agent::call("set_models", &[("libref", s_or(args, "symbolName", "")), ("footprint", s_or(args, "footprint", ""))], None, timeout_of(args, 60));     r["_hints"] = json!(["Replaces ALL models (set_params footprint and add_models append). altium_save the SchLib afterwards; altium_symbol_prims shows the models list."]);     r@@ -427,7 +427,7 @@ pub fn place_on_pcb(args: &Value) -> Value {         Some(l) if !l.is_empty() => l,         _ => return json!({"success": false, "error": "libraryPath (.PcbLib) required"}),     };-    support::agent_open(&lib, 60);+    if let Some(e) = support::must_open(&lib, 60) { return e; }     with_shot(agent::call("place_on_pcb",         &[("pcb", s_or(args, "pcbPath", "")), ("footprint", s_or(args, "footprintName", ""))],         None, timeout_of(args, 90)))@@ -438,7 +438,7 @@ pub fn test_board(args: &Value) -> Value {         return g;     }     if let Some(p) = s(args, "libraryPath") {-        support::agent_open(&p, 60);+        if let Some(e) = support::must_open(&p, 60) { return e; }     }     let mut r = agent::call("test_board",         &[("footprint", s_or(args, "footprintName", "")), ("wMm", s_or(args, "wMm", "15")),
bridge-rs/src/main.rs+1−1
@@ -25,7 +25,7 @@ mod win32; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.24";+pub const BRIDGE_VERSION: &str = "0.2.25";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/support.rs+65−1
@@ -179,13 +179,77 @@ pub const DEMO_FOOTPRINT: &str = "SO-G3";  /// The dominant Python idiom `agent.call("open", {"path": p})` with best-effort semantics. pub fn agent_open(path: &str, timeout_s: u64) -> Value {-    agent::call("open", &[("path", path.to_string())], None, Duration::from_secs(timeout_s))+    let r = agent::call("open", &[("path", path.to_string())], None, Duration::from_secs(timeout_s));+    open_verdict(path, r, std::path::Path::new(path).is_file(), workspace_unavailable_seen())+}++/// True when this bridge session has seen Altium's "Workspace Unavailable" box (the reaper+/// acknowledges it at launch). Measured 2026-09-20: it means the Altium 365 workspace had no free+/// AUTHOR SEAT (another running Altium held it), so this instance dropped to view-only mode and+/// silently refused to open any NEW document while already-open ones kept working.+fn workspace_unavailable_seen() -> bool {+    dialogs::reaped_log(200).iter().any(|e| {+        e.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("workspace unavailable")).unwrap_or(false)+    })+}++fn is_project_path(path: &str) -> bool {+    let l = path.to_lowercase();+    [".prjpcb", ".prjscr", ".dsnwrk", ".libpkg", ".prjfpg", ".prjemb", ".prjmbd"].iter().any(|e| l.ends_with(e))+}++/// Altium's `open` answers ok:true even when the document never appeared (`shown:false`), and+/// every verb that then needs "the current document" failed with a misleading "open the SchLib+/// first". A document that did not open is a FAILURE, with the reason when we know it.+/// (Projects legitimately have no document view, so shown:false is normal for them.)+pub fn open_verdict(path: &str, mut r: Value, file_exists: bool, workspace_unavailable: bool) -> Value {+    if r.get("success").and_then(|b| b.as_bool()) != Some(true) || is_project_path(path) {+        return r;+    }+    let shown = r.get("data").and_then(|d| d.get("shown")).and_then(|b| b.as_bool());+    if shown != Some(false) {+        return r;+    }+    let (code, error, hint) = if !file_exists {+        ("file_not_found", format!("{path} does not exist on this machine"),+         "Stage the file on the desktop first (send_files lands in Downloads; move it where you want it), then retry.".to_string())+    } else if workspace_unavailable {+        ("view_only_no_author_seat", format!("Altium did not open {path}: this Altium is in VIEW-ONLY mode because the Altium 365 workspace has no free Author seat, and view-only refuses every new document"),+         "Altium raised 'Workspace Unavailable' at launch (altium_dialog_log); the warning triangle beside the workspace name says 'There are no Author seats available'. Documents that were ALREADY open still work, which is what makes this confusing. The usual cause is ANOTHER running Altium signed in to the same workspace (another machine, or Altium Designer beside Altium Develop) holding the seat. Fix: close that other Altium CLEANLY (altium_close on its machine; a kill strands the seat until the lease expires), then altium_close + altium_launch this one so it re-requests the seat. No sign-in is involved. Retrying the open will not help.".to_string())+    } else {+        ("document_not_opened", format!("Altium accepted the open command but {path} never became a document (shown:false)"),+         "altium_check_dialogs for a box Altium raised, altium_state to see what IS open, and check the file is a valid Altium document of the kind its extension claims.".to_string())+    };+    r["success"] = json!(false);+    r["errorCode"] = json!(code);+    r["error"] = json!(error);+    r["_hint"] = json!(hint);+    r+}++/// Open `path` for a verb that NEEDS it to be the current document: Some(failure) to return+/// as-is, None when it opened.+pub fn must_open(path: &str, timeout_s: u64) -> Option<Value> {+    let r = agent_open(path, timeout_s);+    if r.get("success").and_then(|b| b.as_bool()) == Some(true) { None } else { Some(r) } }  #[cfg(test)] mod tests {     use super::*; +    #[test]+    fn a_document_that_did_not_open_is_a_failure() {+        let ok = |shown: bool| json!({"success": true, "data": {"ok": true, "shown": shown}});+        assert_eq!(open_verdict(r"C:\x\a.SchLib", ok(true), true, false)["success"], json!(true));+        assert_eq!(open_verdict(r"C:\x\p.PrjPcb", ok(false), true, true)["success"], json!(true), "projects have no document view");+        assert_eq!(open_verdict(r"C:\x\a.SchLib", ok(false), true, false)["errorCode"], json!("document_not_opened"));+        assert_eq!(open_verdict(r"C:\x\a.SchLib", ok(false), true, true)["errorCode"], json!("view_only_no_author_seat"));+        assert_eq!(open_verdict(r"C:\x\a.SchLib", ok(false), false, true)["errorCode"], json!("file_not_found"));+        let failed = json!({"success": false, "errorCode": "agent_timeout"});+        assert_eq!(open_verdict(r"C:\x\a.SchLib", failed, true, false)["errorCode"], json!("agent_timeout"), "an earlier failure passes through");+    }+     #[test]     fn openable_refuses_unknown_extensions_cleanly() {         assert!(validate_openable(r"C:\x\board.PcbDoc").is_none());
bridge-rs/src/surfaces.rs+4−4
@@ -255,7 +255,7 @@ pub fn save(args: &Value) -> Value {         return g;     }     if let Some(p) = s(args, "filePath") {-        support::agent_open(&p, 60);+        if let Some(e) = support::must_open(&p, 60) { return e; }     }     // pre-empts "a command is currently active" (save-a-copy Warning)     agent::call("tx_clear", &[], None, Duration::from_secs(30));@@ -271,7 +271,7 @@ pub fn board_shape(args: &Value) -> Value {         return g;     }     if let Some(p) = s(args, "pcbPath") {-        support::agent_open(&p, 60);+        if let Some(e) = support::must_open(&p, 60) { return e; }     }     agent::call("board_shape",         &[("x0Mm", s_or(args, "x0Mm", "0")), ("y0Mm", s_or(args, "y0Mm", "0")),@@ -284,7 +284,7 @@ pub fn pcb_report(args: &Value) -> Value {         return g;     }     if let Some(p) = s(args, "path") {-        support::agent_open(&p, 60);+        if let Some(e) = support::must_open(&p, 60) { return e; }     }     agent::call("pcb_report",         &[("name", s_or(args, "name", "")), ("want", s_or(args, "want", ""))],@@ -585,7 +585,7 @@ pub fn demo_live(args: &Value) -> Value {     }     let t0 = support::now();     let doc = (b.doc)();-    support::agent_open(&doc, 90);+    if let Some(e) = support::must_open(&doc, 90) { return e; }     for (proc_, params) in b.setup {         agent::call("run_process", &[("process", proc_.to_string()), ("params", params.to_string())], None, Duration::from_secs(60));     }
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.24",+  "version": "0.2.25",   "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.",   "homepage": "https://wiki.adom.inc/adom/altium-bridge",   "docs": "https://wiki.adom.inc/adom/altium-bridge",
page.json+1−1
@@ -140,4 +140,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}