app
Altium - the Altium Bridge
Public Made by Adomby adom
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.
main
Colby Knox
0.2.45 source (not yet released): save-into-missing-folder fix + saved check, sheet grows on append, board_shape/pcb_re…
23ad0df
6d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
//! Modal detection, classification and the STANDING REAPER — ported from the Python bridge's
//! `dialogs.py`. A dialog on Altium's main process FREEZES the resident script loop (the engine is
//! single-threaded), so "the agent stopped answering" and "a modal is up" are the same event.
//!
//! Scoped by PID via the instance snapshot, never by title: a title match once swept up four
//! "RemoteApp security warning" windows that had nothing to do with Altium (2026-09-01) — and
//! pressing buttons in someone else's application is exactly what this bridge must never do.
//! When an instance is selected, the sweep is scoped to it: with two Altiums running, an unscoped
//! sweep lets one AI thread see — and clear — a modal belonging to the other thread's Altium.
use crate::{ab, instance};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
// TThemedConfirmDialog: Altium's licence chooser ("Connect to Workspace" / "You are not currently
// using an Altium Designer license" — Choose a license | Continue). It was invisible to the scan
// until 2026-09-02, when John was left staring at it: "you should be handling this automatically".
// TFailedToRenewWarningForm: "License Renewal Failed" (the stranded-seat re-firer). UIA enumerates
// NO buttons on it, yet its OK is clickable by name — so it is admitted by class and cleared by
// the title rule, never by button count.
const DIALOG_CLASSES: [&str; 7] = ["TMessageForm", "#32770", "TForm", "TMessageDialog", "TForm_RunScriptParameters", "TThemedConfirmDialog", "TFailedToRenewWarningForm"];
/// title/body substring -> (category, safe default action or "" = none, meaning).
/// A safeAction of "" means the reaper must NEVER touch it (save prompts carry decisions).
const CATALOG: &[(&str, &str, &str, &str)] = &[
("another script executing", "script_busy", "OK",
"A -R script request arrived while the agent loop (or another script) was resident. Dismiss; route work through the agent."),
("continue execution?", "script_runtime_error", "Cancel",
"A script hit a runtime error. Cancel stops it cleanly; OK continues past the error."),
("undeclared identifier", "script_compile_error", "OK",
"The script failed to compile; the engine is not halted after OK."),
("unknown identifier", "script_compile_error", "OK",
"Compile error in RunScriptText text (statements only, no Var)."),
("error reading form", "script_form_load_error", "Cancel",
"A script form's .dfm references a class or property this engine does not know (e.g. 'Class TApplicationEvents not found'). Cancel aborts the form load cleanly; Ignore would half-load the form. Fires on open and again on each editor touch until the project is closed."),
("select item to run", "run_script_picker", "Cancel",
"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."),
("could not start vault explorer", "altium_extension_broken", "OK",
"Altium's own VaultExplorer extension fails to load at startup. NOT caused by the bridge. Informational — dismissing it costs nothing, and the standing reaper does so automatically."),
("file type not recognised", "unsupported_document", "OK",
"Altium was handed a path it does not open. From the bridge that means a bad open call (the bridge refuses unsupported extensions up front); at STARTUP it means Altium is restoring a document from the last session that it can no longer open. Safe to dismiss."),
("file type not recognized", "unsupported_document", "OK", "Same as 'not recognised' (US spelling)."),
("license maximum usage reached", "licence_seat_stranded", "OK",
"The seat is held by an instance that never released it — almost always an Altium that was KILLED rather than closed. The licence page will still show Seats 0/1, which looks free and is not. FIX: close every other Altium POLITELY (altium_close, never process_kill), then altium_license {action:'use_license'}. Waiting does not clear it."),
("has expired or been deactivated", "licence_expired", "OK",
"The licence (Develop entitlement or a lease) is dead server-side: Altium runs but refuses scripting and can wedge in a modal loop on close. The bridge cannot renew an entitlement - the human renews or re-activates it in the Altium workspace. NOTE: Develop shows NO title licence tail in this state either, so the absent-tail 'Licensed' inference is WRONG while this box is up (measured 2026-09-21)."),
("license renewal failed", "licence_seat_stranded", "OK",
"Altium could not renew the On-Demand lease. Same cause and fix as 'maximum usage reached': a killed instance is still holding the seat. This dialog RE-FIRES on a timer until the seat is reclaimed, so clearing it is not the fix."),
("cannot focus a disabled or invisible window", "altium_internal", "Send and Close",
"Altium's own crash-report dialog, usually raised when a window's state changed while it was focusing a document view. Its only button SENDS a report to Altium — try the title-bar X first if the user's data matters."),
("unsaved changes", "save_prompt", "",
"Names the document(s) at risk — READ IT. On a shared machine the document may belong to ANOTHER AI thread; Save preserves their work, Don't Save destroys it. Never default this."),
("license", "licence", "", "Licence prompt — read it; usually informational."),
("trial", "licence", "", "Trial notice."),
];
pub fn classify(title: &str, body: &str) -> (String, Option<String>, String) {
let hay = format!("{title} {body}").to_lowercase();
for (needle, cat, action, meaning) in CATALOG {
if hay.contains(needle) {
let act = if action.is_empty() { None } else { Some(action.to_string()) };
return (cat.to_string(), act, meaning.to_string());
}
}
("unknown".into(), None, "Not in the catalog — read the text and decide; never blind-dismiss.".into())
}
/// The painted message via UIA (Altium's TMessageForm has no Static children; WM_GETTEXT sees
/// only button labels, which is why classification must go through ab's desktop_ui_text).
fn dialog_text(hwnd: i64) -> String {
let r = ab::call("desktop_ui_text", json!({"hwnd": hwnd}), &[], 10);
let s = match r.get("text").or_else(|| r.get("texts")) {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" "),
_ => String::new(),
};
if !s.trim().is_empty() {
return s;
}
// Fallback: some dialogs (the script debugger's "Continue execution?" prompt, title "X2")
// paint their message only as Text-role control NAMES; ui_text sees nothing and the empty
// body defeated classification (measured 2026-09-02 during the StopServer probes, #7).
let fc = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "text"}), &[], 10);
fc.get("matches")
.and_then(|a| a.as_array())
.map(|a| a.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()))
.collect::<Vec<_>>().join(" "))
.unwrap_or_default()
}
fn buttons(hwnd: i64) -> Vec<String> {
let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "button"}), &[], 10);
button_names(&r)
}
/// The button labels out of ab's `desktop_find_controls` reply. ab returns them under `matches`
/// (each with `name`); the first port read `controls` and so saw NO buttons on ANY dialog — the
/// lone-OK acknowledgement rule could never fire, and the VaultExplorer startup warning reached
/// John's screen again on 2026-09-02 ("u have not solved this error yet. you MUST"). Title-bar
/// controls (Close/Minimize/Maximize) are dropped so a dialog whose only real button is OK still
/// reads as lone-OK, matching what the Python bridge's child-window enumeration returned.
pub fn button_names(reply: &Value) -> Vec<String> {
let arr = reply.get("matches").or_else(|| reply.get("controls")).and_then(|a| a.as_array());
arr.map(|a| {
a.iter()
.filter_map(|c| c.get("name").or_else(|| c.get("text")).and_then(|t| t.as_str()))
.map(|t| t.replace('&', "").trim().to_string())
.filter(|t| !t.is_empty())
.filter(|t| !matches!(t.to_lowercase().as_str(), "close" | "minimize" | "maximize" | "restore" | "system"))
.collect()
})
.unwrap_or_default()
}
/// Every dialog owned by an Altium process — classified, instance-scoped when one is selected.
pub fn scan() -> Vec<Value> {
scan_scoped(false)
}
/// `main_pid_only` keeps only dialogs owned by the selected/first MAIN instance's process —
/// the ones that freeze the agent (helper X2 dialogs are flagged, not blocking).
pub fn scan_scoped(main_pid_only: bool) -> Vec<Value> {
scan_impl(main_pid_only, instance::selected().and_then(|i| i.get("pid").and_then(|p| p.as_u64())))
}
fn scan_impl(main_pid_only: bool, sel_pid: Option<u64>) -> Vec<Value> {
let mut wins = instance::altium_windows();
if let Some(pid) = sel_pid {
wins.retain(|w| w.get("pid").and_then(|p| p.as_u64()) == Some(pid));
}
let main = wins.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main")).cloned();
let main_pid = main.as_ref().and_then(|m| m.get("pid").and_then(|p| p.as_u64()));
let main_disabled = main
.as_ref()
.map(|m| m.get("enabled").and_then(|b| b.as_bool()) == Some(false))
.unwrap_or(false);
let mut out = Vec::new();
for w in wins {
if w.get("kind").and_then(|k| k.as_str()) == Some("main") {
continue;
}
let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("");
let kind = w.get("kind").and_then(|k| k.as_str()).unwrap_or("other");
let pid = w.get("pid").and_then(|p| p.as_u64());
// Any enabled top-level window while the MAIN window is disabled IS the modal, whatever
// its class (measured: TForm_RunScriptParameters "Select Item To Run").
let modal_by_state = main_disabled
&& w.get("enabled").and_then(|b| b.as_bool()).unwrap_or(true)
&& pid == main_pid
&& !matches!(class, "TClientBarControl" | "TThemedNotificationPopup" | "TAnimatedNotificationControl");
let hwnd = match w.get("hwnd").and_then(|h| h.as_i64()) {
Some(h) => h,
None => continue,
};
let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();
let dialog_class = DIALOG_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(class))
|| matches!(kind, "dialog" | "script_error_modal")
|| modal_by_state;
// Altium's themed licence dialogs ("License Renewal Failed", the licence chooser) do
// not use the catalogued VCL classes, so a class allowlist let them reach John's screen
// (2026-09-02: "you should pre-empt all of these dialog boxes btw"). The BUTTON SET is
// the trustworthy signal: ask UIA for every titled, visible, non-main window and let a
// lone OK qualify it regardless of class. Untitled tool windows are skipped for cost.
if title.trim().is_empty() && !dialog_class {
continue;
}
let btns = buttons(hwnd);
let lone_ok = btns.len() == 1 && btns[0].eq_ignore_ascii_case("ok");
let title_known = classify(&title, "").0 != "unknown";
// 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);
let (mut category, mut safe_action, mut meaning) = classify(&title, &body);
if category == "unknown" && is_licence_chooser(&btns) {
category = "licence_chooser".into();
safe_action = Some("Choose a license".into());
meaning = "Altium's 'You are not currently using an Altium Designer license' prompt (Choose a license | Continue). Choose a license opens the License Management page; the reaper then presses Use License via UIA. 'Continue' would leave Altium in Viewer mode, where scripting is refused — never the right answer for a bridge user.".into();
}
if category == "unknown" && title.trim() == "X2" && btns.len() == 2
&& btns.iter().any(|b| b.eq_ignore_ascii_case("ok")) && btns.iter().any(|b| b.eq_ignore_ascii_case("cancel")) {
// the script debugger's "<error> at Line:N ... Continue execution?" box: its text is
// painted, so it scanned as unknown (seen 2026-10-15 after a bad altium_eval)
category = "script_runtime_error".into();
safe_action = Some("Cancel".into());
meaning = "A script hit a runtime error and Altium's debugger asks 'Continue execution?'. Cancel stops the script cleanly; the resident agent survives. Photograph it first: the error text is only in the picture.".into();
}
if category == "unknown" && title.to_lowercase().contains("renewal failed") {
category = "licence_seat_stranded".into();
safe_action = Some("OK".into());
meaning = "License Renewal Failed: the seat is leased to a session that is gone (an Altium that was killed, not closed). Dismissing is safe; it re-fires on a timer until the lease expires or the user signs out and back in.".into();
}
let owned_by_main = main_pid.is_some() && pid == main_pid;
if main_pid_only && !owned_by_main {
continue;
}
out.push(json!({"hwnd": hwnd, "pid": pid, "title": title, "className": class,
"body": body, "buttons": btns,
"category": category, "safeAction": safe_action, "meaning": meaning,
"ownedByMainInstance": owned_by_main,
"rect": w.get("rect").cloned().unwrap_or(Value::Null)}));
}
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();
l.iter().any(|b| b == "choose a license") && l.iter().any(|b| b == "continue")
}
/// Press a named button on a dialog through UIA (no focus steal, no cursor). When the button is
/// an acknowledgement (OK) and its UIA Invoke refuses - some Information boxes expose no pattern
/// (measured 2026-09-21 on "licence has expired or been deactivated") - fall back to a native
/// WM_CLOSE on the dialog: the same dismissal its titlebar X performs. Never for any other
/// button: closing a question box would silently answer it with its cancel path.
pub fn press(hwnd: i64, button: &str) -> Value {
let r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": button, "role": "button"}), &[], 15);
let ok = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false);
if !ok && button.eq_ignore_ascii_case("ok") && crate::win32::post_message_close(hwnd) {
return json!({"success": true, "clicked": button, "hwnd": hwnd, "via": "wm_close_fallback",
"note": "the OK exposed no UIA Invoke; dismissed with a native WM_CLOSE (same as its titlebar X)", "ab": r});
}
json!({"success": ok, "clicked": button, "hwnd": hwnd, "via": "uia", "ab": r})
}
/// Press a named button on every detected dialog. `only_titles` narrows it (e.g. just "Error").
pub fn dismiss(button: &str, only_titles: Option<&[String]>) -> Value {
let found = scan();
let mut pressed: Vec<Value> = Vec::new();
for d in &found {
// A SWEEP (no hwnd) never answers a question: save prompts and any dialog with more than one
// button are skipped unless the caller named them by title. Pass {hwnd, button} to answer one.
let cat = d.get("category").and_then(|c| c.as_str()).unwrap_or("unknown");
let nbtn = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len()).unwrap_or(0);
if only_titles.is_none() && (cat == "save_prompt" || nbtn > 1) {
pressed.push(json!({"title": d.get("title"), "skipped": "asks a question: answer it explicitly with {hwnd, button}"}));
continue;
}
let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("");
if let Some(filter) = only_titles {
if !filter.iter().any(|f| title.contains(f.as_str())) {
continue;
}
}
if let Some(hwnd) = d.get("hwnd").and_then(|h| h.as_i64()) {
let r = press(hwnd, button);
pressed.push(json!({"title": title, "hwnd": hwnd, "button": button,
"ok": r.get("success").cloned().unwrap_or(json!(false))}));
}
}
json!({"success": true, "detected": found.len(), "pressed": pressed,
"_hint": "John's rule: a dialog the USER has to dismiss is a defect in this bridge — pre-empt it, don't clean up after it."})
}
// ---- the standing reaper (John, 2026-09-01: "always pre-empt these from ever showing up") ----
//
// Runs continuously from bridge start and presses the catalog's SAFE action the moment a known
// nuisance appears — no verb needs to be in flight (Altium's own startup warnings appear when
// nothing else would ever catch them). HARD RULES: only catalog-safe actions — never `unknown`,
// never a save prompt, never anything naming a document; one press per hwnd; everything logged.
static REAPED: Mutex<Vec<Value>> = Mutex::new(Vec::new());
static REAPED_HWNDS: Mutex<Vec<i64>> = Mutex::new(Vec::new());
static REAPER_RUNNING: AtomicBool = AtomicBool::new(false);
/// The last lone-OK box titled "Error" the reaper pressed: {atMs, title, shot}. A DelphiScript
/// runtime error ("Undeclared identifier ...") is exactly this box, and pressing OK leaves the
/// script STOPPED ON A BREAKPOINT in Altium's script debugger: the agent loop is dead, Altium
/// refuses to close, and the caller used to see nothing but a timeout (found on AD 26.9.1,
/// 2026-09-20). agent::call watches this so the halt is reported and cleared, never silent.
static LAST_SCRIPT_ERROR: Mutex<Option<Value>> = Mutex::new(None);
/// Pictures of the last 8 boxes the reaper pressed (newest last): `altium_dialog_log {shots:true}`.
static REAPED_SHOTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());
pub fn reaped_shots() -> Vec<Value> {
REAPED_SHOTS.lock().map(|g| g.clone()).unwrap_or_default()
}
fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_millis() as u64).unwrap_or(0)
}
/// The script Error box reaped at or after `since_ms` (unix ms), if any.
pub fn script_error_since(since_ms: u64) -> Option<Value> {
LAST_SCRIPT_ERROR.lock().ok().and_then(|g| g.clone())
.filter(|e| e.get("atMs").and_then(|a| a.as_u64()).unwrap_or(0) >= since_ms)
}
/// Record a service event (close watchdog etc.) in the same ring `altium_dialog_log` shows.
pub fn note_event(mut ev: Value) {
ev["at"] = json!(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0));
if let Ok(mut g) = REAPED.lock() {
g.push(ev);
let len = g.len();
if len > 200 { g.drain(..len - 200); }
}
}
pub fn reaped_log(limit: usize) -> Vec<Value> {
REAPED
.lock()
.map(|g| g.iter().rev().take(limit).rev().cloned().collect())
.unwrap_or_default()
}
pub fn reap_once() -> usize {
let mut n = 0;
// The reaper is a background service: it sweeps EVERY Altium pid, deliberately ignoring the
// per-verb instance selection (mutating that global from this thread would race a mid-flight
// verb). Catalog-safe actions carry no decisions, so acting on another thread's nuisance
// dialog helps rather than harms.
let dialogs = scan_impl(false, None);
for d in dialogs {
let mut act = d.get("safeAction").and_then(|a| a.as_str()).map(str::to_string);
let mut cat = d.get("category").and_then(|c| c.as_str()).unwrap_or("unknown").to_string();
// Altium PAINTS its dialog messages: when even UIA text is empty, classification cannot
// see them. What CAN be trusted is the button set: a dialog whose ONLY button is OK
// carries NO DECISION — dismissing it can acknowledge, never choose. Every dialog that
// asks something has >= 2 buttons.
let labels: Vec<String> = d
.get("buttons")
.and_then(|b| b.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_lowercase()).collect())
.unwrap_or_default();
let lone_ok = labels == ["ok"];
if act.is_none() && lone_ok {
act = Some("OK".into());
cat = "single_ok_acknowledgement".into();
}
let act = match act {
Some(a) if cat != "unknown" && cat != "save_prompt" => a,
_ => continue,
};
if cat != "single_ok_acknowledgement" && labels.len() > 1 && !labels.contains(&act.to_lowercase()) {
continue; // the catalog's action is not even on this dialog — do not improvise
}
let hwnd = match d.get("hwnd").and_then(|h| h.as_i64()) {
Some(h) => h,
None => continue,
};
if REAPED_HWNDS.lock().map(|g| g.contains(&hwnd)).unwrap_or(true) {
continue;
}
// A lone-OK "Error" is how a script runtime error looks. Altium PAINTS the message, so
// photograph it BEFORE pressing OK: the picture is the only copy of the error text.
let title_str = d.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
let is_script_error = cat == "single_ok_acknowledgement" && title_str.trim().eq_ignore_ascii_case("error");
// Altium PAINTS its message text, so the picture taken here is the ONLY copy of what the box
// said. Until 0.2.34 only "Error" boxes were photographed: an "Information" box that explained
// why Altium refused to quit was pressed away five times in a row, unread (2026-09-21).
let mut err_shot = Value::Null;
{
let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 10);
if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) {
err_shot = json!(img);
}
}
if let Ok(mut g) = REAPED_SHOTS.lock() {
g.push(json!({"atMs": now_ms(), "title": title_str, "shot": err_shot.clone()}));
let len = g.len();
if len > 8 { g.drain(..len - 8); }
}
let ok = press(hwnd, &act).get("success").and_then(|b| b.as_bool()).unwrap_or(false);
if is_script_error {
if let Ok(mut g) = LAST_SCRIPT_ERROR.lock() {
*g = Some(json!({"atMs": now_ms(), "title": title_str, "shot": err_shot}));
}
}
let mut followup = Value::Null;
if ok && cat == "licence_chooser" {
// The chooser only opens the License Management page; the seat is claimed by the
// page's UseLicenseButton (UIA, invokable — measured 2026-09-01). Give the WebView2
// a moment to render, then press it on the SAME instance's main window.
std::thread::sleep(Duration::from_millis(2500));
let pid = d.get("pid").and_then(|p| p.as_u64());
let main_hwnd = instance::altium_windows().into_iter()
.find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main") && w.get("pid").and_then(|p| p.as_u64()) == pid)
.and_then(|w| w.get("hwnd").and_then(|h| h.as_i64()));
followup = match main_hwnd {
Some(mh) => {
let r = ab::call("desktop_ui_click", json!({"hwnd": mh, "automationId": "UseLicenseButton", "name": "Use License", "role": "button"}), &[], 45);
json!({"useLicense": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
"note": "if the seat is stranded (held by a killed Altium) this claim fails with 'License maximum usage reached' — the bridge keeps that dialog off the screen, but only a sign-out/sign-in or the lease expiring frees the seat"})
}
None => json!({"useLicense": false, "note": "no main window found for the chooser's pid"}),
};
}
if let Ok(mut g) = REAPED_HWNDS.lock() {
g.push(hwnd);
let len = g.len();
if len > 500 {
g.drain(..len - 500);
}
}
if let Ok(mut g) = REAPED.lock() {
g.push(json!({"at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0),
"title": d.get("title").cloned().unwrap_or(Value::Null), "category": cat,
"pressed": act, "ok": ok, "followup": followup,
"note": "pre-empted by the standing reaper — the user never had to deal with it"}));
let len = g.len();
if len > 200 {
g.drain(..len - 200);
}
}
n += 1;
}
n
}
/// Start the standing reaper. Idempotent.
pub fn start_reaper() -> bool {
if REAPER_RUNNING.swap(true, Ordering::SeqCst) {
return false;
}
std::thread::Builder::new()
.name("altium-dialog-reaper".into())
.spawn(|| loop {
let _ = std::panic::catch_unwind(|| reap_once());
std::thread::sleep(Duration::from_millis(1500));
})
.is_ok()
}
#[cfg(test)]
mod tests {
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
/// under `matches`, not `controls`. Reading the wrong key made the reaper blind.
#[test]
fn button_names_read_ab_matches_shape() {
let reply = json!({"count": 1, "hwnd": 69126, "matches": [
{"automationId": "WinXPButtons.TXPBitBtn", "invokable": true, "name": "OK", "nth": 0, "role": "Button", "value": "OK"}
]});
assert_eq!(button_names(&reply), vec!["OK".to_string()]);
// title-bar controls never count toward the lone-OK decision
let reply = json!({"matches": [{"name": "OK"}, {"name": "Close"}, {"name": "Minimize"}]});
assert_eq!(button_names(&reply), vec!["OK".to_string()]);
// a real decision dialog keeps all its buttons
let reply = json!({"matches": [{"name": "Save"}, {"name": "Don't Save"}, {"name": "Cancel"}]});
assert_eq!(button_names(&reply).len(), 3);
// legacy shape still parses
let reply = json!({"controls": [{"text": "&OK"}]});
assert_eq!(button_names(&reply), vec!["OK".to_string()]);
assert!(button_names(&json!({"success": false})).is_empty());
}
#[test]
fn catalog_classification() {
let (cat, act, _) = classify("Warning", "Could not start Vault Explorer because ...");
assert_eq!(cat, "altium_extension_broken");
assert_eq!(act.as_deref(), Some("OK"));
let (cat, act, _) = classify("Confirm", "Do you want to save changes to Board1.PcbDoc?");
assert_eq!(cat, "save_prompt");
assert!(act.is_none(), "a save prompt must never carry a safe action");
let (cat, act, _) = classify("Information", "License renewal failed. ...");
assert_eq!(cat, "licence_seat_stranded");
assert_eq!(act.as_deref(), Some("OK"));
let (cat, act, _) = classify("Something", "never seen before");
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");
}
}