app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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));
}
}
}