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.49 source (not yet released, needs signing): licence read on Altium Designer 26.x (a title with a version and no li…
eadc6b3
5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
//! More than one Altium can run at once (Designer beside Designer Develop, often driven by two
//! different AI threads). Everything here treats "the Altium" as a CHOICE, not a given — ported
//! from the Python bridge's `altium_detect.py`, where three single-instance assumptions were each
//! a live bug within an hour of running two Altiums (hardcoded exe path, first-main-window licence
//! gate, unscoped dialog sweep).
//!
//! All window/process facts come from ab (`process_list`, `desktop_enum_windows`); the bridge
//! re-implements no Win32.
use crate::ab;
use crate::health::parse_title;
use serde_json::{json, Value};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// The instance the CURRENT verb acts on. Set once per dispatch (like Python's
/// `agent.use_instance`); background services must not rely on it.
static SELECTED: Mutex<Option<Value>> = Mutex::new(None);
pub fn select(inst: Option<Value>) {
if let Ok(mut g) = SELECTED.lock() {
*g = inst;
}
}
pub fn selected() -> Option<Value> {
SELECTED.lock().ok().and_then(|g| g.clone())
}
/// Stable per-instance key for the agent workspace folder: the install folder name ("AD26",
/// "ADDevelop") survives a restart, which a pid does not. Same-install twins fall back to pid.
pub fn key_for(exe: Option<&str>, pid: u64) -> String {
if let Some(e) = exe {
let norm = e.replace('/', "\\");
let parts: Vec<&str> = norm.split('\\').filter(|q| !q.is_empty()).collect();
if parts.len() >= 2 {
return parts[parts.len() - 2].to_string();
}
}
format!("pid{pid}")
}
/// Executable path out of ab's reported command line (never a constant: Develop and Designer
/// live in different install folders, and a -R helper must target the SELECTED instance's image).
pub fn exe_from_command_line(cl: &str) -> Option<String> {
let cl = cl.trim();
if cl.is_empty() {
return None;
}
if let Some(rest) = cl.strip_prefix('"') {
return rest.split('"').next().map(str::to_string);
}
cl.split(' ').next().map(str::to_string)
}
// ---- cached window snapshot --------------------------------------------------
// Two ab round trips cost ~1 s and half a dozen helpers want the same answer inside one verb.
struct Snapshot {
at: Instant,
procs: Vec<Value>,
windows: Vec<Value>,
ab_unreachable: bool,
}
static SNAP: Mutex<Option<Snapshot>> = Mutex::new(None);
const SNAP_TTL: Duration = Duration::from_secs(2);
/// Was ab reachable on the freshest snapshot? (/health must tell "Altium not running" apart
/// from "cannot even ask".)
pub fn ab_unreachable() -> bool {
let _ = snapshot();
SNAP.lock().ok().and_then(|g| g.as_ref().map(|s| s.ab_unreachable)).unwrap_or(false)
}
fn snapshot() -> (Vec<Value>, Vec<Value>) {
if let Ok(g) = SNAP.lock() {
if let Some(s) = g.as_ref() {
if s.at.elapsed() < SNAP_TTL {
return (s.procs.clone(), s.windows.clone());
}
}
}
let pl = ab::call("process_list", json!({"nameFilter": "X2"}), &[], 15);
let ab_down = pl.get("errorCode").and_then(|c| c.as_str()) == Some("ab_unreachable");
let procs = pl.get("processes").and_then(|a| a.as_array()).cloned().unwrap_or_default();
let pids: Vec<u64> = procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect();
let windows = if pids.is_empty() {
Vec::new()
} else {
ab::call("desktop_enum_windows", json!({"pids": pids}), &[], 20)
.get("windows")
.and_then(|a| a.as_array())
.cloned()
.unwrap_or_default()
};
if let Ok(mut g) = SNAP.lock() {
*g = Some(Snapshot { at: Instant::now(), procs: procs.clone(), windows: windows.clone(), ab_unreachable: ab_down });
}
(procs, windows)
}
/// Every live X2.exe pid (main window or not) — the close watchdog needs "process alive,
/// window gone", which instances() alone cannot express.
pub fn x2_pids() -> Vec<u64> {
let (procs, _) = snapshot();
procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect()
}
pub fn invalidate_snapshot() {
if let Ok(mut g) = SNAP.lock() {
*g = None;
}
}
/// Normalise ab's rect (object or [l,t,r,b] array) to {left,top,width,height}.
pub fn norm_rect(w: &Value) -> Value {
match w.get("rect") {
Some(Value::Array(a)) if a.len() == 4 => {
let g = |i: usize| a[i].as_i64().unwrap_or(0);
json!({"left": g(0), "top": g(1), "width": g(2) - g(0), "height": g(3) - g(1)})
}
Some(Value::Object(o)) => {
if o.contains_key("width") {
Value::Object(o.clone())
} else {
let g = |k: &str| o.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
json!({"left": g("left"), "top": g("top"),
"width": g("right") - g("left"), "height": g("bottom") - g("top")})
}
}
_ => Value::Null,
}
}
/// Every visible top-level window owned by an X2.exe, classified like the Python bridge's
/// `altium_windows()`: kind = main | script_error_modal | dialog | other, mains first.
pub fn altium_windows() -> Vec<Value> {
let (_, wins) = snapshot();
let mut out: Vec<Value> = Vec::new();
for w in wins {
let visible = w.get("visible").and_then(|b| b.as_bool()).unwrap_or(true);
let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();
let class = w.get("className").and_then(|s| s.as_str()).unwrap_or("").to_string();
let rect = norm_rect(&w);
let tiny = rect.get("width").and_then(|v| v.as_i64()).unwrap_or(999) < 20
&& rect.get("height").and_then(|v| v.as_i64()).unwrap_or(999) < 20;
if !visible || tiny {
continue;
}
let mut row = json!({
"hwnd": w.get("hwnd").cloned().unwrap_or(Value::Null),
"pid": w.get("pid").cloned().unwrap_or(Value::Null),
"title": title, "className": class, "visible": visible,
"enabled": w.get("enabled").cloned().unwrap_or(json!(true)),
"rect": rect,
});
let kind = if let Some(p) = parse_title(&row["title"].as_str().unwrap_or("").to_string()) {
for k in ["edition", "adVersion", "document"] {
row[k] = p.get(k).cloned().unwrap_or(Value::Null);
}
row["licenceState"] = p.get("licenceState").cloned().unwrap_or(Value::Null);
"main"
} else if row["title"] == json!("X2") {
"script_error_modal"
} else if matches!(row["className"].as_str(), Some("TMessageForm") | Some("#32770")) {
"dialog"
} else {
"other"
};
row["kind"] = json!(kind);
out.push(row);
}
let order = |k: &str| match k {
"main" => 0,
"script_error_modal" => 1,
"dialog" => 2,
_ => 3,
};
out.sort_by_key(|w| order(w.get("kind").and_then(|k| k.as_str()).unwrap_or("other")));
out
}
/// Licence read from a main window's parsed title, with the two measured quirks:
/// "Viewer" counts as unlicensed, and Develop with NO tail is LICENSED (Develop only writes a
/// tail — "- Viewer" — when it does not hold the seat; 2026-09-01, ADD imported five parts while
/// the naive read reported it unlicensed).
fn licence_of(win: &Value) -> (Option<bool>, Option<String>) {
let st = win.get("licenceState").and_then(|s| s.as_str()).map(str::to_string);
match st {
Some(s) => {
let l = s.to_lowercase();
(Some(l != "no license" && l != "no licence" && l != "viewer"), Some(s))
}
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),
}
}
/// Every running Altium, each with the identity a caller needs to choose between them.
pub fn instances() -> Vec<Value> {
let (procs, _) = snapshot();
let exe_of = |pid: u64| -> Option<String> {
procs
.iter()
.find(|p| p.get("pid").and_then(|v| v.as_u64()) == Some(pid))
.and_then(|p| p.get("commandLine").and_then(|c| c.as_str()))
.and_then(exe_from_command_line)
};
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for w in altium_windows() {
if w.get("kind").and_then(|k| k.as_str()) != Some("main") {
continue;
}
let pid = match w.get("pid").and_then(|p| p.as_u64()) {
Some(p) if !seen.contains(&p) => p,
_ => continue,
};
seen.insert(pid);
let exe = exe_of(pid);
let (licensed, st) = licence_of(&w);
out.push(json!({
"pid": pid,
"key": key_for(exe.as_deref(), pid),
"edition": w.get("edition").and_then(|e| e.as_str()).unwrap_or("Designer"),
"adVersion": w.get("adVersion").cloned().unwrap_or(Value::Null),
"licenceState": st, "licensed": licensed,
"exe": exe, "hwnd": w.get("hwnd").cloned().unwrap_or(Value::Null),
"document": w.get("document").cloned().unwrap_or(Value::Null),
"title": w.get("title").cloned().unwrap_or(Value::Null),
}));
}
out.sort_by_key(|i| i.get("pid").and_then(|p| p.as_u64()).unwrap_or(0));
out
}
/// Pick the instance a verb should act on.
/// Ok(inst) — act on it. Err(refusal) — return the refusal verbatim (ambiguity must STOP here,
/// or one thread's verb lands in another thread's Altium; not-running is Err too, with its code).
pub fn resolve(selector: Option<&Value>) -> Result<Value, Value> {
let found = instances();
if found.is_empty() {
return Err(json!({"success": false, "error": "no Altium is running",
"errorCode": "altium_not_running", "instances": []}));
}
let sel = match selector {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.trim().to_lowercase(),
Some(v) => v.to_string().trim_matches('"').to_lowercase(),
};
if sel.is_empty() || sel == "auto" {
if found.len() == 1 {
return Ok(found[0].clone());
}
return Err(json!({"success": false,
"error": format!("{} Altium instances are running — say which one", found.len()),
"errorCode": "ambiguous_instance", "instances": found,
"_hint": "pass instance: a pid, a key (e.g. 'ADDevelop'), or a word from the edition/title (e.g. 'develop')"}));
}
for i in &found {
let pid = i.get("pid").and_then(|p| p.as_u64()).map(|p| p.to_string()).unwrap_or_default();
let key = i.get("key").and_then(|k| k.as_str()).unwrap_or("").to_lowercase();
if sel == pid || sel == key {
return Ok(i.clone());
}
}
for i in &found {
let hay = format!(
"{} {} {}",
i.get("edition").and_then(|e| e.as_str()).unwrap_or(""),
i.get("key").and_then(|k| k.as_str()).unwrap_or(""),
i.get("title").and_then(|t| t.as_str()).unwrap_or("")
)
.to_lowercase();
if hay.contains(&sel) {
return Ok(i.clone());
}
}
Err(json!({"success": false, "error": format!("no running Altium matches instance={sel:?}"),
"errorCode": "instance_not_found", "instances": found}))
}
/// The main window of the SELECTED instance, or the only one. Instance-awareness has to reach
/// this far down: `licence_state()` is built on it and every `_need_licensed` gate on that.
pub fn main_window() -> Option<Value> {
let mains: Vec<Value> = altium_windows()
.into_iter()
.filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main"))
.collect();
if mains.is_empty() {
return None;
}
if let Some(sel) = selected() {
if let Some(pid) = sel.get("pid").and_then(|p| p.as_u64()) {
for w in &mains {
if w.get("pid").and_then(|p| p.as_u64()) == Some(pid) {
return Some(w.clone());
}
}
}
}
Some(mains[0].clone())
}
/// Parsed from the selected instance's main window title (the cheapest truthful source).
pub fn licence_state() -> Value {
let mw = match main_window() {
Some(w) => w,
None => return json!({"running": false, "licensed": null, "state": null}),
};
let (licensed, st) = licence_of(&mw);
json!({"running": true, "licensed": licensed, "state": st,
"adVersion": mw.get("adVersion").cloned().unwrap_or(Value::Null),
"edition": mw.get("edition").cloned().unwrap_or(Value::Null),
"document": mw.get("document").cloned().unwrap_or(Value::Null),
"hwnd": mw.get("hwnd").cloned().unwrap_or(Value::Null),
"pid": mw.get("pid").cloned().unwrap_or(Value::Null)})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_from_install_folder_survives_restart() {
assert_eq!(key_for(Some(r"C:\Program Files\Altium\AD26\X2.EXE"), 1), "AD26");
assert_eq!(key_for(Some(r"C:\Program Files\Altium\ADDevelop\X2.EXE"), 2), "ADDevelop");
assert_eq!(key_for(Some("C:/Program Files/Altium/AD26/X2.EXE"), 3), "AD26");
assert_eq!(key_for(None, 4242), "pid4242");
}
#[test]
fn exe_out_of_quoted_and_bare_command_lines() {
assert_eq!(
exe_from_command_line(r#""C:\Program Files\Altium\AD26\X2.EXE" -restart"#).as_deref(),
Some(r"C:\Program Files\Altium\AD26\X2.EXE")
);
assert_eq!(exe_from_command_line(r"C:\X2.EXE -x").as_deref(), Some(r"C:\X2.EXE"));
assert_eq!(exe_from_command_line(""), None);
}
#[test]
fn develop_absent_tail_means_licensed() {
let w = json!({"edition": "Develop", "licenceState": null});
let (lic, st) = licence_of(&w);
assert_eq!(lic, Some(true));
assert!(st.unwrap().contains("Develop"));
// ...but "- Viewer" on Develop means NOT licensed
let w = json!({"edition": "Develop", "licenceState": "Viewer"});
assert_eq!(licence_of(&w).0, Some(false));
// 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));
}
#[test]
fn rect_array_and_object_forms_normalise() {
let a = json!({"rect": [10, 20, 110, 220]});
assert_eq!(norm_rect(&a), json!({"left": 10, "top": 20, "width": 100, "height": 200}));
let o = json!({"rect": {"left": 1, "top": 2, "right": 11, "bottom": 22}});
assert_eq!(norm_rect(&o), json!({"left": 1, "top": 2, "width": 10, "height": 20}));
let w = json!({"rect": {"left": 1, "top": 2, "width": 3, "height": 4}});
assert_eq!(norm_rect(&w)["width"], json!(3));
}
}