app
Chip Fetcher (cf)
Public Made by Adomby adom
Crawls the web on your behalf to gather everything about the chips you care about: datasheets, symbols, footprints, 3D models, stock and pricing, straight from the manufacturer first, into your EDA tool. Say "cf" to your AI.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
//! 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()
}