main
John Lauer 0.2.29 source: altium_install verb + detect.installVerb/hostAppOptionalVerbs; battery covers it; install skill starts w… 1b81b35 1mo ago
//! `altium_install`: install Altium Designer on this machine, staged, with the rules IN the verb.
//!
//! Written 2026-09-20 after an install on a fresh box where the AI driving it kept forgetting the
//! rules: it asked the human to "sign in" on a page that was already signed in (it never looked),
//! it left Release / Assign / Use License / Refresh for the human although those are ordinary
//! clicks, it never toasted him when it did need him, and it launched the installer while he was
//! away so the UAC prompt timed out. John: "build all of the rules into an install verb so you
//! stop screwing up and forgetting", "put in ai hints", "build that into your verbs now".
//!
//! THE ONE RULE: `needsHuman` may only ever name (1) a PASSWORD / 2FA, (2) the UAC prompt (secure
//! desktop, unreachable by any software), (3) clicks inside an ELEVATED window (UIPI blocks them
//! from a non-elevated bridge, and the bridge must never be elevated). Everything else the verb
//! does itself, and it LOOKS before it asks: every stage reads the real state first.

use crate::{ab, health, instance, license};
use serde_json::{json, Value};

/// Run a PowerShell snippet hidden; stdout trimmed, or None.
fn ps(script: &str) -> Option<String> {
    let mut cmd = std::process::Command::new("powershell");
    cmd.args(["-NoProfile", "-NonInteractive", "-Command", script]);
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
    }
    let out = cmd.output().ok()?;
    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}

fn s(args: &Value, k: &str) -> Option<String> {
    args.get(k).and_then(|v| v.as_str()).map(str::to_string).filter(|t| !t.is_empty())
}

/// The newest `AltiumDesignerSetup*.exe` in the user's Downloads, with its signature facts.
fn find_installer(explicit: Option<String>) -> Value {
    let script = match explicit {
        Some(p) => format!("$f = Get-Item -LiteralPath '{}' -ErrorAction SilentlyContinue", p.replace('\'', "''")),
        None => "$f = Get-ChildItem \"$env:USERPROFILE\\Downloads\" -Filter 'AltiumDesignerSetup*.exe' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1".to_string(),
    } + "; if ($f) { $s = Get-AuthenticodeSignature $f.FullName; @{path=$f.FullName; mb=[math]::Round($f.Length/1MB,2); status=\"$($s.Status)\"; signer=\"$($s.SignerCertificate.Subject)\"; version=$f.VersionInfo.FileVersion} | ConvertTo-Json -Compress } else { '{}' }";
    ps(&script).and_then(|t| serde_json::from_str::<Value>(&t).ok()).unwrap_or_else(|| json!({}))
}

/// Valid Authenticode signature by Altium. Anything else must never be run.
pub fn signature_ok(info: &Value) -> bool {
    info.get("status").and_then(|v| v.as_str()) == Some("Valid")
        && info.get("signer").and_then(|v| v.as_str()).map(|t| t.contains("CN=Altium LLC")).unwrap_or(false)
}

/// Altium is installed when the bridge can find its X2.EXE.
fn altium_installed() -> bool {
    crate::lifecycle::x2_exe_pub().is_some()
}

/// Toast the person at THIS machine and say truthfully whether it displayed.
fn toast(title: &str, body: &str) -> Value {
    let r = ab::call("notify_user", json!({"title": title, "body": body, "level": "warning", "scenario": "reminder",
        "buttons": [{"label": "On it"}], "reason": "altium_install needs the human for a step no software can do"}), &[], 20);
    let shown = r.get("action").and_then(|a| a.as_str()) == Some("displayed");
    json!({"displayed": shown, "error": r.get("error").cloned().unwrap_or(Value::Null),
        "_hint": if shown { "Shown on THIS machine. If the user sits at a different machine they will not see it: also toast them where they are (notify_user with NO --target reaches the machine your container is attached to; `--target <mesh machine>` answered target_not_found on 2026-09-20) AND tell them in chat." }
                 else { "The toast did NOT display. Do not claim you notified the user: tell them in chat, and toast them with notify_user (no --target) from your side." }})
}

const HUMAN_ONLY: &str = "needsHuman may only ever name a password/2FA, the UAC prompt, or clicks inside an ELEVATED window. Release, Assign, Use License, Refresh, Sign in buttons, save prompts and browser navigation are ordinary clicks: do them yourself (UIA first, then desktop_click with a fresh shotId), and LOOK at a screenshot before you ask the human for anything.";

pub fn install(args: &Value) -> Value {
    let stage = s(args, "stage").unwrap_or_else(|| "auto".into());
    let installed = altium_installed();
    let running = health::is_running();
    let stage = if stage == "auto" {
        if installed && running { "license" } else if installed { "launch_altium" } else if find_installer(s(args, "installerPath")).get("path").is_some() { "launch" } else { "preflight" }
    } else { stage.as_str() }.to_string();

    let mut out = match stage.as_str() {
        "preflight" => {
            let facts = ps("$c = Get-PSDrive C; $p = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()); $k = Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System'; @{freeGB=[math]::Round($c.Free/1GB); os=(Get-CimInstance Win32_OperatingSystem).Caption; bridgeElevated=$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator); uacOn=[bool]$k.EnableLUA; uacSecureDesktop=[bool]$k.PromptOnSecureDesktop; user=$env:USERNAME} | ConvertTo-Json -Compress")
                .and_then(|t| serde_json::from_str::<Value>(&t).ok()).unwrap_or_else(|| json!({}));
            let free = facts.get("freeGB").and_then(|v| v.as_f64()).unwrap_or(0.0);
            json!({"success": free >= 25.0, "stage": "preflight", "installed": installed, "facts": facts,
                "errorCode": if free >= 25.0 { Value::Null } else { json!("not_enough_disk") },
                "_hint": "Altium needs ~10 GB installed and 15-25 GB with libraries and installer temp. bridgeElevated is normally false and must STAY false: it only tells you which windows you will be able to click.",
                "_next": ["altium_install {stage:'get_installer'}"],
                "pitfalls": ["There is NO public installer URL: downloads.altium.com holds only legacy archives. The current installer is behind an altium.com sign-in.",
                             "Never use the installer's unattended switches: Altium's silent mode takes the account PASSWORD on the command line."]})
        }
        "get_installer" => {
            let info = find_installer(s(args, "installerPath"));
            if info.get("path").is_none() {
                json!({"success": false, "stage": "get_installer", "errorCode": "installer_not_found",
                    "error": "no AltiumDesignerSetup*.exe in this user's Downloads",
                    "_hint": "Get it with the user's altium.com session, on ANY machine, then copy the file here (send_files): 1) pup_open_window https://www.altium.com/products/downloads and pup_lower_os_window it at once. 2) pup_eval: signed in = zero `a.login-form` links and the buttons read 'Online Installer'. 3) If signed out: navigate to auth.altium.com, fill the account email, click Next, click the first `button.round-button` (Google). If Google asks for a password, THAT is the human step: raise the window (pup_raise_os_window), toast, and wait. 4) Signed in: click `a[data-id-in]` for the newest version INSIDE the page (pup_fetch_url sends no cookies and gets 401). 5) Verify the signature here with this stage again.",
                    "needsHuman": ["the Google/Altium PASSWORD, only if the browser session is not already signed in"],
                    "_next": ["altium_install {stage:'get_installer'}"]})
            } else if !signature_ok(&info) {
                json!({"success": false, "stage": "get_installer", "errorCode": "installer_signature_invalid", "installer": info,
                    "error": "the installer's Authenticode signature is not a Valid signature by Altium LLC: it will not be run",
                    "_hint": "Delete it and download again from altium.com. Never run an unsigned or differently signed installer."})
            } else {
                json!({"success": true, "stage": "get_installer", "installer": info,
                    "_hint": "Signature Valid, signer Altium LLC. Launch it only when the human is AT this machine: the UAC prompt auto-denies after about two minutes and the installer then exits.",
                    "_next": ["altium_install {stage:'launch'}"]})
            }
        }
        "launch" => {
            let info = find_installer(s(args, "installerPath"));
            if !signature_ok(&info) {
                return json!({"success": false, "stage": "launch", "errorCode": "installer_not_verified", "installer": info,
                    "_hint": "Run stage get_installer first; a missing or wrongly signed installer is never launched."});
            }
            if args.get("humanIsPresent").and_then(|b| b.as_bool()) != Some(true) {
                return json!({"success": false, "stage": "launch", "errorCode": "human_presence_required", "installer": info,
                    "error": "launching raises a UAC prompt that only a person can answer and that auto-denies after ~2 minutes",
                    "_hint": "Ask the user to be at this machine's screen (RustDesk counts), THEN call again with humanIsPresent:true. Launching into an empty room wastes the prompt (it did, 2026-09-20).",
                    "needsHuman": ["be at this machine's screen before the launch"]});
            }
            let path = info.get("path").and_then(|p| p.as_str()).unwrap_or("").replace('\'', "''");
            let _ = ps(&format!("Start-Process -FilePath '{path}'"));
            let t = toast("Altium installer: I need you at this machine NOW",
                "1) Yes on the UAC prompt (it auto-denies in ~2 min). 2) Next. 3) Accept the EULA (your agreement, not mine). 4) Sign in when the browser opens. 5) Keep the default features. 6) Install, then Finish. I will do everything after that myself.");
            json!({"success": true, "stage": "launch", "installer": info, "toast": t,
                "needsHuman": ["UAC: Yes (secure desktop: no software can click it)",
                               "the wizard: Next, accept the EULA, sign in in the browser, Install, Finish (the window is ELEVATED; UIPI blocks clicks from the non-elevated bridge)"],
                "_hint": "The human's part ENDS at Finish. After that everything is yours, including licensing. Poll stage 'wait'. Tell the user in chat as well as by toast, and check `toast.displayed` before you say you notified anyone.",
                "_next": ["altium_install {stage:'wait'}"],
                "pitfalls": ["Screenshots of the elevated installer WORK (desktop_screenshot_screen); only clicks are blocked. Look at it to tell the user which page they are on.",
                             "The EULA is the user's agreement: say plainly that it is theirs to accept."]})
        }
        "wait" => {
            let st = ps("@{consent=[bool](Get-Process consent -ErrorAction SilentlyContinue); installer=[bool](Get-Process | Where-Object { $_.Name -like 'AltiumDesignerSetup*' }); dir=(Test-Path 'C:\\Program Files\\Altium')} | ConvertTo-Json -Compress")
                .and_then(|t| serde_json::from_str::<Value>(&t).ok()).unwrap_or_else(|| json!({}));
            let consent = st.get("consent").and_then(|b| b.as_bool()).unwrap_or(false);
            let inst = st.get("installer").and_then(|b| b.as_bool()).unwrap_or(false);
            let hint = if altium_installed() { "Installed. Go straight to stage 'license': do NOT ask the user to sign in, the installer's browser sign-in normally carries over." }
                else if consent { "The UAC prompt is UP and unanswered. It auto-denies in ~2 minutes: get the human to it NOW (toast + chat)." }
                else if inst { "The installer is running (past UAC). desktop_screenshot_screen shows which page it is on." }
                else { "No installer process and nothing installed: the UAC prompt was denied or timed out. Stage 'launch' again, with the human present." };
            json!({"success": true, "stage": "wait", "installed": altium_installed(), "uacPromptUp": consent, "installerRunning": inst,
                "_hint": hint, "_next": [if altium_installed() { "altium_install {stage:'license'}" } else { "altium_install {stage:'wait'}" }]})
        }
        "launch_altium" => json!({"success": true, "stage": "launch_altium", "installed": true,
            "_hint": "Altium is installed but not running. altium_launch (cold start can exceed 2 minutes), then stage 'license'.",
            "_next": ["altium_launch", "altium_install {stage:'license'}"]}),
        "license" => {
            if !running {
                return json!({"success": false, "stage": "license", "errorCode": "altium_not_running", "_next": ["altium_launch"]});
            }
            let before = instance::licence_state();
            if before.get("licensed").and_then(|b| b.as_bool()) == Some(true) {
                return json!({"success": true, "stage": "license", "licensed": true, "licenceState": before.get("state"),
                    "_hint": "Already licensed. Done: altium_agent_start and drive.", "_next": ["altium_agent_start"]});
            }
            // DO IT, do not ask: Refresh, then Use License, then read the state again.
            let refreshed = license::license(&json!({"action": "refresh"}));
            std::thread::sleep(std::time::Duration::from_secs(4));
            let mut used = Value::Null;
            if instance::licence_state().get("licensed").and_then(|b| b.as_bool()) != Some(true) {
                used = license::license(&json!({"action": "use_license"}));
                std::thread::sleep(std::time::Duration::from_secs(5));
            }
            let after = instance::licence_state();
            let ok = after.get("licensed").and_then(|b| b.as_bool()) == Some(true);
            json!({"success": ok, "stage": "license", "licensed": ok, "licenceState": after.get("state"),
                "did": {"refresh": refreshed.get("clicked"), "useLicense": used.get("clicked")},
                "errorCode": if ok { Value::Null } else { json!("no_seat_available") },
                "_hint": if ok { "Licensed by the bridge (Refresh / Use License), no human involved. altium_agent_start and drive." }
                    else { "Still Viewer. READ the page before asking anyone: altium_license {action:'show'} returns a screenshot. 'Sign Out' visible = the user IS signed in, no password needed. 'There are no seats available for this license' = the account's seat is held by ANOTHER running Altium (one On-Demand seat serves every Altium, Designer and Develop alike): altium_close it on that machine (cleanly, a kill strands the seat), then run this stage again. Release / Assign / Use License / Refresh are ordinary clicks: do them, do not hand them to the user." },
                "needsHuman": if ok { json!([]) } else { json!(["a PASSWORD, only if the page shows 'Sign in' instead of 'Sign Out'"]) },
                "_next": [if ok { "altium_agent_start" } else { "altium_license {action:'show'}" }]})
        }
        other => json!({"success": false, "errorCode": "unknown_stage", "error": format!("unknown stage '{other}'"),
            "_hint": "stages, in order: preflight, get_installer, launch, wait, license (or omit stage: 'auto' picks the right one from the machine's state)"}),
    };
    out["rule"] = json!(HUMAN_ONLY);
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn only_a_valid_altium_signature_is_accepted() {
        assert!(signature_ok(&json!({"status": "Valid", "signer": "CN=Altium LLC, O=Altium LLC, L=La Jolla, S=California, C=US"})));
        assert!(!signature_ok(&json!({"status": "NotSigned", "signer": ""})));
        assert!(!signature_ok(&json!({"status": "Valid", "signer": "CN=Somebody Else"})));
        assert!(!signature_ok(&json!({"status": "HashMismatch", "signer": "CN=Altium LLC"})));
        assert!(!signature_ok(&json!({})));
    }
}