← Commit history

0.2.30 source: dialog scan sees button-bearing dialogs; close discardChanges; battery corrections

John Lauer ·59046f5b98 ·1mo ago ·parent 6dc3b9d
8 files changed +66−12
bridge-rs/Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "altium-bridge"-version = "0.2.29"+version = "0.2.30" dependencies = [  "getrandom",  "serde",
bridge-rs/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.29"+version = "0.2.30" 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/dialogs.rs+32−2
@@ -38,6 +38,12 @@ const CATALOG: &[(&str, &str, &str, &str)] = &[      "Altium could not resolve the -R RunScript target (unit did not compile, or wrong ProcName) and opened its Run Script picker. Cancel it, fix the script."),     ("another instance of", "second_instance_busy", "OK",      "A helper X2 could not hand its -R request to the running instance because that instance is busy (a modal is open)."),+    ("confirm save locally", "save_prompt", "",+     "Altium's close-time 'Confirm Save Locally for (N) Modified Documents' (Save All | Save None | OK | Cancel). It names the documents: READ the shot. Never auto-answered; altium_close {discardChanges:true} presses Save None for documents the caller owns."),+    ("confirm not saving", "save_prompt", "",+     "The follow-up to Save None: 'Confirm Not Saving (N) Modified Documents'. OK discards. Never auto-answered."),+    ("zoom value", "input_prompt", "Cancel",+     "A process asked for a number (PCB:Zoom with no value). A verb must never launch a process that prompts; Cancel is always safe here."),     ("do you want to save", "save_prompt", "",      "Unsaved changes on close. Decide: Yes saves, No discards. Never default this."),     ("save changes", "save_prompt", "", "Unsaved changes. Decide explicitly."),@@ -172,7 +178,12 @@ fn scan_impl(main_pid_only: bool, sel_pid: Option<u64>) -> Vec<Value> {         let btns = buttons(hwnd);         let lone_ok = btns.len() == 1 && btns[0].eq_ignore_ascii_case("ok");         let title_known = classify(&title, "").0 != "unknown";-        if !dialog_class && !lone_ok && !title_known && !is_licence_chooser(&btns) {+        // The allowlists above missed every box that mattered on 2026-09-20: 'Zoom Value' (OK|Cancel),+        // 'Confirm Save Locally' (Save All|Save None|OK|Cancel) and 'Confirm Not Saving' were all+        // dropped here, so check_dialogs said "none" while they blocked altium_close for minutes.+        // A titled top-level window that carries real DIALOG BUTTONS is a dialog, whatever its class.+        // Reported as category unknown/save_prompt, which the reaper NEVER presses.+        if !dialog_class && !lone_ok && !title_known && !is_licence_chooser(&btns) && !has_dialog_buttons(&btns) {             continue;         }         let body = dialog_text(hwnd);@@ -200,6 +211,13 @@ fn scan_impl(main_pid_only: bool, sel_pid: Option<u64>) -> Vec<Value> {     out } +/// Two or more of the labels every Windows/VCL dialog is built from. One alone is not enough (a+/// floating panel can carry a lone "Close").+pub fn has_dialog_buttons(btns: &[String]) -> bool {+    const WORDS: [&str; 12] = ["ok", "cancel", "yes", "no", "save all", "save none", "retry", "abort", "ignore", "continue", "apply", "close"];+    btns.iter().filter(|b| WORDS.contains(&b.trim().to_lowercase().as_str())).count() >= 2+}+ /// The licence chooser is recognisable by its BUTTONS even though its text is painted. fn is_licence_chooser(btns: &[String]) -> bool {     let l: Vec<String> = btns.iter().map(|b| b.to_lowercase()).collect();@@ -390,7 +408,7 @@ pub fn start_reaper() -> bool {  #[cfg(test)] mod tests {-    use super::{button_names, classify};+    use super::{button_names, classify, has_dialog_buttons};     use serde_json::json;      /// The exact reply shape ab gave for the VaultExplorer warning (2026-09-02): buttons live@@ -428,4 +446,16 @@ mod tests {         assert_eq!(cat, "unknown");         assert!(act.is_none());     }++    #[test]+    fn button_bearing_windows_are_dialogs() {+        let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();+        assert!(has_dialog_buttons(&v(&["Save All", "Save None", "OK", "Cancel"])));+        assert!(has_dialog_buttons(&v(&["OK", "Cancel"])));+        assert!(!has_dialog_buttons(&v(&["Close"])), "a floating panel's lone Close is not a dialog");+        assert!(!has_dialog_buttons(&v(&["Place", "Properties"])));+        assert_eq!(classify("Confirm Save Locally for (3) Modified Documents", "").0, "save_prompt");+        assert_eq!(classify("Confirm Not Saving (3) Modified Documents", "").0, "save_prompt");+        assert_eq!(classify("Zoom Value (0.001x..4x)", "").0, "input_prompt");+    } }
bridge-rs/src/main.rs+1−1
@@ -26,7 +26,7 @@ mod win32; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.29";+pub const BRIDGE_VERSION: &str = "0.2.30";  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/misc.rs+22
@@ -312,6 +312,28 @@ pub fn close(args: &Value) -> Value {                     "_seatWarning": format!("The licence seat is now STRANDED. {}", support::SEAT_STRANDED_FIX),                     "_hint": "force:true kills even when a dialog blocks the polite close (e.g. the 'script stopped on a breakpoint' Error that refuses WM_CLOSE)."});             }+            // Altium's save prompts. discardChanges:true = "every modified document is mine": press+            // Save None, then OK on the confirmation, and keep waiting for the exit. Without it, say+            // exactly what is unsaved instead of timing out.+            let is_save = |d: &Value| d.get("category").and_then(|c| c.as_str()) == Some("save_prompt");+            if dl.iter().any(is_save) {+                if args.get("discardChanges").and_then(|b| b.as_bool()) == Some(true) {+                    for d in dl.iter().filter(|d| is_save(d)) {+                        if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {+                            let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_lowercase();+                            let button = if title.contains("not saving") { "OK" } else { "Save None" };+                            let r = dialogs::press(hw, button);+                            dialogs::note_event(json!({"title": d.get("title"), "category": "save_prompt_discarded", "pressed": button,+                                "ok": r.get("success"), "note": "altium_close {discardChanges:true}: the caller declared the modified documents its own"}));+                        }+                    }+                    std::thread::sleep(Duration::from_millis(1500));+                    continue;+                }+                crate::shot::attach_dialog_shots(&mut dl);+                return json!({"success": false, "errorCode": "unsaved_changes", "dialogs": dl,+                    "_hint": "Altium will not close: documents are modified. dialogs[].shot NAMES them: read it. If EVERY one is yours (a scratch board, a test library, the agent script), call altium_close {discardChanges:true}: the bridge presses Save None and confirms. If ANY belongs to the user or another thread, altium_save it first or ask. Never force-kill over unsaved work."});+            }             crate::shot::attach_dialog_shots(&mut dl);             return json!({"success": false, "errorCode": "close_blocked_by_dialog", "dialogs": dl,                 "_hint": "Altium asked something on close (save?). Read dialogs[] and answer via altium_dismiss_dialogs {hwnd,button}, or pass force:true (with acceptSeatLoss:true — a kill strands the seat)."});
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.29",+  "version": "0.2.30",   "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",
dev-skills/altium-bridge-test/run_verb_tests.py+7−5
@@ -171,25 +171,27 @@ def matrix(w):     M["net_connections"] = dict(phase=3, **anyo("the battery board has no nets: named refusal is a pass", filePath=PD, net="GND"))     M["set_layer_visibility"] = dict(phase=3, **anyo("toggle Top Overlay", filePath=PD, layer="Top Overlay", visible=True))     M["pcb_report"] = dict(phase=3, **anyo("board report", path=PD, want="summary"))+    M["show_schematic"] = dict(phase=3, **ok(filePath=SD))     M["read_schematic"] = dict(phase=3, **ok(filePath=SD, path=SD))     M["schematic_data"] = dict(phase=3, **anyo("schematic data", filePath=SD))     M["cross_check"] = dict(phase=3, **anyo("sch vs pcb", board=PD, sheet=SD))     M["review_board"] = dict(phase=3, timeout=300, **anyo("design review", board=PD, sheet=SD, limit=5))     M["compile"] = dict(phase=3, **anyo("free documents have no project to compile: named refusal is a pass", filePath=SD))     M["run_drc"] = dict(phase=3, timeout=300, **anyo("DRC on the battery board", filePath=PD, limit=5))-    M["show_schematic"] = dict(phase=3, **ok(filePath=SD))     M["show_2d_board"] = dict(phase=3, **ok(filePath=PD))     M["show_3d_board"] = dict(phase=3, timeout=300, **ok(filePath=PD))     M["animate_view"] = dict(phase=3, **anyo("short orbit", preset="orbit", steps=3, pauseMs=50))-    M["show_project"] = dict(phase=3, **ref("no project given: must refuse by name"))+    M["show_project"] = dict(phase=3, **anyo("no path = Altium's bundled demo project, by design ('show me altium')"))     M["open_project"] = dict(phase=3, **ref("no project given: must refuse by name"))     M["run_erc"] = dict(phase=3, **anyo("ERC needs a compiled project: named refusal is a pass", filePath=SD))      # ---- phase 4: outputs --------------------------------------------------------------------     M["export_step"] = dict(phase=4, timeout=300, **anyo("STEP of the battery board", filePath=PD, output=D + "/battery.step"))-    M["export_bom"] = dict(phase=4, **anyo("BOM needs a project: named refusal is a pass", filePath=SD))-    M["export_gerber"] = dict(phase=4, **anyo("gerbers need an OutJob/project: named refusal is a pass", filePath=PD))-    M["run_outjob"] = dict(phase=4, **ref("no OutJob in the fixtures: must refuse by name"))+    M["export_bom"] = dict(phase=4, **anyo("missing OutJob: named refusal (the demo OutJob opens its .xlsx on screen)", outjobPath=D + "/missing.OutJob"))+    M["export_gerber"] = dict(phase=4, **anyo("missing OutJob: named refusal", outjobPath=D + "/missing.OutJob"))+    # NEVER the demo defaults here: the bundled OutJobs OPEN what they generate (an .xlsx app chooser+    # landed on the screen, 2026-09-20). A path that does not exist must be refused by name.+    M["run_outjob"] = dict(phase=4, **anyo("missing OutJob: named refusal", outjobPath=D + "/missing.OutJob"))     M["compile_intlib"] = dict(phase=4, **ref("no LibPkg in the fixtures: must refuse by name"))     M["extract_intlib"] = dict(phase=4, **ref("no IntLib in the fixtures: must refuse by name"))     M["import_kicad"] = dict(phase=4, **ref("no KiCad file in the fixtures: must refuse by name"))
page.json+1−1
@@ -140,4 +140,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}