master
John Lauer 1.0.3: 1.0.3: placement. kicad_placement_state, kicad_move_footprint (a batch of moves as one native undo step, refused… 0752ebc 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
//! The Adom Bridge (ab) callback client, ported from `handlers/ad_client.py` and
//! `caller_identity.py`.
//!
//! The bridge owns KiCad; ab owns Windows. Anything about windows themselves (a capture
//! with its owned popups and coordMap, the z-ordered window list, a graceful close with
//! ab's bookkeeping, a toast, a caption, a taskbar badge) is asked of ab over its loopback
//! direct API: `POST {ADOM_DIRECT_API_URL}/command`, falling back to the `host:port` in
//! `~/.adom/direct-api-port`. `X-Adom-Bridge-Token` (from `ADOM_BRIDGE_TOKEN`, re-read on
//! every call because a stale token 403s after an ab restart) is attribution only.
//!
//! Caller provenance: while serving an AI thread's verb the three `X-Adom-Caller-*`
//! headers are forwarded verbatim and `X-Adom-Caller-Delegate: kicad` is added, so the
//! user's Activity Log reads "chip-fetcher tab 3 (via kicad)". A call the bridge makes on
//! its own behalf (no caller known) sends `caller: {aiThread: "kicad bridge (self)"}` in
//! the args instead, never a stale identity and never nothing (ab refuses anonymous calls
//! with `caller_identity_required`).
//!
//! Every call carries its own timeout and NEVER hangs a verb: an unreachable ab yields
//! `AbError::Unavailable`, the verb reports `abUnavailable: true` and falls back to the
//! platform layer where one exists. After a transport failure the client backs off for a
//! few seconds so a verb that asks five times does not pay five connect timeouts.
//!
//! Reply shapes (wiki #121): the direct API hands a verb payload back either flat
//! (`{success, windows: [...]}`) or wrapped (`{success, output: "<json>"}` with the verb's
//! data under `output.data`). `AbReply::get` looks in every place a key can live.

use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};

use serde_json::{json, Map, Value};

/// What we name ourselves when forwarding a caller (the verb prefix ab knows us by).
pub const DELEGATE: &str = "kicad";
/// The `caller` block for a call the bridge makes on its own behalf.
pub const SELF_THREAD: &str = "kicad bridge (self)";

/// Default per-call timeout (Python `_TIMEOUT`).
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8);
/// TCP connect budget: ab is on loopback, so a connect that takes longer is a dead port.
#[cfg(feature = "net")]
const CONNECT_TIMEOUT: Duration = Duration::from_millis(1500);
/// After a transport failure, report Unavailable immediately for this long.
const BACKOFF: Duration = Duration::from_secs(3);

// ── Caller identity ───────────────────────────────────────────────────────────

/// Who asked for the verb we are serving. Self-asserted; attribution only, never a gate.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Caller {
    pub thread: String,
    pub container: String,
    pub reason: String,
}

static CALLER: Mutex<Option<Caller>> = Mutex::new(None);

/// Record the caller of the verb being served (cleared with `clear_caller`).
pub fn set_caller(c: Caller) {
    if let Ok(mut g) = CALLER.lock() {
        *g = if c.thread.trim().is_empty() && c.container.trim().is_empty() && c.reason.trim().is_empty() { None } else { Some(c) };
    }
}

/// Build a caller from a verb's args (`args.caller.aiThread` / `containerName`, the shape
/// ab actually relays) with the request headers as the fallback, and record it. The
/// explicit args form wins when both are present (Python `set_from_request`).
pub fn caller_from(args: &Value, header_thread: &str, header_container: &str, header_reason: &str) -> Caller {
    let mut c = Caller { thread: header_thread.to_string(), container: header_container.to_string(), reason: header_reason.to_string() };
    if let Some(cb) = args.get("caller").and_then(Value::as_object) {
        if let Some(t) = cb.get("aiThread").and_then(Value::as_str).filter(|s| !s.trim().is_empty()) {
            c.thread = t.to_string();
        }
        if let Some(n) = cb.get("containerName").and_then(Value::as_str).filter(|s| !s.trim().is_empty()) {
            c.container = n.to_string();
        }
    }
    if c.reason.is_empty() {
        if let Some(r) = args.get("reason").and_then(Value::as_str) {
            c.reason = r.to_string();
        }
    }
    c
}

/// `caller_from` with no headers, then `set_caller`.
pub fn set_caller_from_args(args: &Value) -> Caller {
    let c = caller_from(args, "", "", "");
    set_caller(c.clone());
    c
}

pub fn clear_caller() {
    if let Ok(mut g) = CALLER.lock() {
        *g = None;
    }
}

pub fn current_caller() -> Option<Caller> {
    CALLER.lock().ok().and_then(|g| g.clone())
}

/// The caller's thread name, or "" when the request was anonymous.
pub fn thread_name() -> String {
    current_caller().map(|c| c.thread).unwrap_or_default()
}

/// Headers for a callback made on the caller's behalf (Python `forward_headers`): empty
/// when no thread is known, else the three echoed plus our Delegate.
pub fn forward_headers() -> Vec<(String, String)> {
    let Some(c) = current_caller() else { return Vec::new() };
    if c.thread.trim().is_empty() {
        return Vec::new();
    }
    vec![
        ("X-Adom-Caller-Thread".into(), c.thread),
        ("X-Adom-Caller-Container".into(), c.container),
        ("X-Adom-Caller-Reason".into(), c.reason),
        ("X-Adom-Caller-Delegate".into(), DELEGATE.into()),
    ]
}

pub fn self_caller() -> Value {
    json!({"aiThread": SELF_THREAD, "containerName": "local"})
}

// ── Transport ─────────────────────────────────────────────────────────────────

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AbError {
    /// No direct API (env var and port file both missing), connection refused, timeout,
    /// or the net feature is off in this build.
    Unavailable(String),
    /// ab answered, with success:false.
    Refused { code: String, error: String },
    /// ab answered something we could not parse.
    Shape(String),
}

impl AbError {
    pub fn is_unavailable(&self) -> bool {
        matches!(self, AbError::Unavailable(_))
    }
    pub fn message(&self) -> String {
        match self {
            AbError::Unavailable(m) => format!("ab unavailable: {m}"),
            AbError::Refused { code, error } => {
                if code.is_empty() { format!("ab refused: {error}") } else { format!("ab refused ({code}): {error}") }
            }
            AbError::Shape(m) => format!("ab reply unreadable: {m}"),
        }
    }
}

impl std::fmt::Display for AbError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.message())
    }
}

/// A parsed ab reply: the raw envelope and the unwrapped verb payload.
#[derive(Clone, Debug, Default)]
pub struct AbReply {
    pub raw: Value,
    pub body: Value,
}

impl AbReply {
    /// A key wherever ab put it: `body.data[key]`, `body[key]`, then `raw[key]`.
    pub fn get(&self, key: &str) -> Option<&Value> {
        if let Some(v) = self.body.get("data").and_then(|d| d.get(key)) {
            return Some(v);
        }
        if let Some(v) = self.body.get(key) {
            return Some(v);
        }
        self.raw.get(key)
    }
    pub fn get_str(&self, key: &str) -> Option<&str> {
        self.get(key).and_then(Value::as_str).filter(|s| !s.is_empty())
    }
    pub fn get_u64(&self, key: &str) -> Option<u64> {
        self.get(key).and_then(num_u64)
    }
    pub fn get_bool(&self, key: &str) -> Option<bool> {
        self.get(key).and_then(Value::as_bool)
    }
}

/// Python `_from_ad` unwrapping: `output` (a JSON string or an object) when present,
/// else the envelope itself.
pub fn unwrap_payload(raw: &Value) -> Value {
    match raw.get("output") {
        Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok().filter(Value::is_object).unwrap_or_else(|| raw.clone()),
        Some(Value::Object(_)) => raw["output"].clone(),
        _ => raw.clone(),
    }
}

fn num_u64(v: &Value) -> Option<u64> {
    match v {
        Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f as u64)),
        Value::String(s) => s.trim().parse().ok(),
        _ => None,
    }
}

fn num_i64(v: &Value) -> Option<i64> {
    match v {
        Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
        Value::String(s) => s.trim().parse().ok(),
        _ => None,
    }
}

/// The loopback direct-API base: `ADOM_DIRECT_API_URL`, else `~/.adom/direct-api-port`.
pub fn base_url() -> Option<String> {
    if let Ok(u) = std::env::var("ADOM_DIRECT_API_URL") {
        let u = u.trim().trim_end_matches('/').to_string();
        if !u.is_empty() {
            return Some(u);
        }
    }
    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)?;
    let text = std::fs::read_to_string(home.join(".adom").join("direct-api-port")).ok()?;
    let t = text.trim();
    if t.is_empty() {
        return None;
    }
    if t.starts_with("http://") || t.starts_with("https://") {
        return Some(t.trim_end_matches('/').to_string());
    }
    if t.contains(':') {
        return Some(format!("http://{t}"));
    }
    t.parse::<u16>().ok().map(|p| format!("http://127.0.0.1:{p}"))
}

fn token() -> String {
    std::env::var("ADOM_BRIDGE_TOKEN").unwrap_or_default()
}

static LAST_FAILURE: Mutex<Option<Instant>> = Mutex::new(None);

fn in_backoff() -> bool {
    LAST_FAILURE.lock().ok().and_then(|g| *g).map(|t| t.elapsed() < BACKOFF).unwrap_or(false)
}

fn note_failure() {
    if let Ok(mut g) = LAST_FAILURE.lock() {
        *g = Some(Instant::now());
    }
}

fn note_success() {
    if let Ok(mut g) = LAST_FAILURE.lock() {
        *g = None;
    }
}

/// Cheap: is there a direct API to talk to at all (and are we not backing off)?
pub fn available() -> bool {
    base_url().is_some() && !in_backoff()
}

#[cfg(feature = "net")]
fn http_post(url: &str, headers: &[(String, String)], body: &str, timeout: Duration) -> Result<String, String> {
    let mut req = ureq::post(url)
        .config()
        .timeout_global(Some(timeout))
        .timeout_connect(Some(CONNECT_TIMEOUT))
        .http_status_as_error(false)
        .build()
        .header("Content-Type", "application/json")
        .header("Accept", "application/json");
    for (k, v) in headers {
        if !v.is_empty() {
            req = req.header(k.as_str(), v.as_str());
        }
    }
    let mut resp = req.send(body.as_bytes()).map_err(|e| e.to_string())?;
    resp.body_mut().with_config().limit(64 * 1024 * 1024).read_to_string().map_err(|e| e.to_string())
}

#[cfg(not(feature = "net"))]
fn http_post(_url: &str, _headers: &[(String, String)], _body: &str, _timeout: Duration) -> Result<String, String> {
    Err("ab client unavailable: the net feature is off in this build".into())
}

/// POST one verb to ab. `args` gains our own `caller` when no thread is being forwarded.
pub fn call(command: &str, args: Value, timeout: Duration) -> Result<AbReply, AbError> {
    let Some(base) = base_url() else {
        return Err(AbError::Unavailable("ADOM_DIRECT_API_URL is not set and ~/.adom/direct-api-port is missing (inbound-only spawn?)".into()));
    };
    // Cleanup and stop verbs are never skipped: a swallowed desktop_record_stop leaves the
    // recorder running and a zero-byte file (ConfRoomROG, 2026-09-11).
    let critical = matches!(command, "desktop_record_stop" | "desktop_record_window_stop" | "desktop_caption_hide" | "notify_dismiss" | "desktop_demo_panel" | "desktop_set_window_bounds");
    if in_backoff() && !critical {
        return Err(AbError::Unavailable("direct API failed a moment ago; backing off".into()));
    }
    let mut args = match args {
        Value::Object(m) => m,
        _ => Map::new(),
    };
    let fwd = forward_headers();
    if fwd.is_empty() && !args.contains_key("caller") {
        args.insert("caller".into(), self_caller());
    }
    let mut headers = fwd;
    let tok = token();
    if !tok.is_empty() {
        headers.push(("X-Adom-Bridge-Token".into(), tok));
    }
    let body = json!({"command": command, "args": Value::Object(args)}).to_string();
    let text = match http_post(&format!("{base}/command"), &headers, &body, timeout) {
        Ok(t) => t,
        Err(e) => {
            // A timeout means ab is busy (a 30 s screenshot, a recorder finalising), not gone:
            // only a connection-level failure starts the backoff.
            if !e.contains("timeout") && !e.contains("timed out") {
                note_failure();
            }
            return Err(AbError::Unavailable(e));
        }
    };
    note_success();
    let raw: Value = serde_json::from_str(&text).map_err(|e| AbError::Shape(format!("{e}: {}", text.chars().take(200).collect::<String>())))?;
    let ok = raw.get("success").and_then(Value::as_bool).or_else(|| raw.get("ok").and_then(Value::as_bool)).unwrap_or(false);
    if !ok {
        let error = raw.get("error").and_then(Value::as_str).unwrap_or("no error text").to_string();
        let code = raw.get("errorCode").and_then(Value::as_str).unwrap_or("").to_string();
        return Err(AbError::Refused { code, error });
    }
    let body = unwrap_payload(&raw);
    Ok(AbReply { raw, body })
}

// ── Typed helpers for the window group ────────────────────────────────────────

/// One row of `desktop_list_windows`.
#[derive(Clone, Debug, Default)]
pub struct AbWindow {
    pub hwnd: u64,
    pub title: String,
    pub class_name: String,
    pub kind: String,
    /// z-order position (0 = front) when ab reports it.
    pub z: Option<i64>,
    /// x, y, width, height.
    pub rect: (i32, i32, i32, i32),
    /// Owner handle when ab reports it (v2.1.12+), else 0.
    pub owner: u64,
    pub pid: u32,
    /// Executable base name when ab reports it (v2.1.12 `image`), lower case.
    pub image: String,
    pub minimized: bool,
    pub maximized: bool,
    pub hung: bool,
}

#[derive(Clone, Debug, Default)]
pub struct WindowList {
    pub windows: Vec<AbWindow>,
    /// Zero rows with degraded:true means the enumeration could NOT answer.
    pub degraded: bool,
}

fn rect_of(v: Option<&Value>) -> (i32, i32, i32, i32) {
    let Some(r) = v.and_then(Value::as_object) else { return (0, 0, 0, 0) };
    let g = |keys: &[&str]| keys.iter().find_map(|k| r.get(*k).and_then(num_i64)).unwrap_or(0) as i32;
    let x = g(&["x", "left"]);
    let y = g(&["y", "top"]);
    let mut w = g(&["width", "w"]);
    let mut h = g(&["height", "h"]);
    if w == 0 && r.contains_key("right") {
        w = g(&["right"]) - x;
    }
    if h == 0 && r.contains_key("bottom") {
        h = g(&["bottom"]) - y;
    }
    (x, y, w, h)
}

/// Parse a `desktop_list_windows` payload (flat or wrapped).
pub fn parse_window_list(reply: &AbReply) -> Result<WindowList, AbError> {
    let rows = reply.get("windows").and_then(Value::as_array).ok_or_else(|| AbError::Shape("desktop_list_windows reply has no windows[]".into()))?;
    let degraded = reply.get_bool("degraded").unwrap_or(false);
    let mut windows = Vec::with_capacity(rows.len());
    for w in rows {
        let Some(hwnd) = w.get("hwnd").and_then(num_u64).filter(|h| *h != 0) else { continue };
        let s = |k: &str| w.get(k).and_then(Value::as_str).unwrap_or("").to_string();
        windows.push(AbWindow {
            hwnd,
            title: s("title"),
            class_name: s("className"),
            kind: s("kind"),
            z: w.get("z").and_then(num_i64),
            rect: rect_of(w.get("rect")),
            owner: w.get("owner").and_then(num_u64).unwrap_or(0),
            pid: w.get("pid").and_then(num_u64).unwrap_or(0) as u32,
            image: s("image").to_ascii_lowercase(),
            minimized: w.get("minimized").and_then(Value::as_bool).unwrap_or(false),
            maximized: w.get("maximized").and_then(Value::as_bool).unwrap_or(false),
            hung: w.get("hung").and_then(Value::as_bool).unwrap_or(false),
        });
    }
    Ok(WindowList { windows, degraded })
}

/// Every top-level window with z-order (Python `kicad_windows._from_ad`).
pub fn desktop_list_windows() -> Result<WindowList, AbError> {
    let r = call("desktop_list_windows", json!({}), Duration::from_secs(6))?;
    parse_window_list(&r)
}

/// One owned popup returned alongside a parent capture.
#[derive(Clone, Debug, Default)]
pub struct PopupShot {
    pub hwnd: u64,
    pub title: String,
    pub full_path: String,
    pub safe_path: String,
    pub shot_id: String,
    pub coord_map: Value,
    pub rect: Value,
}

/// A `desktop_screenshot_window` capture in its file form.
#[derive(Clone, Debug, Default)]
pub struct Shot {
    pub hwnd: u64,
    /// Host-side paths (the bridge runs on the desktop, so it can read them).
    pub full_path: String,
    pub safe_path: String,
    pub local_full_path: Option<String>,
    pub shot_id: String,
    pub coord_map: Value,
    pub full_width: u64,
    pub full_height: u64,
    pub safe_width: u64,
    pub safe_height: u64,
    pub full_bytes: u64,
    pub safe_bytes: u64,
    pub source: String,
    pub owned_popup_count: u64,
    pub popups: Vec<PopupShot>,
    /// The raw popup entries, for callers that want every field ab returned.
    pub screenshots: Value,
}

impl Shot {
    /// The Python `_ab_capture` shape plus the popups.
    pub fn to_json(&self) -> Value {
        let mut v = json!({
            "success": true,
            "savedTo": self.full_path,
            "fullPath": self.full_path,
            "capturedBy": "ad:desktop_screenshot_window",
        });
        if !self.safe_path.is_empty() {
            v["safePath"] = json!(self.safe_path);
        }
        if let Some(l) = &self.local_full_path {
            v["localFullPath"] = json!(l);
        }
        if !self.shot_id.is_empty() {
            v["shotId"] = json!(self.shot_id);
        }
        if !self.coord_map.is_null() {
            v["coordMap"] = self.coord_map.clone();
        }
        if self.full_width > 0 {
            v["fullWidth"] = json!(self.full_width);
            v["fullHeight"] = json!(self.full_height);
        }
        if self.safe_width > 0 {
            v["safeWidth"] = json!(self.safe_width);
            v["safeHeight"] = json!(self.safe_height);
        }
        let bytes = if self.safe_bytes > 0 { self.safe_bytes } else { self.full_bytes };
        if bytes > 0 {
            v["sizeKB"] = json!((bytes as f64 / 1024.0 * 10.0).round() / 10.0);
        }
        v["ownedPopupCount"] = json!(self.owned_popup_count);
        if !self.popups.is_empty() {
            v["screenshots"] = self.screenshots.clone();
            v["popups"] = json!(self.popups.iter().map(|p| json!({
                "hwnd": p.hwnd, "title": p.title, "fullPath": p.full_path, "safePath": p.safe_path,
                "shotId": p.shot_id, "coordMap": p.coord_map, "rect": p.rect,
            })).collect::<Vec<_>>());
        }
        v
    }
}

fn norm_path(s: &str) -> String {
    s.replace('\\', "/")
}

/// Parse a `desktop_screenshot_window` reply (either envelope form).
pub fn parse_shot(hwnd: u64, reply: &AbReply) -> Result<Shot, AbError> {
    let full = reply.get_str("fullPath").or_else(|| reply.get_str("fullPathHost")).or_else(|| reply.get_str("savedTo")).or_else(|| reply.get_str("path"));
    let Some(full) = full else {
        return Err(AbError::Shape("desktop_screenshot_window reply carries no fullPath/savedTo (base64-only form?)".into()));
    };
    let mut popups = Vec::new();
    let screenshots = reply.get("screenshots").cloned().unwrap_or(Value::Null);
    if let Some(arr) = screenshots.as_array() {
        for p in arr {
            let s = |k: &str| p.get(k).and_then(Value::as_str).unwrap_or("").to_string();
            popups.push(PopupShot {
                hwnd: p.get("hwnd").and_then(num_u64).unwrap_or(0),
                title: s("title"),
                full_path: norm_path(&s("fullPath")),
                safe_path: norm_path(&s("safePath")),
                shot_id: p.get("shotId").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| p.get("coordMap").and_then(|c| c.get("shotId")).and_then(Value::as_str).unwrap_or("").to_string()),
                coord_map: p.get("coordMap").cloned().unwrap_or(Value::Null),
                rect: p.get("rect").cloned().unwrap_or(Value::Null),
            });
        }
    }
    let coord_map = reply.get("coordMap").cloned().unwrap_or(Value::Null);
    let shot_id = reply.get_str("shotId").map(str::to_string).unwrap_or_else(|| coord_map.get("shotId").and_then(Value::as_str).unwrap_or("").to_string());
    Ok(Shot {
        hwnd,
        full_path: norm_path(full),
        safe_path: reply.get_str("safePath").map(norm_path).unwrap_or_default(),
        local_full_path: reply.get_str("localFullPath").map(str::to_string),
        shot_id,
        coord_map,
        full_width: reply.get_u64("fullWidth").unwrap_or(0),
        full_height: reply.get_u64("fullHeight").unwrap_or(0),
        safe_width: reply.get_u64("safeWidth").unwrap_or(0),
        safe_height: reply.get_u64("safeHeight").unwrap_or(0),
        full_bytes: reply.get_u64("fullBytes").unwrap_or(0),
        safe_bytes: reply.get_u64("safeBytes").unwrap_or(0),
        source: reply.get_str("source").unwrap_or("").to_string(),
        owned_popup_count: reply.get_u64("ownedPopupCount").unwrap_or(popups.len() as u64),
        popups,
        screenshots,
    })
}

/// Capture one window plus the popups it owns, in the background (PrintWindow).
/// `resize_max` caps the safe image width (ab default 1568).
pub fn desktop_screenshot_window(hwnd: u64, reason: &str, resize_max: Option<u32>) -> Result<Shot, AbError> {
    let mut args = json!({"hwnd": hwnd, "reason": reason});
    if let Some(m) = resize_max {
        args["resizeMax"] = json!(m);
    }
    let r = call("desktop_screenshot_window", args, Duration::from_secs(30))?;
    parse_shot(hwnd, &r)
}

/// Stamp the owning AI thread on a KiCad window the bridge raised (wiki #12.3).
pub fn desktop_set_window_identity(hwnd: u64, app_name: &str, tooltip: &str) -> Result<Value, AbError> {
    let r = call("desktop_set_window_identity", json!({"hwnd": hwnd, "appName": app_name, "tooltip": tooltip}), Duration::from_secs(8))?;
    Ok(r.body)
}

/// `desktop_taskbar {hwnd, progress?, overlay?, flash?, thumbnailTooltip?}`.
pub fn desktop_taskbar(hwnd: u64, fields: Value) -> Result<Value, AbError> {
    let mut args = json!({"hwnd": hwnd});
    if let Some(m) = fields.as_object() {
        for (k, v) in m {
            args[k] = v.clone();
        }
    }
    let r = call("desktop_taskbar", args, Duration::from_secs(8))?;
    Ok(r.body)
}

/// A short always-on-top caption (reason is required by ab).
pub fn desktop_caption(id: &str, text: &str, duration_ms: u64, reason: &str) -> Result<Value, AbError> {
    let r = call("desktop_caption", json!({"id": id, "text": text, "duration": duration_ms, "size": "medium", "position": "bottom-left", "reason": reason}), Duration::from_secs(6))?;
    Ok(r.body)
}

/// A native toast, attributed to the forwarded caller by ab itself.
pub fn notify_user(title: &str, body: &str, level: &str) -> Result<Value, AbError> {
    let r = call("notify_user", json!({"title": title, "body": body, "message": body, "level": level}), Duration::from_secs(8))?;
    Ok(r.body)
}

#[derive(Clone, Debug, Default)]
pub struct CloseOutcome {
    pub closed: bool,
    pub forced: bool,
}

/// Graceful WM_CLOSE with ab's bookkeeping; never forceAfterMs (a save prompt must stay up).
pub fn desktop_close_window(hwnd: u64, reason: &str) -> Result<CloseOutcome, AbError> {
    let r = call("desktop_close_window", json!({"hwnd": hwnd, "reason": reason}), Duration::from_secs(15))?;
    Ok(CloseOutcome { closed: r.get_bool("closed").unwrap_or(false), forced: r.get_bool("forced").unwrap_or(false) })
}

/// The one sanctioned foreground, through ab (used when the platform has no bring_to_front).
/// Bring a window forward through ab WITHOUT changing its maximized/normal state: used to
/// hand the activation back to the user's own window after a KiCad self-raise. ab holds
/// the foreground rights the bridge process lacks (a SetForegroundWindow from the bridge
/// reports success and does not stick; measured on ConfRoomROG 2026-09-12).
pub fn desktop_restore_user_window(hwnd: u64, reason: &str) -> Result<Value, AbError> {
    let r = call("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": reason}), Duration::from_secs(10))?;
    Ok(r.body)
}

pub fn desktop_bring_to_front(hwnd: u64, reason: &str) -> Result<Value, AbError> {
    let r = call("desktop_bring_to_front", json!({"hwnd": hwnd, "state": "restore", "reason": reason}), Duration::from_secs(10))?;
    Ok(r.body)
}

#[derive(Clone, Debug, Default)]
pub struct WaitOutcome {
    /// The requested condition was met (with state "absent" this means the window is GONE).
    pub appeared: bool,
    pub waited_ms: u64,
    pub window: Option<AbWindow>,
}

/// Block (bounded) until a window with `title_contains` appears, or with
/// `state: Some("absent")` until it goes away.
pub fn desktop_wait_for_window(title_contains: &str, state: Option<&str>, timeout_ms: u64) -> Result<WaitOutcome, AbError> {
    let mut args = json!({"titleContains": title_contains, "timeoutMs": timeout_ms});
    if let Some(s) = state {
        args["state"] = json!(s);
    }
    let r = call("desktop_wait_for_window", args, Duration::from_millis(timeout_ms + 5000))?;
    let window = r.get("window").and_then(Value::as_object).and_then(|w| {
        let hwnd = w.get("hwnd").and_then(num_u64)?;
        Some(AbWindow {
            hwnd,
            title: w.get("title").and_then(Value::as_str).unwrap_or("").into(),
            class_name: w.get("className").and_then(Value::as_str).unwrap_or("").into(),
            rect: rect_of(w.get("rect")),
            pid: w.get("pid").and_then(num_u64).unwrap_or(0) as u32,
            ..Default::default()
        })
    });
    Ok(WaitOutcome { appeared: r.get_bool("appeared").unwrap_or(false), waited_ms: r.get_u64("waitedMs").unwrap_or(0), window })
}

// ── Typed helpers for the demo tour (phase 5) ─────────────────────────────────

/// `desktop_list_monitors`: the raw `monitors[]` payload (x/y/width/height, primary,
/// workArea). The direct API hands it back unwrapped (measured on ConfRoomROG 2026-09-04:
/// top-level `monitors`); older shapes wrap it, and `AbReply::get` covers both.
pub fn desktop_list_monitors(reason: &str) -> Result<Value, AbError> {
    let r = call("desktop_list_monitors", json!({"reason": reason}), Duration::from_secs(6))?;
    Ok(r.get("monitors").cloned().unwrap_or(Value::Null))
}

/// `desktop_set_window_bounds {hwnd, x, y, w, h, reason}`.
pub fn desktop_set_window_bounds(hwnd: u64, x: i32, y: i32, w: i32, h: i32, reason: &str) -> Result<Value, AbError> {
    let r = call("desktop_set_window_bounds", json!({"hwnd": hwnd, "x": x, "y": y, "w": w, "h": h, "reason": reason}), Duration::from_secs(8))?;
    Ok(r.body)
}

/// A caption with an explicit size and position (the tour paints its beat line bottom-left
/// at medium size, its acknowledgements and foreground notes small at the top). Fleet
/// ruling (dash-demo, 2026-08-24): only id, text, duration, size and position are passed;
/// reason is REQUIRED (ab refuses a caption without one).
pub fn desktop_caption_at(id: &str, text: &str, duration_ms: u64, size: &str, position: &str, reason: &str) -> Result<Value, AbError> {
    let r = call("desktop_caption", json!({"id": id, "text": text, "duration": duration_ms, "size": size, "position": position, "reason": reason}), Duration::from_secs(6))?;
    Ok(r.body)
}

/// `desktop_caption {action:"hide", id}`.
pub fn desktop_caption_hide(id: &str, reason: &str) -> Result<Value, AbError> {
    let r = call("desktop_caption", json!({"action": "hide", "id": id, "reason": reason}), Duration::from_secs(6))?;
    Ok(r.body)
}

/// `desktop_demo_panel` with whatever action the caller built (show / poll / hide / press).
/// The reply body is returned unwrapped so `shown`, `state`, `stage`, `muted`, `events`
/// and `callbackAccepted` can be read wherever ab put them.
pub fn desktop_demo_panel(args: Value) -> Result<AbReply, AbError> {
    call("desktop_demo_panel", args, Duration::from_secs(8))
}

/// A toast with buttons (the transport fallback for an ab older than the demo panel).
pub fn notify_user_buttons(id: &str, title: &str, body: &str, buttons: &[&str], duration_long: bool) -> Result<Value, AbError> {
    let b: Vec<Value> = buttons.iter().map(|l| json!({"label": l})).collect();
    let r = call("notify_user", json!({"id": id, "title": title, "body": body, "message": body, "buttons": b, "durationLong": duration_long}), Duration::from_secs(6))?;
    Ok(r.body)
}

/// `notify_events {sinceSeq}`: (events, nextSeq) of toast button clicks.
pub fn notify_events(since_seq: i64) -> Result<(Vec<Value>, Option<i64>), AbError> {
    let r = call("notify_events", json!({"sinceSeq": since_seq}), Duration::from_secs(6))?;
    let events = r.get("events").and_then(Value::as_array).cloned().unwrap_or_default();
    let next = r.get("nextSeq").and_then(num_i64);
    Ok((events, next))
}

pub fn notify_dismiss(id: &str) -> Result<Value, AbError> {
    let r = call("notify_dismiss", json!({"id": id}), Duration::from_secs(4))?;
    Ok(r.body)
}

/// A started desktop recording (ab 2.1.35+ native recorder).
#[derive(Clone, Debug, Default)]
pub struct Recording {
    pub recording_id: String,
    pub path: String,
    pub encoder: String,
    pub hardware_encoder: Option<bool>,
    pub audio: Option<bool>,
    pub raw: Value,
}

/// `desktop_record_start {monitor:"primary", fps, audio:true, maxDurationMs, reason,
/// confirmDesktopNotTabRecording:true}`. The reason is required: a recording can never
/// start silently, the user sees it in the recording caption.
pub fn desktop_record_start(reason: &str, fps: u32, max_duration_ms: u64) -> Result<Recording, AbError> {
    let args = json!({"monitor": "primary", "fps": fps, "audio": true, "maxDurationMs": max_duration_ms, "reason": reason, "confirmDesktopNotTabRecording": true});
    let r = call("desktop_record_start", args, Duration::from_secs(20))?;
    Ok(Recording {
        recording_id: r.get_str("recordingId").unwrap_or("").to_string(),
        path: r.get_str("path").or_else(|| r.get_str("filePath")).unwrap_or("").replace('\\', "/"),
        encoder: r.get_str("encoder").unwrap_or("").to_string(),
        hardware_encoder: r.get_bool("hardwareEncoder"),
        audio: r.get_bool("audio"),
        raw: r.body.clone(),
    })
}

/// `desktop_record_stop {recordingId}`: the finalized file (path, bytes, frames, audioFrames).
pub fn desktop_record_stop(recording_id: &str) -> Result<Value, AbError> {
    let args = if recording_id.is_empty() { json!({}) } else { json!({"recordingId": recording_id}) };
    let r = call("desktop_record_stop", args, Duration::from_secs(60))?;
    let mut out = json!({"recordingId": recording_id});
    for k in ["path", "filePath", "bytes", "frames", "audioFrames", "elapsedMs", "durationMs", "sizeKB"] {
        if let Some(v) = r.get(k) {
            out[k] = v.clone();
        }
    }
    if out.get("path").is_none() {
        if let Some(p) = out.get("filePath").cloned() {
            out["path"] = p;
        }
    }
    Ok(out)
}

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

    #[test]
    fn unwraps_flat_wrapped_and_string_output() {
        let flat = json!({"success": true, "windows": [{"hwnd": 1}]});
        assert_eq!(unwrap_payload(&flat)["windows"][0]["hwnd"], json!(1));
        let wrapped = json!({"success": true, "output": {"data": {"windows": []}}});
        assert!(unwrap_payload(&wrapped)["data"]["windows"].is_array());
        let stringy = json!({"success": true, "output": "{\"data\":{\"windows\":[{\"hwnd\":7,\"title\":\"x\"}]}}"});
        let r = AbReply { body: unwrap_payload(&stringy), raw: stringy.clone() };
        assert_eq!(r.get("windows").unwrap()[0]["hwnd"], json!(7));
        let plain = json!({"success": true, "output": "not json", "closed": true});
        let r = AbReply { body: unwrap_payload(&plain), raw: plain.clone() };
        assert_eq!(r.get_bool("closed"), Some(true));
    }

    #[test]
    fn window_list_reads_both_rect_spellings_and_degraded() {
        let raw = json!({"success": true, "degraded": false, "windows": [
            {"hwnd": 10, "title": "KiCad 10.0", "className": "wxWindowNR", "kind": "app", "z": 3,
             "rect": {"left": 5, "top": 6, "width": 700, "height": 500}, "pid": 42, "image": "KiCad.exe", "owner": 0},
            {"hwnd": 11, "title": "Error", "className": "#32770", "rect": {"x": 1, "y": 2, "w": 3, "h": 4}, "owner": 10, "minimized": true},
            {"hwnd": 0, "title": "skipped"},
            {"hwnd": "12", "title": "stringy", "rect": {"left": 0, "top": 0, "right": 50, "bottom": 40}}
        ]});
        let r = AbReply { body: unwrap_payload(&raw), raw: raw.clone() };
        let l = parse_window_list(&r).unwrap();
        assert!(!l.degraded);
        assert_eq!(l.windows.len(), 3);
        assert_eq!(l.windows[0].rect, (5, 6, 700, 500));
        assert_eq!(l.windows[0].image, "kicad.exe");
        assert_eq!(l.windows[0].z, Some(3));
        assert_eq!(l.windows[1].rect, (1, 2, 3, 4));
        assert_eq!(l.windows[1].owner, 10);
        assert!(l.windows[1].minimized);
        assert_eq!(l.windows[2].hwnd, 12);
        assert_eq!(l.windows[2].rect, (0, 0, 50, 40));
        let bad = json!({"success": true});
        let r = AbReply { body: bad.clone(), raw: bad };
        assert!(matches!(parse_window_list(&r), Err(AbError::Shape(_))));
    }

    #[test]
    fn shot_parses_popups_and_coord_map() {
        let raw = json!({"success": true, "fullPath": "C:\\shots\\w.full.png", "safePath": "C:\\shots\\w.safe.png",
            "fullWidth": 3840, "fullHeight": 2160, "safeWidth": 1568, "safeHeight": 882, "safeBytes": 88104,
            "coordMap": {"shotId": "shot_a", "image": {"w": 1568, "h": 882}}, "source": "printwindow",
            "ownedPopupCount": 1,
            "screenshots": [{"hwnd": 265518, "title": "Error", "kind": "owned_popup", "fullPath": "C:\\shots\\p.png", "safePath": "C:\\shots\\p.png", "coordMap": {"shotId": "shot_pop1"}}]});
        let r = AbReply { body: unwrap_payload(&raw), raw: raw.clone() };
        let s = parse_shot(99, &r).unwrap();
        assert_eq!(s.full_path, "C:/shots/w.full.png");
        assert_eq!(s.shot_id, "shot_a");
        assert_eq!(s.owned_popup_count, 1);
        assert_eq!(s.popups[0].shot_id, "shot_pop1");
        assert_eq!(s.popups[0].title, "Error");
        let j = s.to_json();
        assert_eq!(j["capturedBy"], json!("ad:desktop_screenshot_window"));
        assert_eq!(j["sizeKB"], json!(86.0));
        assert_eq!(j["popups"][0]["hwnd"], json!(265518));
        let b64only = json!({"success": true, "data": {"image": "iVBOR..."}});
        let r = AbReply { body: b64only.clone(), raw: b64only };
        assert!(matches!(parse_shot(1, &r), Err(AbError::Shape(_))));
    }

    #[test]
    fn caller_args_win_over_headers_and_delegate_is_added() {
        let args = json!({"caller": {"aiThread": "chip-fetcher tab 3", "containerName": "c1"}, "reason": "look"});
        let c = caller_from(&args, "hdr-thread", "hdr-container", "");
        assert_eq!(c.thread, "chip-fetcher tab 3");
        assert_eq!(c.container, "c1");
        assert_eq!(c.reason, "look");
        set_caller(c);
        let h = forward_headers();
        assert!(h.iter().any(|(k, v)| k == "X-Adom-Caller-Delegate" && v == DELEGATE));
        assert!(h.iter().any(|(k, v)| k == "X-Adom-Caller-Thread" && v == "chip-fetcher tab 3"));
        clear_caller();
        assert!(forward_headers().is_empty());
        assert_eq!(thread_name(), "");
        let c = caller_from(&json!({}), "", "", "");
        set_caller(c);
        assert!(current_caller().is_none());
    }

    #[test]
    fn unavailable_without_env_never_hangs() {
        std::env::remove_var("ADOM_DIRECT_API_URL");
        // A port file may exist on a dev box; only assert the no-config path when it does not.
        if base_url().is_none() {
            let t = Instant::now();
            let r = call("ping", json!({}), Duration::from_secs(1));
            assert!(matches!(r, Err(AbError::Unavailable(_))));
            assert!(t.elapsed() < Duration::from_secs(1));
        }
    }
}