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