main
John Lauer Publish 0.1.0 55a9481 2d ago
#![allow(dead_code)] // template helpers: keep the ones your stage uses
//! Adom Bridge from a flow app: every desktop action (a browser through pup, a native app, a window
//! recording, files to and from the desktop) is one `adom-bridge` call. The app runs on Linux; the
//! desktop it drives is whatever `--target` names. Copied from aiflow-bridge; keep what you use.
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Clone)]
pub struct Bridge { pub target: String, pub ai_thread: String }

pub struct ClipStop { pub remote: Option<String>, pub local: Option<PathBuf>, pub pulled: bool, pub reply: Value }

impl Bridge {
    /// One verb with JSON args. Gated verbs need a per-call `reason` inside `args`.
    pub fn call(&self, verb: &str, args: &Value) -> Value {
        let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();
        match out {
            Ok(o) => {
                let text = String::from_utf8_lossy(&o.stdout).to_string();
                let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}));
                // some verbs wrap their answer as a JSON string in `output`
                match v.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()) { Some(inner) => inner, None => v }
            }
            Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),
        }
    }

    /// A field from a reply, at the top level or under `data` (verbs answer both ways).
    pub fn field<'a>(v: &'a Value, k: &str) -> Option<&'a Value> {
        v.get(k).or_else(|| v.get("data").and_then(|d| d.get(k)))
    }

    pub fn ok(v: &Value) -> bool {
        v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())
    }

    /// Record ONE window (Windows Graphics Capture: works in the background, nobody at the desk is
    /// disturbed). Capped at an hour whatever the AI forgets. Returns the recording id.
    pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {
        let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));
        Self::field(&r, "recordingId").or_else(|| Self::field(&r, "id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)
    }

    /// Stop a recording and pull the file into `save_to`, checked: the local copy must exist and be
    /// non-empty. The desktop cleans its recordings folder, so pull now or lose the clip.
    pub fn record_stop(&self, id: &str, save_to: &Path) -> ClipStop {
        let r = self.call("desktop_record_stop", &json!({"recordingId": id}));
        let remote = Self::field(&r, "filePath").or_else(|| Self::field(&r, "path"))
            .and_then(|v| v.as_str()).map(|s| s.replace('\\', "/"));
        match remote {
            Some(p) => { let (pulled, local) = self.pull(&p, save_to, 3); ClipStop { remote: Some(p), local: Some(local), pulled, reply: r } }
            None => ClipStop { remote: None, local: None, pulled: false, reply: r },
        }
    }

    /// pull_file one desktop file into `save_to` until it lands non-empty (at most `attempts`).
    pub fn pull(&self, remote: &str, save_to: &Path, attempts: usize) -> (bool, PathBuf) {
        let local = save_to.join(remote.rsplit(['/', '\\']).next().unwrap_or(remote));
        for k in 0..attempts.max(1) {
            self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to.display().to_string(), "reason": "flow app: bring a desktop file into the project"}));
            if std::fs::metadata(&local).map(|m| m.len() > 0).unwrap_or(false) { return (true, local); }
            if k + 1 < attempts { std::thread::sleep(std::time::Duration::from_secs(3)); }
        }
        (false, local)
    }

    /// A background screenshot of one window, copied to `dest`.
    pub fn screenshot_window(&self, hwnd: i64, dest: &Path) -> Option<PathBuf> {
        let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd, "reason": "flow app: evidence screenshot"}));
        let d = r.get("data").cloned().unwrap_or(r.clone());
        let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);
        let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;
        std::fs::copy(&local, dest).ok()?;
        Some(dest.to_path_buf())
    }

    /// JavaScript in a pup (Puppeteer) browser window. No OS cursor moves, so recordings stay clean.
    pub fn pup_eval(&self, window: &str, expression: &str, reason: &str) -> Value {
        self.call("pup_eval", &json!({"window": window, "expression": expression, "reason": reason}))
    }
}