← Commit history

0.2.49 source (not yet released, needs signing): licence read on Altium Designer 26.x (a title with a version and no licence tail is licensed; ensure_licensed no longer reports no_seat_available on a licensed Altium) and the Use License pixel fallback works through the bridge (screen space from the window rectangle; ab's direct API returns no shotId) (#19). No -R helper is spawned while a dialog other than the script Error box blocks Altium, so a save prompt can no longer turn into a stream of 'another instance is busy' notices; recover stops and names the dialog. build_board's project reopen and build_schematic's project rewrite read the project's members from Altium itself, save them before closing, and never close documents the project file does not list. The battery refuses to start over a running Altium. Battery on DESKTOP-BAR6S0F (AD 26.11.1), twice in a row: PASS 145, FAIL 0, SKIP 1 (a365_logout), uncovered 0.

Colby Knox ·eadc6b3953 ·5d ago ·parent 3834002
11 files changed +163−33
bridge-rs/src/instance.rs+13−1
@@ -194,6 +194,13 @@ fn licence_of(win: &Value) -> (Option<bool>, Option<String>) {         None if win.get("edition").and_then(|e| e.as_str()) == Some("Develop") => {             (Some(true), Some("Licensed (Develop reports no licence tail)".into()))         }+        // Altium Designer writes a tail only when it does NOT hold a seat ("- No License", "- Viewer"),+        // like Develop. AD 26.11.1 on a Develop licence titles itself "<doc> - Altium Designer (26.11.1)"+        // while every scripted edit runs (Altium refuses scripts without a licence), and reading that as+        // unknown sent ensure_licensed to click Use License on a licensed Altium (#19).+        None if win.get("adVersion").and_then(|v| v.as_str()).is_some() => {+            (Some(true), Some("Licensed (no licence tail)".into()))+        }         None => (None, None),     } }@@ -350,9 +357,14 @@ mod tests {         // ...but "- Viewer" on Develop means NOT licensed         let w = json!({"edition": "Develop", "licenceState": "Viewer"});         assert_eq!(licence_of(&w).0, Some(false));-        // Designer with no tail stays unknown+        // Designer with a version and no tail holds its seat (AD 26.11.1, #19)+        let w = json!({"edition": null, "adVersion": "26.11.1", "licenceState": null});+        assert_eq!(licence_of(&w), (Some(true), Some("Licensed (no licence tail)".to_string())));+        // ...a title with neither (starting up, or not parsed) stays unknown         let w = json!({"edition": null, "licenceState": null});         assert_eq!(licence_of(&w).0, None);+        // and a tail always decides, whatever the version+        assert_eq!(licence_of(&json!({"adVersion": "26.11.1", "licenceState": "No License"})).0, Some(false));         // Viewer / No License are unlicensed; anything else licensed         assert_eq!(licence_of(&json!({"licenceState": "No License"})).0, Some(false));         assert_eq!(licence_of(&json!({"licenceState": "Trial"})).0, Some(true));
bridge-rs/src/license.rs+56−7
@@ -38,7 +38,7 @@ fn page_automation_id(action: &str) -> Option<&'static str> {  /// UIA first (quiet), image-space click only if UIA cannot reach the control. fn click_by_name_then_pixels(hwnd: i64, name: &str, automation_id: Option<&str>, pixel: impl FnOnce() -> Value) -> Value {-    let mut args = json!({"hwnd": hwnd, "name": name, "role": "button"});+    let mut args = json!({"hwnd": hwnd, "name": name, "role": "button", "reason": "altium licence page: click the named button"});     if let Some(id) = automation_id {         args["automationId"] = json!(id);     }@@ -57,6 +57,29 @@ fn click_by_name_then_pixels(hwnd: i64, name: &str, automation_id: Option<&str>,     out } +/// The licence-page positions in `page_coords` are pixels of a 1400-px-wide capture of the maximised+/// main window (the safe frame ab hands back). Map one onto physical screen pixels through the+/// window's own rectangle.+fn page_to_screen(rect: &Value, x: i64, y: i64) -> Option<(i64, i64)> {+    let g = |a: &str, b: &str| rect.get(a).or_else(|| rect.get(b)).and_then(|v| v.as_i64());+    let (left, top, w) = (g("left", "x")?, g("top", "y")?, g("width", "w")?);+    if w < 200 { return None; }+    let k = w as f64 / 1400.0;+    Some((left + (x as f64 * k).round() as i64, top + (y as f64 * k).round() as i64))+}++/// The shotId of a desktop_screenshot_window reply, wherever ab nests it (top level, coordMap, data).+fn find_shot_id(v: &Value) -> Option<Value> {+    match v {+        Value::Object(m) => {+            if let Some(Value::String(id)) = m.get("shotId") { return Some(json!(id)); }+            m.values().find_map(find_shot_id)+        }+        Value::Array(a) => a.iter().find_map(find_shot_id),+        _ => None,+    }+}+ /// Drive the License Management page. Every call returns a capture so the caller can see the /// state and click anything else itself. pub fn license(args: &Value) -> Value {@@ -78,22 +101,34 @@ pub fn license(args: &Value) -> Value {         agent::call_no_autostart("run_process", &[("process", "Client:MyAccount".into()), ("params", "".into())], Duration::from_secs(20));     }     std::thread::sleep(Duration::from_secs(1));-    let win = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd}), &[], 20);+    // the pixel fallback clicks in THIS screenshot's coordinates, so it needs its shotId (#19: the+    // call carried no reason and came back without one, so the fallback never ran)+    let win = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd, "reason": "altium licence page: the frame the pixel fallback clicks in"}), &[], 30);     let mut frame = shot::shot_of_main();-    let shot_id = win.get("coordMap").and_then(|c| c.get("shotId")).or_else(|| win.get("shotId")).cloned();+    let shot_id = find_shot_id(&win);     if frame.is_object() {         frame["shotId"] = shot_id.clone().unwrap_or(Value::Null);         frame["coordMap"] = win.get("coordMap").cloned().unwrap_or(Value::Null);     }     let mut out = json!({"success": true, "licenceState": instance::licence_state().get("state"),         "shot": frame, "windowEtiquette": "background"});+    if shot_id.is_none() {+        out["pixelFallbackUnavailable"] = json!({"screenshotSuccess": win.get("success"), "error": win.get("error"),+            "errorCode": win.get("errorCode"), "keys": win.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>())});+    }     if let Some((x, y)) = page_coords(&action) {         let sid = shot_id.clone();+        let rect = instance::main_window().map(|w| instance::norm_rect(&w));         let r = click_by_name_then_pixels(hwnd, page_name(&action), page_automation_id(&action), move || {-            match sid {-                Some(id) if !id.is_null() =>-                    ab::call("desktop_click", json!({"space": "image", "shotId": id, "x": x, "y": y, "hwnd": hwnd}), &[], 20),-                _ => json!({"success": false, "error": "no shotId for the pixel fallback"}),+            let why = "altium licence page: the pixel fallback for a button UI Automation did not find";+            match (sid, rect.as_ref().and_then(|r| page_to_screen(r, x, y))) {+                (Some(id), _) if !id.is_null() =>+                    ab::call("desktop_click", json!({"space": "image", "shotId": id, "x": x, "y": y, "hwnd": hwnd, "reason": why}), &[], 20),+                // ab's direct API returns a bare image with no shotId (#19), so the bridge maps the page+                // position onto the window itself: screen = window origin + position x (window width / 1400)+                (_, Some((sx, sy))) =>+                    ab::call("desktop_click", json!({"space": "screen", "x": sx, "y": sy, "hwnd": hwnd, "reason": why}), &[], 20),+                _ => json!({"success": false, "error": "no shotId and no window rectangle for the pixel fallback"}),             }         });         out["windowEtiquette"] = r.get("windowEtiquette").cloned().unwrap_or(json!("foreground"));@@ -268,3 +303,17 @@ pub fn connect_workspace(_args: &Value) -> Value {     json!({"success": true, "picker": picker, "shot": shot, "windowEtiquette": "background",         "_hint": "The picker lists candidate workspaces; click the wanted row with ab desktop_click {space:'image', shotId, x, y}. It is a popup — it closes on focus loss."}) }++#[cfg(test)]+mod page_tests {+    use super::*;++    #[test]+    fn page_to_screen_maps_through_the_window_rect() {+        // the laptop's maximised AD 26.11.1 window: 2582 x 1550 physical px at (-11, -11), safe frame 1400 px+        let r = json!({"left": -11, "top": -11, "width": 2582, "height": 1550});+        assert_eq!(page_to_screen(&r, 1288, 178), Some((-11 + 2375, -11 + 328)));+        assert_eq!(page_to_screen(&json!({"x": 0, "y": 0, "w": 1400, "h": 840}), 700, 420), Some((700, 420)));+        assert_eq!(page_to_screen(&json!({"left": 0, "top": 0, "width": 10}), 5, 5), None);+    }+}
bridge-rs/src/lifecycle.rs+25
@@ -139,10 +139,31 @@ fn x2_exe() -> Option<String> {         .and_then(|a| a.iter().find_map(|p| p.get("commandLine").and_then(|s| s.as_str()).and_then(crate::instance::exe_from_command_line))) } +/// The first dialog on the main Altium window that a -R hand-off would bounce off: anything but the+/// script Error / X2 notice (a lone OK), which recover answers itself.+pub fn blocking_dialog() -> Option<Value> {+    crate::dialogs::scan_scoped(true).into_iter().find(|d| {+        let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").trim().to_string();+        let lone_ok = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1 && a[0].as_str().map(|x| x.eq_ignore_ascii_case("ok")).unwrap_or(false)).unwrap_or(false);+        !(lone_ok && (title.eq_ignore_ascii_case("error") || title.eq_ignore_ascii_case("x2")))+    })+}+ /// Spawn the X2 helper. Process spawning is NOT desktop-shaped, and ab's direct API refuses /// `shell_execute` anyway (412 — that verb is CLI-only, alongside pull_file/send_files), so the /// bridge owns the child directly: no shell, no quoting layer, and a real handle to wait on. fn spawn_helper(exe: &str, arg: &str) -> Result<std::process::Child, String> {+    // A -R hand-off cannot reach an Altium frozen behind a modal: the helper bounces off it with+    // "another instance is busy" on the user's screen. agent::call's auto-recover used to spawn one+    // per failing verb, so a single save prompt left mid-battery turned into fifteen minutes of those+    // notices (2026-10-16). The script Error box is the one dialog recover answers itself, so it does+    // not block; anything else (a save prompt, a reload confirm) does.+    if !arg.is_empty() {+        if let Some(d) = blocking_dialog() {+            return Err(format!("blocked_by_dialog: '{}' is open on Altium's main window; answer it first (altium_check_dialogs / altium_dismiss_dialogs)",+                               d.get("title").and_then(|t| t.as_str()).unwrap_or("?")));+        }+    }     let mut cmd = std::process::Command::new(exe);     #[cfg(windows)]     {@@ -678,6 +699,10 @@ fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {             steps.push(json!({"dialog": title, "pressed": "OK"}));         }     }+    if let Some(d) = blocking_dialog() {+        return json!({"success": false, "errorCode": "blocked_by_dialog", "dialog": d, "steps": steps,+            "_hint": "A dialog that is not the script Error box is open on Altium's main window (a save prompt, a reload confirm). recover never answers those, and every -R hand-off would bounce off it with an 'another instance is busy' notice on the user's screen. Read it (altium_check_dialogs), answer it with altium_dismiss_dialogs, then retry."});+    }     // 2. tell the editor to stop whatever unit it is holding (raises the busy notice → reap it).     //    EditScript:Stop ONLY acts when a SCRIPT DOCUMENT is the active document. With a board or a     //    library in front it is a silent no-op: recover answered success while the script stayed on
bridge-rs/src/main.rs+1−1
@@ -31,7 +31,7 @@ use tiny_http::{Header, Method, Response, Server}; /// Attached to every failed reply (see the /command handler). const REPORT_ISSUES: &str = "If this looks like an altium-bridge BUG (not a refusal you expected), report it: check for a duplicate with `adom-wiki issue list adom/altium-bridge`, then `adom-wiki issue create adom/altium-bridge --title \"<verb>: <one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON (and errorShot if present), bridgeVersion, the Altium version from altium_status, the OS>\"`. A relay, routing, toast or lifecycle problem belongs on adom/adom-bridge instead. Reports from real machines are how this bridge gets correct: three different Altium builds behaved three different ways in one weekend."; -pub const BRIDGE_VERSION: &str = "0.2.48";+pub const BRIDGE_VERSION: &str = "0.2.49";  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/routing.rs+53−17
@@ -808,35 +808,61 @@ fn same_file_name(a: &std::path::Path, b: &std::path::Path) -> bool {     } } +/// Altium's own view of a project: the documents it holds OPEN under that project, and those of them+/// the project FILE does not list (they joined in memory only). Closing the project over an unlisted,+/// modified member raises "Save changes?" on the user's screen.+fn project_members(prj: &std::path::Path) -> (Vec<String>, Vec<String>) {+    let dir = prj.parent().unwrap_or(std::path::Path::new("."));+    let listed = std::fs::read(prj).map(|b| listed_documents(&String::from_utf8_lossy(&b), dir)).unwrap_or_default();+    let name = prj.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+    let st = agent::call("state", &[], None, Duration::from_secs(30));+    let open: Vec<String> = st.get("data").and_then(|d| d.get("documents")).and_then(|d| d.as_array()).cloned().unwrap_or_default()+        .iter().filter(|d| d["project"].as_str().map(|p| p.eq_ignore_ascii_case(&name)).unwrap_or(false) && d["open"].as_bool() == Some(true))+        .filter_map(|d| d["path"].as_str().map(str::to_string)).collect();+    let unlisted = open.iter().filter(|o| !listed.iter().any(|l| same_file_name(l, std::path::Path::new(o.as_str())))).cloned().collect();+    (open, unlisted)+}++/// Save every open member of a project, then close them and the project: no prompt, nothing lost.+fn close_project_saved(prj: &str, open: &[String]) {+    for d in open {+        let _ = crate::surfaces::save(&json!({"filePath": d}));+    }+    for d in open.iter().map(|s| s.as_str()).chain(std::iter::once(prj)) {+        let _ = agent::call("close_document", &[("path", d.to_string())], None, Duration::from_secs(30));+    }+}+ /// A board built into a folder whose project already lists it (altium_build_schematic writes the /// project first) is created as a new document that joins the project and is then renamed onto the /// listed path: Altium holds the file twice, one entry greyed, and a second hidden view survives a-/// project close (#23). Save the project's sheets, boards and libraries, close them and the project,-/// reopen the project and show the board: one entry, one view. None when no project lists the board.-fn reopen_listing_project(pcb: &str) -> Option<String> {+/// project close (#23). Save every open member of that project, close them and the project, reopen it+/// and show the board: one entry, one view.+/// The members come from Altium's own view (the agent's state), not the file: a document that joined+/// the project in memory only is not in the file, and closing the project over it raised "Save+/// changes? battery.PcbDoc" mid-battery. When the project holds such a member the reopen is skipped+/// (the caller's documents are not this verb's to close). None when no project lists the board.+fn reopen_listing_project(pcb: &str) -> Option<Value> {     let pcb_p = std::path::Path::new(pcb);     let dir = pcb_p.parent()?;-    let (prj, docs) = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path())+    let (prj, listed) = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path())         .filter(|p| p.extension().map(|x| x.eq_ignore_ascii_case("prjpcb")).unwrap_or(false))         .find_map(|prj| {             let text = String::from_utf8_lossy(&std::fs::read(&prj).ok()?).to_string();             let docs = listed_documents(&text, dir);             docs.iter().any(|d| same_file_name(d, pcb_p)).then_some((prj, docs))         })?;-    const KINDS: [&str; 4] = ["schdoc", "pcbdoc", "schlib", "pcblib"];-    let docs: Vec<String> = docs.iter()-        .filter(|d| d.extension().map(|x| KINDS.contains(&x.to_string_lossy().to_lowercase().as_str())).unwrap_or(false))-        .map(|d| d.display().to_string()).collect();-    for d in &docs {-        let _ = crate::surfaces::save(&json!({"filePath": d}));-    }-    for d in docs.iter().chain(std::iter::once(&prj.display().to_string())) {-        let _ = agent::call("close_document", &[("path", d.clone())], None, Duration::from_secs(30));-    }     let prj_s = prj.display().to_string();+    let _ = listed;+    let (open, unlisted) = project_members(&prj);+    if !unlisted.is_empty() {+        return Some(json!({"project": prj_s, "reopened": false,+            "skipped": format!("the open project also holds documents its file does not list ({}); they are not this verb's to close", unlisted.join(", "))}));+    }+    close_project_saved(&prj_s, &open);     let _ = crate::support::agent_open(&prj_s, 90);     let _ = crate::support::agent_open(pcb, 90);-    Some(prj_s)+    Some(json!({"project": prj_s, "reopened": true, "closed": open})) }  pub fn build_board(args: &Value) -> Value {@@ -1162,8 +1188,18 @@ pub fn build_schematic(args: &Value) -> Value {     // written only when it changes: rewriting an OPEN project's file makes Altium ask to reload it     // (a modal that blocked the next verb, measured 2026-10-15)     if std::fs::read_to_string(&prj).ok().as_deref() != Some(text.as_str()) {-        // an open project closes first, so the new file opens clean instead of asking to reload-        if std::path::Path::new(&prj).exists() { agent::call("close_document", &[("path", prj.clone())], None, Duration::from_secs(60)); }+        // an open project closes first, so the new file opens clean instead of asking to reload; its+        // open members are saved and closed with it (closing it over a modified one raised "Save+        // changes?", then the rewrite raised "Reload?", mid-battery 2026-10-16). Documents that joined+        // it in memory only are the caller's: refuse rather than close them.+        if std::path::Path::new(&prj).exists() {+            let (open, unlisted) = project_members(std::path::Path::new(&prj));+            if !unlisted.is_empty() {+                return err("project_has_unlisted_documents", format!("{prj} is open in Altium with documents its file does not list: {}", unlisted.join(", ")),+                    "Save or close those documents (or the project) first: altium_save / altium_close_document, then call again. The bridge will not close documents it did not make.");+            }+            close_project_saved(&prj, &open);+        }         if let Err(e) = std::fs::write(&prj, &text) { return err("write_failed", format!("could not write {prj}: {e}"), ""); }     }     // the project open first, so the sheet the agent opens is the project's
bridge-rs/src/support.rs+3−3
@@ -59,9 +59,9 @@ pub fn seat_hints(mut out: Value, licence_state: Option<&str>, instance_count: u     }     if instance_count > 1 {         out["_seatHint"] = json!(format!(-            "{SEAT_RULE} {SEAT_SWAP_HOWTO} NOTE: Altium Designer Develop writes NO licence tail in \-             its title when it HOLDS the seat (only '- Viewer' when it does not), so a blank \-             licenceState on Develop does not mean unlicensed — try the verb."+            "{SEAT_RULE} {SEAT_SWAP_HOWTO} NOTE: Altium writes NO licence tail in its title when it \+             HOLDS the seat (Develop, and Altium Designer 26.x: only '- Viewer' / '- No License' when it \+             does not), so a blank licenceState does not mean unlicensed — try the verb."         ));     }     out
dev-skills/altium-bridge-test/run_verb_tests.py+8
@@ -310,6 +310,14 @@ def main():             sys.exit(f"REFUSED: {T} had keyboard/mouse input {idle} s ago (need {a.min_idle} s idle). Someone is using it; "                      "the battery would raise Altium over their work. Ask the user, then pass --user-is-away.")         print(f"{T} idle for {idle} s: ok to run")+    # The battery starts from a CLOSED Altium: phase 0 tests the closed state, and an Altium left open+    # holds the previous run's scratch project in memory, which turned build_board and build_schematic+    # into save and reload prompts mid-run (2026-10-16). Closing it is the user's call: their documents.+    if not a.only:+        inst = ab(T, TH, "altium_instances", {})+        if (inst.get("count") or len(inst.get("instances") or [])) > 0:+            sys.exit(f"REFUSED: Altium is already running on {T}. The battery starts from a closed Altium; save anything "+                     "that matters and close it (altium_close), then run again.")     home = "C:/Users/john"     r = ab(T, TH, "shell_execute", {"command": "cmd /c echo %USERPROFILE%"})     if r.get("stdout"):
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.48",+  "version": "0.2.49",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
page.json+1−1
@@ -4,7 +4,7 @@   "type": "app",   "title": "Altium - the Altium Bridge",   "brief": "Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.",-  "version": "0.2.48",+  "version": "0.2.49",   "hero": {     "type": "image",     "path": "screenshots/hero.png"
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.48+0.2.49
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.48",+  "version": "0.2.49",   "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",