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
//! 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));
    }
}