master
John Lauer 1.3.0: Dock Bar contract (serve --print-url, /api/status LEDs, CORS, say strip), settings modal, taskbar progress, pup vault for logins, adom-bridge + pup_* transport, dashboard overhaul per adom-ui-design, doctor for first run 83b2ae8 1mo ago
//! Adom Bridge transport: every call to the user's desktop (pup, the vault,
//! file pulls, the taskbar) goes through `adom-bridge --ai-thread "<name>"
//! <verb> '<json>'`. One place owns the thread name, the target pick and the
//! reply shape, so no other module ever spells the CLI out.
//!
//! The ai-thread name (app-creator-ai-thread-name): every call says which
//! conversation is asking. Precedence: the `--ai-thread` global flag (stored
//! into `CHIP_FETCHER_AI_THREAD` by the CLI), then `ADOM_AI_THREAD`, then the
//! app's own name for housekeeping.

use anyhow::{Context, Result};
use serde_json::Value;
use std::process::Command;
use std::sync::OnceLock;

pub const HOUSEKEEPING_THREAD: &str = "chip-fetcher housekeeping";

/// The ai-thread name for outbound bridge calls.
pub fn ai_thread() -> String {
    for k in ["CHIP_FETCHER_AI_THREAD", "ADOM_AI_THREAD"] {
        if let Ok(v) = std::env::var(k) {
            let v = v.trim().to_string();
            if !v.is_empty() {
                return v;
            }
        }
    }
    HOUSEKEEPING_THREAD.to_string()
}

/// Record the `--ai-thread` flag for this process (and every child).
pub fn set_ai_thread(name: &str) {
    if !name.trim().is_empty() {
        std::env::set_var("CHIP_FETCHER_AI_THREAD", name.trim());
    }
}

fn on_path(b: &str) -> bool {
    let home = std::env::var("HOME").unwrap_or_default();
    std::path::Path::new(&home).join(".local/bin").join(b).is_file()
        || std::env::var("PATH").unwrap_or_default().split(':').any(|d| std::path::Path::new(d).join(b).is_file())
}

fn binary() -> &'static str {
    static BIN: OnceLock<&'static str> = OnceLock::new();
    BIN.get_or_init(|| if on_path("adom-bridge") { "adom-bridge" } else { "adom-bridge-cli" })
}

pub fn available() -> bool {
    on_path(binary())
}

/// Explicit desktop pick: `CHIP_FETCHER_TARGET` (set by `--target`).
pub fn target() -> Option<String> {
    std::env::var("CHIP_FETCHER_TARGET").ok().filter(|t| !t.trim().is_empty())
}

pub fn set_target(name: &str) {
    if !name.trim().is_empty() {
        std::env::set_var("CHIP_FETCHER_TARGET", name.trim());
    }
}

/// Resolve the desktop to drive, once per process: the `--target` flag /
/// `CHIP_FETCHER_TARGET`, else the first connected desktop when more than
/// one is on the relay (the bridge refuses to guess; we pick and say so).
pub fn ensure_target() -> Option<String> {
    static RESOLVED: OnceLock<Option<String>> = OnceLock::new();
    RESOLVED
        .get_or_init(|| {
            if let Some(t) = target() {
                return Some(t);
            }
            let all = connected_targets();
            if all.len() > 1 {
                // Pick the desktop the user is actually sitting at: the one
                // with the smallest time since their last keyboard/mouse input.
                let pick = all
                    .iter()
                    .map(|t| (t.clone(), idle_ms(t).unwrap_or(u64::MAX)))
                    .min_by_key(|(_, idle)| *idle)
                    .map(|(t, _)| t)
                    .unwrap_or_else(|| all[0].clone());
                eprintln!("[adom-chip-fetcher] {} desktops connected ({}); driving {} (where you were last active). Pass --target to choose.", all.len(), all.join(", "), pick);
                set_target(&pick);
                return Some(pick);
            }
            None
        })
        .clone()
}

/// A ready-to-extend `adom-bridge --ai-thread "<name>" [--target <box>]`
/// command. Callers add the verb + args.
pub fn command() -> Command {
    let mut c = Command::new(binary());
    c.arg("--ai-thread").arg(ai_thread());
    if let Some(t) = ensure_target() {
        c.arg("--target").arg(t);
    }
    c
}

/// Call one verb. Resolves `ambiguous_target` (2+ desktops connected) by
/// using `--target` from the environment, else the first connected desktop,
/// and retries once. Returns the bridge envelope as-is
/// (`{success, output, errorCode, _hint, ...}`); use [`output`] to unwrap.
pub fn call(verb: &str, args: &Value) -> Result<Value> {
    call_inner(verb, args, target().as_deref(), true)
}

fn call_inner(verb: &str, args: &Value, target: Option<&str>, allow_retry: bool) -> Result<Value> {
    let mut cmd = Command::new(binary());
    cmd.arg("--ai-thread").arg(ai_thread());
    if let Some(t) = target.map(String::from).or_else(ensure_target) {
        cmd.arg("--target").arg(t);
    }
    cmd.arg(verb).arg(args.to_string());
    let out = cmd.output().with_context(|| format!("running {} {verb}", binary()))?;
    let text = String::from_utf8_lossy(&out.stdout).to_string();
    // The CLI may prefix a "note:" line (retired alias). Find the JSON.
    let json_start = text.find('{').unwrap_or(text.len());
    let v: Value = serde_json::from_str(&text[json_start..]).unwrap_or_else(|_| {
        serde_json::json!({
            "success": false,
            "error": format!("{} {verb}: non-JSON reply: {}", binary(), text.trim().chars().take(300).collect::<String>()),
            "stderr": String::from_utf8_lossy(&out.stderr).trim().to_string(),
        })
    });
    if allow_retry && target.is_none() && v.get("error").and_then(|e| e.as_str()) == Some("ambiguous_target") {
        let pick = v
            .get("connected")
            .and_then(|c| c.as_array())
            .and_then(|a| a.first())
            .and_then(|x| x.as_str())
            .map(String::from);
        if let Some(name) = pick {
            set_target(&name);
            return call_inner(verb, args, Some(&name), false);
        }
    }
    Ok(v)
}

/// Unwrap the bridge envelope's `output`, which pup returns as a JSON-encoded
/// string. Falls back to the envelope itself when there is no such field.
pub fn output(v: &Value) -> Value {
    match v.get("output") {
        Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(Value::String(s.clone())),
        Some(o) => o.clone(),
        None => v.clone(),
    }
}

pub fn succeeded(v: &Value) -> bool {
    v.get("success").and_then(|b| b.as_bool()).unwrap_or(false)
        || (v.get("error").is_none() && v.get("errorCode").is_none() && v.get("status").and_then(|s| s.as_str()) != Some("error"))
}

pub fn error_text(v: &Value) -> String {
    let code = v.get("errorCode").and_then(|c| c.as_str()).unwrap_or("");
    let err = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
    let hint = v.get("_hint").and_then(|h| h.as_str()).unwrap_or("");
    let mut s = String::new();
    if !code.is_empty() {
        s.push_str(code);
    }
    if !err.is_empty() {
        if !s.is_empty() {
            s.push_str(": ");
        }
        s.push_str(err);
    }
    if !hint.is_empty() {
        s.push_str("\n  Hint: ");
        s.push_str(hint);
    }
    if s.is_empty() {
        s = "unknown bridge error".into();
    }
    s
}

/// Milliseconds since the user's last input on one desktop (GetLastInputInfo).
fn idle_ms(target: &str) -> Option<u64> {
    let out = Command::new("timeout")
        .args(["4", binary(), "--ai-thread", &ai_thread(), "--target", target, "desktop_user_idle_ms", "{}"])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let v: Value = serde_json::from_str(&text[text.find('{')?..]).ok()?;
    output(&v).get("idleMs").and_then(|n| n.as_u64())
}

/// `adom-bridge targets`: the desktops connected right now.
pub fn connected_targets() -> Vec<String> {
    let out = match Command::new("timeout").args(["3", binary(), "targets"]).output() {
        Ok(o) => o,
        Err(_) => return vec![],
    };
    let text = String::from_utf8_lossy(&out.stdout);
    let start = match text.find('{') {
        Some(i) => i,
        None => return vec![],
    };
    let v: Value = match serde_json::from_str(&text[start..]) {
        Ok(v) => v,
        Err(_) => return vec![],
    };
    v.get("targets")
        .and_then(|t| t.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|t| t.get("name").or_else(|| t.get("hostname")).and_then(|n| n.as_str()).map(String::from))
                .collect()
        })
        .unwrap_or_default()
}