master
John Lauer v2.0.641 (insiders): background birth measured to one frame: park while invisible + bottomed the instant Chrome first s… aee3a87 1mo ago
//! pup-core: types, settings, the response envelope, caller identity and the AbClient trait.
//! No OS calls live here; everything desktop-shaped goes through `AbClient` (ab's verbs) or
//! the `pup-host` traits. This crate is the root of the dependency tree and changes rarely.

use serde_json::{json, Map, Value};
use std::path::{Path, PathBuf};
use std::time::Duration;

/// The bridge version, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both).
pub const VERSION_RAW: &str = include_str!("../../../src/BRIDGE_VERSION");
pub fn version() -> &'static str { VERSION_RAW.trim() }

// ─── Caller identity (ab stamps it on every inbound verb) ───────────────────────────────

/// Who this verb is acting FOR. ab forwards the caller's identity as X-Adom-Caller-* headers and
/// stamps the transport it verified in X-Adom-Verified-Origin (relay | local | bridge).
#[derive(Debug, Clone, Default)]
pub struct Caller {
    pub thread: String,
    pub container: String,
    pub reason: String,
    pub delegate: String,
    pub verified_origin: String,
}

impl Caller {
    pub fn from_headers<'a>(headers: impl Iterator<Item = (&'a str, &'a str)>) -> Caller {
        let mut c = Caller::default();
        for (k, v) in headers {
            match k.to_ascii_lowercase().as_str() {
                "x-adom-caller-thread" => c.thread = v.to_string(),
                "x-adom-caller-container" => c.container = v.to_string(),
                "x-adom-caller-reason" => c.reason = v.to_string(),
                "x-adom-caller-delegate" => c.delegate = v.to_string(),
                "x-adom-verified-origin" => c.verified_origin = v.to_string(),
                _ => {}
            }
        }
        c
    }
    pub fn is_relayed(&self) -> bool { self.verified_origin == "relay" || !self.container.is_empty() }
}

// ─── The response envelope (the contract the Node build established) ────────────────────

/// Success: `{ok:true, status:"ok", ...fields}`.
pub fn ok(fields: Value) -> Value {
    let mut m = Map::new();
    m.insert("ok".into(), json!(true));
    m.insert("status".into(), json!("ok"));
    if let Value::Object(o) = fields { for (k, v) in o { m.insert(k, v); } }
    Value::Object(m)
}

/// Failure: `{success:false, status:"error", errorCode, error, _hint?}`.
pub fn err(code: &str, error: impl Into<String>, hint: Option<&str>) -> Value {
    let mut v = json!({ "success": false, "status": "error", "errorCode": code, "error": error.into() });
    if let Some(h) = hint { v["_hint"] = json!(h); }
    v
}

/// The blocked-on-a-human shape (ab BRIDGE_SDK 2.1.30 rule, pup-bridge#92): never a bare timeout.
pub fn blocked_on_user(still_running: bool, blocked_by: &str, hint: &str, extra: Value) -> Value {
    let mut v = json!({ "success": false, "status": "error", "errorCode": "blocked_on_user", "stillRunning": still_running, "blockedBy": blocked_by, "_hint": hint });
    if let Value::Object(o) = extra { for (k, x) in o { v[k] = x; } }
    v
}

pub fn merge(mut base: Value, extra: Value) -> Value {
    if let (Some(b), Value::Object(e)) = (base.as_object_mut(), extra) { for (k, v) in e { b.insert(k, v); } }
    base
}

// ─── AbClient: the only way pup touches the desktop ─────────────────────────────────────

#[derive(Debug, thiserror::Error)]
pub enum AbError {
    #[error("ab loopback API unreachable: {0}")]
    Unreachable(String),
    #[error("verb {verb} refused: {code}: {message}")]
    Refused { verb: String, code: String, message: String },
    #[error("verb {0} failed: {1}")]
    Failed(String, String),
}

pub trait AbClient: Send + Sync {
    fn call(&self, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError>;
    /// Like `call`, but routed to another desktop of the same account (ab cross-desktop egress). The
    /// default ignores `target` (single-box / NoAb). Used by pup_vault_sync to reach the peer's pup.
    fn call_target(&self, target: &str, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> { let _ = target; self.call(verb, args, caller, timeout) }
    /// The ab version this bridge is talking to, if known (bridge_info).
    fn version(&self) -> Option<String> { None }
}

/// The loopback direct-API client (identical wire contract to the Node build's abCommand).
pub struct Loopback { pub base: String, pub token: String }

impl Loopback {
    pub fn from_env() -> Option<Loopback> {
        let mut base = std::env::var("ADOM_DIRECT_API_URL").unwrap_or_default().trim().trim_end_matches('/').to_string();
        if base.is_empty() {
            let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();
            if let Ok(port) = std::fs::read_to_string(Path::new(&home).join(".adom").join("direct-api-port")) {
                let port = port.trim();
                // the file carries either a bare port or host:port (ab 2.1.x writes "127.0.0.1:24680")
                if !port.is_empty() { base = if port.contains(':') { format!("http://{port}") } else { format!("http://127.0.0.1:{port}") }; }
            }
        }
        if base.is_empty() { return None; }
        Some(Loopback { base, token: std::env::var("ADOM_BRIDGE_TOKEN").unwrap_or_default() })
    }
}

/// ab wraps a bridge verb's JSON in an envelope whose `output` is a JSON STRING; unwrap it, keeping
/// the envelope's own keys (inner wins on a clash).
pub fn unwrap_output(mut v: Value) -> Value {
    if let Some(inner) = v.get("output").and_then(|o| o.as_str()) {
        if inner.trim_start().starts_with('{') {
            if let Ok(mut parsed) = serde_json::from_str::<Value>(inner) {
                if let (Some(po), Some(eo)) = (parsed.as_object_mut(), v.as_object()) {
                    for (k, val) in eo { if k != "output" { po.entry(k.clone()).or_insert(val.clone()); } }
                }
                v = parsed;
            }
        }
    }
    v
}

impl AbClient for Loopback {
    fn call(&self, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> {
        let agent = ureq::AgentBuilder::new().timeout(timeout.max(Duration::from_secs(1))).build();
        let mut req = agent.post(&format!("{}/command", self.base))
            .set("Content-Type", "application/json")
            .set("X-Adom-Caller-Delegate", "puppeteer");
        if !self.token.is_empty() { req = req.set("X-Adom-Bridge-Token", &self.token); }
        if !caller.thread.is_empty() { req = req.set("X-Adom-Caller-Thread", &caller.thread); }
        if !caller.container.is_empty() { req = req.set("X-Adom-Caller-Container", &caller.container); }
        if !caller.reason.is_empty() { req = req.set("X-Adom-Caller-Reason", &caller.reason); }
        let resp = req.send_json(json!({"command": verb, "args": args})).map_err(|e| AbError::Unreachable(e.to_string()))?;
        let v: Value = resp.into_json().map_err(|e| AbError::Failed(verb.into(), e.to_string()))?;
        let v = unwrap_output(v);
        if v.get("success") == Some(&Value::Bool(false)) || v.get("status") == Some(&Value::String("error".into())) {
            let code = v.get("errorCode").and_then(|c| c.as_str()).unwrap_or("error").to_string();
            let message = v.get("error").and_then(|c| c.as_str()).unwrap_or("").to_string();
            return Err(AbError::Refused { verb: verb.into(), code, message });
        }
        Ok(v)
    }
    fn call_target(&self, target: &str, verb: &str, args: Value, caller: &Caller, timeout: Duration) -> Result<Value, AbError> {
        // The direct API reads a top-level `target` and egresses to that peer's ab over the relay/mesh.
        let agent = ureq::AgentBuilder::new().timeout(timeout.max(Duration::from_secs(2))).build();
        let mut req = agent.post(&format!("{}/command", self.base)).set("Content-Type", "application/json").set("X-Adom-Caller-Delegate", "puppeteer");
        if !self.token.is_empty() { req = req.set("X-Adom-Bridge-Token", &self.token); }
        if !caller.thread.is_empty() { req = req.set("X-Adom-Caller-Thread", &caller.thread); }
        if !caller.reason.is_empty() { req = req.set("X-Adom-Caller-Reason", &caller.reason); }
        let resp = req.send_json(json!({ "command": verb, "args": args, "target": target })).map_err(|e| AbError::Unreachable(e.to_string()))?;
        let v = unwrap_output(resp.into_json().map_err(|e| AbError::Failed(verb.into(), e.to_string()))?);
        if v.get("success") == Some(&Value::Bool(false)) || v.get("status") == Some(&Value::String("error".into())) {
            return Err(AbError::Refused { verb: verb.into(), code: v.get("errorCode").and_then(|c| c.as_str()).unwrap_or("error").to_string(), message: v.get("error").and_then(|c| c.as_str()).unwrap_or("").to_string() });
        }
        Ok(v)
    }
}

/// No ab at all (hand-launched exe): every desktop verb reports unreachable, nothing panics.
pub struct NoAb;
impl AbClient for NoAb {
    fn call(&self, _verb: &str, _args: Value, _caller: &Caller, _timeout: Duration) -> Result<Value, AbError> {
        Err(AbError::Unreachable("ADOM_DIRECT_API_URL not set (bridge launched outside ab)".into()))
    }
}

// ─── Settings (~/.adom/pup-settings.json, same file and keys as the Node build) ─────────

pub struct Settings { path: PathBuf, cache: std::sync::Mutex<Option<Value>> }

impl Settings {
    pub fn new(state_dir: &Path) -> Settings { Settings { path: state_dir.join("pup-settings.json"), cache: std::sync::Mutex::new(None) } }
    pub fn all(&self) -> Value {
        let mut c = self.cache.lock().unwrap();
        if c.is_none() {
            let v = std::fs::read_to_string(&self.path).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));
            *c = Some(if v.is_object() { v } else { json!({}) });
        }
        c.clone().unwrap()
    }
    /// `"on"` / `"off"` style keys; a missing key returns the default the Node build uses.
    pub fn get(&self, key: &str, default: &str) -> String {
        self.all().get(key).and_then(|v| v.as_str().map(|s| s.to_string()).or_else(|| v.as_bool().map(|b| if b { "on".into() } else { "off".into() }))).unwrap_or_else(|| default.to_string())
    }
    pub fn set(&self, key: &str, value: Value) -> std::io::Result<()> {
        let mut all = self.all();
        all[key] = value;
        std::fs::create_dir_all(self.path.parent().unwrap_or(Path::new(".")))?;
        std::fs::write(&self.path, serde_json::to_string_pretty(&all).unwrap_or_default())?;
        *self.cache.lock().unwrap() = Some(all);
        Ok(())
    }
}

// ─── Logging (stdout, ab tails it into ~/.adom/bridge-logs/puppeteer.log) ───────────────

static LOG_FILE: std::sync::OnceLock<std::sync::Mutex<Option<std::path::PathBuf>>> = std::sync::OnceLock::new();

/// 2.0.445: pup prints to stdout AND appends to a rolling file (ab wipes its per-spawn capture, so
/// there was no trace to read after "it showed and then hid"). 2 MB, one rotation (pup.log.1).
pub fn set_log_file(path: std::path::PathBuf) {
    if let Some(d) = path.parent() { let _ = std::fs::create_dir_all(d); }
    *LOG_FILE.get_or_init(|| std::sync::Mutex::new(None)).lock().unwrap() = Some(path);
}
pub fn log_file() -> Option<std::path::PathBuf> { LOG_FILE.get().and_then(|m| m.lock().unwrap().clone()) }

static LAST_LOG: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
/// The most recent log line (2.0.636): lets a watcher say what pup had just done when something moved.
pub fn last_log() -> String { LAST_LOG.lock().map(|l| l.clone()).unwrap_or_default() }
pub fn log(line: impl AsRef<str>) {
    let line = line.as_ref();
    if !line.starts_with("[zwatch]") { if let Ok(mut l) = LAST_LOG.lock() { *l = line.chars().take(120).collect(); } }
    println!("{line}");
    let Some(path) = log_file() else { return };
    use std::io::Write;
    if std::fs::metadata(&path).map(|m| m.len() > 2_000_000).unwrap_or(false) { let _ = std::fs::rename(&path, path.with_extension("log.1")); }
    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { let _ = writeln!(f, "{} {line}", iso_now()); }
}

pub fn now_ms() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) }

pub fn iso_now() -> String {
    // No chrono dependency: ISO-8601 UTC from the unix clock.
    let dur = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = dur.as_secs(); let ms = dur.subsec_millis();
    let days = secs / 86400; let rem = secs % 86400;
    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
    // civil-from-days (Howard Hinnant)
    let z = days as i64 + 719468; let era = z.div_euclid(146097); let doe = z.rem_euclid(146097);
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1; let mo = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if mo <= 2 { y + 1 } else { y };
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}.{ms:03}Z")
}

/// Background-loop cadence stats (Node: _loopStats / loopReport) for pup_status.loops.
pub mod loops {
    use serde_json::{json, Value};
    use std::collections::HashMap;
    use std::sync::Mutex;
    #[derive(Default, Clone)]
    struct Stat { cadence_ms: u64, ticks: u64, total_ms: u64, last_ms: u64, max_ms: u64, slow_ticks: u64, last_at: u64, minute_at: u64, minute_ticks: u64 }
    static STATS: Mutex<Option<HashMap<String, Stat>>> = Mutex::new(None);
    pub fn tick(name: &str, cadence_ms: u64, dur_ms: u64) {
        let now = super::now_ms();
        let mut g = STATS.lock().unwrap(); let m = g.get_or_insert_with(HashMap::new);
        let s = m.entry(name.to_string()).or_default();
        s.cadence_ms = cadence_ms; s.ticks += 1; s.total_ms += dur_ms; s.last_ms = dur_ms; s.max_ms = s.max_ms.max(dur_ms); s.last_at = now;
        if dur_ms > cadence_ms / 2 { s.slow_ticks += 1; }
        if now.saturating_sub(s.minute_at) > 60_000 { s.minute_at = now; s.minute_ticks = 0; }
        s.minute_ticks += 1;
    }
    pub fn report() -> Value {
        let now = super::now_ms();
        let g = STATS.lock().unwrap();
        let mut out = serde_json::Map::new();
        if let Some(m) = g.as_ref() { for (n, s) in m { out.insert(n.clone(), json!({ "cadenceMs": s.cadence_ms, "ticks": s.ticks, "lastMin": if now.saturating_sub(s.minute_at) <= 60_000 { s.minute_ticks } else { 0 }, "avgMs": if s.ticks > 0 { s.total_ms / s.ticks } else { 0 }, "lastMs": s.last_ms, "maxMs": s.max_ms, "slowTicks": s.slow_ticks, "lastAt": s.last_at })); } }
        Value::Object(out)
    }
}

/// Download a URL to a file, following redirects, via ureq (used by the real-Chrome installer). The
/// TLS + redirect handling lives here so host-win needs no HTTP dependency of its own.
pub fn http_download(url: &str, dest: &std::path::Path) -> Result<(), String> {
    let agent = ureq::AgentBuilder::new().timeout(std::time::Duration::from_secs(120)).redirects(8).build();
    let resp = agent.get(url).call().map_err(|e| e.to_string())?;
    let mut r = resp.into_reader();
    let mut f = std::fs::File::create(dest).map_err(|e| e.to_string())?;
    std::io::copy(&mut r, &mut f).map_err(|e| e.to_string())?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn envelope_shapes() {
        let o = ok(json!({"a": 1})); assert_eq!(o["ok"], json!(true)); assert_eq!(o["status"], json!("ok")); assert_eq!(o["a"], json!(1));
        let e = err("x", "boom", Some("h")); assert_eq!(e["success"], json!(false)); assert_eq!(e["errorCode"], json!("x")); assert_eq!(e["_hint"], json!("h"));
        let b = blocked_on_user(true, "click", "wait", json!({"consentId": "c1"})); assert_eq!(b["errorCode"], json!("blocked_on_user")); assert_eq!(b["stillRunning"], json!(true)); assert_eq!(b["consentId"], json!("c1"));
    }
    #[test] fn unwraps_output() {
        let v = unwrap_output(json!({"output": "{\"ok\":true,\"x\":1}", "hwnd": 5, "x": 9}));
        assert_eq!(v["x"], json!(1)); assert_eq!(v["hwnd"], json!(5));
    }
    #[test] fn caller_headers() {
        let c = Caller::from_headers([("X-Adom-Caller-Thread", "t1"), ("x-adom-verified-origin", "local")].into_iter());
        assert_eq!(c.thread, "t1"); assert_eq!(c.verified_origin, "local"); assert!(!c.is_relayed());
    }
    #[test] fn iso() { assert!(iso_now().ends_with("Z")); assert_eq!(iso_now().len(), 24); }
}


// ── Runtime data dir (pup-bridge#98 / adom-bridge#153) ──────────────────────────────────────────
/// ab passes every bridge `ADOM_BRIDGE_DATA_DIR` (%LOCALAPPDATA%\Adom Bridge\bridge-data\<bridge>): a
/// dir that survives cache swaps and version bumps. Runtime data (profiles, sessions, screenshots,
/// recordings) lives there when it is set; without it (an older ab, a hand-launched exe) every
/// resolver falls back to its legacy location beside the code / under ~/.adom.
pub fn data_dir() -> Option<std::path::PathBuf> {
    let v = std::env::var("ADOM_BRIDGE_DATA_DIR").ok()?.trim().to_string();
    if v.is_empty() { return None; }
    let p = std::path::PathBuf::from(v);
    std::fs::create_dir_all(&p).ok()?;
    Some(p)
}

/// `<data>/<kind>` when the data dir is set, else `legacy`. The first time the new dir is resolved and
/// the legacy one exists, the legacy dir is MOVED (rename, same volume) so the user's logged-in
/// profiles and session files carry over; a failed rename keeps the legacy path (never two copies).
pub fn data_subdir(kind: &str, legacy: std::path::PathBuf) -> std::path::PathBuf {
    let Some(root) = data_dir() else { return legacy };
    let new = root.join(kind);
    if !new.exists() && legacy.exists() {
        match std::fs::rename(&legacy, &new) {
            Ok(()) => log(format!("[data] moved {} -> {} (ADOM_BRIDGE_DATA_DIR, pup-bridge#98)", legacy.display(), new.display())),
            Err(e) => { log(format!("[data] could not move {} -> {}: {e}; staying on the legacy path", legacy.display(), new.display())); return legacy; }
        }
    }
    let _ = std::fs::create_dir_all(&new);
    new
}

/// Retention for ephemeral output dirs (screenshots, recordings): delete files older than `max_age_days`,
/// then oldest-first until the dir is under `max_bytes`. Files touched in the last 10 minutes are never
/// removed (a recording still being written). Returns (files removed, bytes freed).
pub fn retention_sweep(dir: &std::path::Path, max_age_days: u64, max_bytes: u64) -> (usize, u64) {
    fn walk(d: &std::path::Path, out: &mut Vec<(std::path::PathBuf, u64, std::time::SystemTime)>) {
        let Ok(rd) = std::fs::read_dir(d) else { return };
        for e in rd.flatten() {
            let p = e.path();
            let Ok(md) = e.metadata() else { continue };
            if md.is_dir() { walk(&p, out); } else { out.push((p, md.len(), md.modified().unwrap_or(std::time::UNIX_EPOCH))); }
        }
    }
    let mut files = Vec::new(); walk(dir, &mut files);
    files.sort_by_key(|f| f.2);
    let now = std::time::SystemTime::now();
    let mut total: u64 = files.iter().map(|f| f.1).sum();
    let (mut removed, mut freed) = (0usize, 0u64);
    let recent = std::time::Duration::from_secs(600);
    for (p, len, m) in &files {
        let age = now.duration_since(*m).unwrap_or_default();
        if age < recent { continue; }
        let too_old = age.as_secs() > max_age_days * 86_400;
        if !too_old && total <= max_bytes { break; }
        if std::fs::remove_file(p).is_ok() { removed += 1; freed += len; total = total.saturating_sub(*len); }
    }
    // drop now-empty frame dirs left behind by tab recordings
    if let Ok(rd) = std::fs::read_dir(dir) { for e in rd.flatten() { if e.path().is_dir() { let _ = std::fs::remove_dir(e.path()); } } }
    (removed, freed)
}

#[cfg(test)]
mod data_dir_tests {
    use super::*;
    fn tmp(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("pup-core-test-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d }
    #[test]
    fn data_subdir_moves_legacy_once_and_falls_back_without_env() {
        let root = tmp("data"); let legacy = root.join("legacy-profiles"); std::fs::create_dir_all(legacy.join("adom-you")).unwrap(); std::fs::write(legacy.join("adom-you").join("Cookies"), b"x").unwrap();
        std::env::remove_var("ADOM_BRIDGE_DATA_DIR");
        assert_eq!(data_subdir("profiles", legacy.clone()), legacy, "no env: legacy path");
        std::env::set_var("ADOM_BRIDGE_DATA_DIR", root.join("bridge-data").to_string_lossy().to_string());
        let new = data_subdir("profiles", legacy.clone());
        assert_eq!(new, root.join("bridge-data").join("profiles"));
        assert!(new.join("adom-you").join("Cookies").exists(), "legacy profile moved over");
        assert!(!legacy.exists(), "legacy dir gone after the move");
        assert_eq!(data_subdir("profiles", legacy.clone()), new, "second resolve is stable");
        std::env::remove_var("ADOM_BRIDGE_DATA_DIR");
    }
    #[test]
    fn retention_sweep_drops_old_then_oldest_over_cap_and_keeps_recent() {
        let d = tmp("ret");
        let old = d.join("old.png"); std::fs::write(&old, vec![0u8; 100]).unwrap();
        let t = std::time::SystemTime::now() - std::time::Duration::from_secs(20 * 86_400);
        std::fs::File::open(&old).unwrap().set_modified(t).unwrap();
        let mid = d.join("mid.png"); std::fs::write(&mid, vec![0u8; 600]).unwrap();
        std::fs::File::open(&mid).unwrap().set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(3 * 86_400)).unwrap();
        let fresh = d.join("fresh.png"); std::fs::write(&fresh, vec![0u8; 600]).unwrap();
        let (n, b) = retention_sweep(&d, 14, 500);
        assert!(!old.exists(), "older than 14 d removed"); assert!(!mid.exists(), "oldest over the cap removed"); assert!(fresh.exists(), "touched in the last 10 min kept");
        assert_eq!(n, 2); assert_eq!(b, 700);
    }
}