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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
//! Licence lifecycle. The License Management page is a WebView2 whose DOM IS in the a11y tree,
//! so the quiet lane works (John: "you keep turning on the cursor" → UIA FIRST, no cursor, no
//! foreground); the measured pixel table is only the fallback. The seat rules live in
//! `support.rs` — one On-Demand seat serves every Altium app on the machine.
use crate::support::{self, s_or};
use crate::{ab, agent, instance, shot};
use serde_json::{json, Value};
use std::time::Duration;
/// image-space on a 1400x840 safe capture of a maximised main window (measured 2026-08-29)
fn page_coords(action: &str) -> Option<(i64, i64)> {
Some(match action {
"refresh" => (1288, 178),
"use_license" => (1252, 388),
"start_trial" => (788, 607),
"sign_out" => (386, 179),
_ => return None,
})
}
/// The SAME actions by accessible name / automationId — the quiet lane (measured 2026-09-01).
fn page_name(action: &str) -> &str {
match action {
"refresh" => "Refresh",
"use_license" => "Use License",
"start_trial" => "Start Trial",
"sign_out" => "Sign Out",
other => other,
}
}
fn page_automation_id(action: &str) -> Option<&'static str> {
match action {
"use_license" => Some("UseLicenseButton"),
_ => None,
}
}
/// 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", "reason": "altium licence page: click the named button"});
if let Some(id) = automation_id {
args["automationId"] = json!(id);
}
let r = ab::call("desktop_ui_click", args, &[], 30);
if r.get("success").and_then(|b| b.as_bool()) == Some(true) {
let mut out = r;
out["quiet"] = json!(true);
out["windowEtiquette"] = json!("background");
out["lane"] = json!("uia");
return out;
}
let mut out = pixel();
out["lane"] = json!("image_click");
out["windowEtiquette"] = json!("foreground");
out["_uiaError"] = r.get("error").or_else(|| r.get("_hint")).cloned().unwrap_or(Value::Null);
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 {
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
return json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."});
}
let hwnd = lic.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);
let action = s_or(args, "action", "show");
// An unknown action used to do nothing and answer success:true (found 2026-09-21).
if !matches!(action.as_str(), "show" | "refresh" | "use_license" | "start_trial" | "sign_out") {
return json!({"success": false, "errorCode": "unknown_action", "action": action,
"error": format!("unknown licence action '{action}'"),
"_hint": "action is one of: show (default, returns the page screenshot), refresh, use_license, start_trial, sign_out. altium_ensure_licensed does the usual sequence in one call; altium_install {stage:'license'} does it after a fresh install."});
}
if agent::is_alive() {
agent::call_no_autostart("run_process", &[("process", "Client:LicenseSubscriptionNotification".into()), ("params", "".into())], Duration::from_secs(15));
// bring the License Management view up via its process launcher (background)
agent::call_no_autostart("run_process", &[("process", "Client:MyAccount".into()), ("params", "".into())], Duration::from_secs(20));
}
std::thread::sleep(Duration::from_secs(1));
// 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 = 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 || {
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"));
out["clicked"] = json!({"action": action, "lane": r.get("lane"), "result": r});
std::thread::sleep(Duration::from_secs(2));
instance::invalidate_snapshot();
out["licenceStateAfter"] = instance::licence_state().get("state").cloned().unwrap_or(Value::Null);
out["shotAfter"] = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd}), &[], 20);
}
out["_hint"] = json!("actions: show | refresh | use_license | start_trial | sign_out. Coordinates were measured on a maximised window; verify against shot.image before trusting a click. start_trial opens the browser workspace-creation flow (name, terms, Activate) — see the altium-install skill.");
out
}
/// Plain-language answer to 'what Altium licence am I on?'.
pub fn license_status(_args: &Value) -> Value {
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
return json!({"success": true, "running": false, "licensed": null,
"summary": "Altium is not running. altium_launch (or altium_ensure_licensed) first.",
"_hint": "altium_launch first."});
}
let state = lic.get("state").and_then(|st| st.as_str()).unwrap_or("Unknown").to_string();
let licensed = lic.get("licensed").and_then(|b| b.as_bool());
let page = license(&json!({"action": "show"}));
let mut seat_stuck = Value::Null;
let summary = if licensed == Some(true) {
format!("Licensed and in use: {state}.")
} else if matches!(state.to_lowercase().as_str(), "no license" | "no licence") {
seat_stuck = json!("possible");
"Signed in but NOT currently using a licence. If the License Management page shows 'no seats available', the single seat is still leased by a prior/crashed session — it frees on its own, or Sign Out + Sign In forces the server to release it, then Use license. altium_ensure_licensed automates the recovery.".to_string()
} else {
format!("Licence state: {state}.")
};
json!({"success": true, "running": true, "licensed": licensed, "licenceState": state,
"seatStuck": seat_stuck, "summary": summary, "shot": page.get("shot"),
"_hint": "Structured state from the window title; read shot.image for the product row (product, Trial/Full, days left, seat n/n, licence code). Trial info lives ONLY on the page."})
}
/// One call to get the user usable: launch if needed, claim a seat, or name the human step.
pub fn ensure_licensed(args: &Value) -> Value {
let mut steps: Vec<Value> = Vec::new();
let mut lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
let wait = args.get("launchWaitSec").and_then(|v| v.as_u64()).unwrap_or(240);
let lr = crate::lifecycle::launch(Duration::from_secs(wait));
steps.push(json!({"launch": lr.get("success"), "detail": lr.get("error")}));
instance::invalidate_snapshot();
lic = instance::licence_state();
}
if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {
return json!({"success": true, "alreadyLicensed": true, "licenceState": lic.get("state"),
"steps": steps, "summary": format!("Already licensed: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});
}
// try to use an available seat
let used = license(&json!({"action": "use_license"}));
steps.push(json!({"use_license": used.get("licenceStateAfter").or_else(|| used.get("licenceState"))}));
std::thread::sleep(Duration::from_secs(2));
instance::invalidate_snapshot();
let lic = instance::licence_state();
if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {
return json!({"success": true, "licenceState": lic.get("state"), "steps": steps,
"summary": format!("Acquired a seat: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});
}
// no seat: it is leased elsewhere (crash/other machine)
if args.get("allowSignInReset").and_then(|b| b.as_bool()) == Some(true) {
let so = license(&json!({"action": "sign_out"}));
steps.push(json!({"sign_out": so.get("licenceStateAfter")}));
return json!({"success": false, "errorCode": "signed_out_for_reset", "steps": steps,
"summary": "No seat was free, so I signed you out. Sign back in (browser flow) and the server will hand the seat back; then altium_ensure_licensed again.",
"needsHuman": "browser sign-in", "shot": so.get("shotAfter")});
}
if args.get("allowTrial").and_then(|b| b.as_bool()) == Some(true) {
let tr = license(&json!({"action": "start_trial"}));
steps.push(json!({"start_trial": "opened the trial/workspace flow in the browser"}));
return json!({"success": false, "errorCode": "trial_needs_browser", "steps": steps,
"summary": "Started the free-trial flow — finish it in the browser (name the workspace, accept terms, Activate), then altium_ensure_licensed again.",
"needsHuman": "browser trial activation", "shot": tr.get("shot")});
}
let st = license_status(&json!({}));
support::seat_hints(
json!({"success": false, "errorCode": "no_seat_available", "steps": steps,
"summary": "A licence exists but its single seat is in use (a prior or crashed session still holds it). Retry shortly, or call again with allowSignInReset:true to Sign Out + back in, which forces the server to release it.",
"seatStuck": true, "shot": st.get("shot")}),
instance::licence_state().get("state").and_then(|x| x.as_str()),
instance::instances().len())
}
/// Onboarding: nothing → working trial. Returns the ONE next action; human steps are named,
/// not faked (the elevated installer's EULA click sits behind the UIPI wall).
pub fn setup_trial(_args: &Value) -> Value {
let inst = support::first_install();
if inst.is_none() {
return json!({"success": false, "stage": "install",
"summary": "Altium Designer is not installed. Use the altium-install skill: it downloads AltiumDesignerSetup, verifies the signature and drives the wizard — but the installer self-elevates, so a non-elevated bridge cannot click past the EULA (UIPI wall); the user clicks Next there.",
"needsHuman": "installer EULA click", "nextAction": "run the altium-install skill"});
}
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
return json!({"success": true, "stage": "launch", "installed": true,
"summary": "Altium is installed but not running. This verb reports the next step, it does not launch: call altium_launch (or altium_ensure_licensed, which launches and claims a seat), then altium_setup_trial again.",
"needsHuman": Value::Null,
"nextAction": "altium_launch (or altium_ensure_licensed)"});
}
let ss = signin_status(&json!({}));
let signed_in = ss.get("account").map(|v| !v.is_null()).unwrap_or(false)
|| ss.get("user").map(|v| !v.is_null()).unwrap_or(false)
|| ss.get("signedIn").and_then(|b| b.as_bool()).unwrap_or(false);
if !signed_in && lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {
return json!({"success": false, "stage": "signin", "installed": true, "running": true,
"summary": "Altium is open but no account is signed in. Sign in at the account menu (browser flow) with a free Altium account, then start the trial.",
"needsHuman": "browser sign-in", "nextAction": "sign in, then altium_setup_trial again"});
}
if lic.get("licensed").and_then(|b| b.as_bool()) == Some(true) {
return json!({"success": true, "stage": "done", "licenceState": lic.get("state"),
"summary": format!("Already set up and licensed: {:?}.", lic.get("state").and_then(|st| st.as_str()).unwrap_or("?"))});
}
// signed in, not licensed -> start the trial
ensure_licensed(&json!({"allowTrial": true}))
}
/// Account + workspace connection as Altium itself reports it.
pub fn signin_status(_args: &Value) -> Value {
let lic = instance::licence_state();
let mut out = json!({"success": true, "running": lic.get("running"),
"licensed": lic.get("licensed"), "licenceState": lic.get("state")});
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
out["_hint"] = json!("altium_launch first.");
return out;
}
if agent::is_alive() {
let st = agent::call_no_autostart("run_process", &[("process", "Client:CurrentUser".into()), ("params", "".into())], Duration::from_secs(15));
out["currentUserProcess"] = st.get("data").cloned().unwrap_or(Value::Null);
}
// A whole-window UIA tree takes >45 s on this box (even scoped), so ask one exact question
// instead: is there a menu item literally named "Not Connected"?
let hwnd = lic.get("hwnd").cloned().unwrap_or(Value::Null);
let r = ab::call("desktop_find_control", json!({"hwnd": hwnd, "name": "Not Connected", "role": "menuitem"}), &[], 25);
let found = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)
&& (r.get("best").map(|b| !b.is_null()).unwrap_or(false) || r.get("control").map(|c| !c.is_null()).unwrap_or(false));
out["workspaceMenu"] = json!(if found { "Not Connected" } else { "(connected: the menu shows the workspace name)" });
out["workspaceConnected"] = json!(!found);
out["workspace"] = Value::Null;
out["_hint"] = json!("Signed-in account is on the License Management page (altium_license {action:'show'}). 'Not Connected' = no Altium 365 workspace attached: altium_connect_workspace opens the picker.");
out
}
/// Open Altium's workspace picker (the 'Not Connected' menu). Its list is a custom control with
/// no UIA children, so the verb returns a screenshot with coordMap for the caller to click.
pub fn connect_workspace(_args: &Value) -> Value {
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
return json!({"success": false, "errorCode": "altium_not_running"});
}
let hwnd = lic.get("hwnd").cloned().unwrap_or(Value::Null);
ab::call("desktop_ui_expand", json!({"hwnd": hwnd, "name": "Not Connected", "role": "menuitem"}), &[], 25);
let mut picker: Option<Value> = None;
for _ in 0..8 {
std::thread::sleep(Duration::from_millis(400));
instance::invalidate_snapshot();
picker = instance::altium_windows().into_iter()
.find(|w| w.get("className").and_then(|c| c.as_str()) == Some("TWorkspacesForm"));
if picker.is_some() {
break;
}
}
let picker = match picker {
Some(p) => p,
None => return json!({"success": false, "errorCode": "picker_not_open",
"_hint": "Altium may already be connected (menu text is the workspace name) — altium_signin_status."}),
};
let shot = ab::call("desktop_screenshot_window", json!({"hwnd": picker.get("hwnd")}), &[], 20);
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);
}
}