app
Adom DS2SF - Datasheet to Symbol and Footprint
Public Made by Adomby adom
Datasheet to symbol + footprint + provenance JSON, with a live dashboard: pins, groups, land pattern, and the datasheet page every fact came from.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035
//! adom-ds2sf dashboard: the AI-drivable live app over a chip-fetcher library.
//!
//! GET / the dashboard UI
//! GET /events SSE live channel (state on connect, 15s heartbeat)
//! GET /state full dashboard state (library summary, current chip, running job)
//! GET /api/chips per-chip extraction status
//! GET /api/chip/<MPN>[/svg/<kind>|/pdf|/png]
//! POST /extract {mpn, force?, pdf?, model?, symbolOnly?, footprintOnly?, aiThread}
//! POST /select {mpn, aiThread}
//! POST /ui/toast {text, kind} POST /ui/click {selector} POST /ui/reveal {selector}
//! GET/POST /console, GET/POST /settings, GET /health, GET /api/status, POST /shutdown
//!
//! Lifecycle (app-creator-server-lifecycle): registry entry under
//! ~/.adom/instances/adom-ds2sf/<thread>.json, 24h idle reaper with a per-thread
//! snapshot, --keep-alive / --ttl overrides. Every state-changing POST needs an
//! ai-thread name (app-creator-ai-thread-name).
use serde_json::{json, Value};
use std::collections::VecDeque;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Sender};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
pub const APP: &str = "adom-ds2sf";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DEFAULT_PORT: u16 = 8791;
pub const WEBVIEW_TAB: &str = "DS2SF";
const APP_HTML: &str = include_str!("app.html");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 3.5h9l4 4V13"/><path d="M13 3.5v4h4"/><path d="M7 8.5h3M7 11.5h5"/><rect x="12" y="14" width="8" height="7" rx="1"/><path d="M14 14v-2M18 14v-2M14 21v1.5M18 21v1.5M12 16.5h-2M12 19h-2"/></svg>"##;
// ───────────────────────── state ─────────────────────────
#[derive(Clone, Default)]
pub struct Job {
pub mpn: String,
pub started: u64,
pub stage: String,
pub args: Vec<String>,
pub ai_thread: String,
}
#[derive(Default)]
struct Dash {
current: String,
job: Option<Job>,
last_job: Option<Value>,
log: VecDeque<String>,
console: Vec<String>,
/// Threads that touched this instance, most recent first.
threads: Vec<String>,
}
static DASH: Mutex<Option<Dash>> = Mutex::new(None);
static CLIENTS: Mutex<Vec<Sender<Vec<u8>>>> = Mutex::new(Vec::new());
static LAST_ACTIVITY: Mutex<Option<Instant>> = Mutex::new(None);
static OWNER: Mutex<Option<(String, u16)>> = Mutex::new(None);
static SETTINGS: Mutex<Option<Value>> = Mutex::new(None);
/// Recent UI directives with a sequence number, so a page that only polls
/// (the cloud proxy buffers SSE) still executes click/tab/toast/reveal.
static UI_EVENTS: Mutex<VecDeque<(u64, String, Value)>> = Mutex::new(VecDeque::new());
static UI_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn dash<R>(f: impl FnOnce(&mut Dash) -> R) -> R {
let mut g = DASH.lock().unwrap();
if g.is_none() {
*g = Some(Dash::default());
}
f(g.as_mut().unwrap())
}
fn touch() {
*LAST_ACTIVITY.lock().unwrap() = Some(Instant::now());
}
fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
// ───────────────────────── settings ─────────────────────────
fn settings_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_default();
Path::new(&home).join(".config/adom-ds2sf/settings.json")
}
fn default_settings() -> Value {
json!({
"ttl_hours": 24,
"default_surface": "wv",
"open_in_background": true,
"default_model": "claude-opus-4-7",
"dev_mode": false,
"library_root": Value::Null,
})
}
fn settings_enums() -> Value {
json!({
"ttl_hours": {"type": "number", "min": 1, "max": 720},
"default_surface": {"type": "enum", "values": ["wv", "pup", "hbw", "nb"]},
"open_in_background": {"type": "bool"},
"default_model": {"type": "enum", "values": ["claude-opus-4-7", "claude-sonnet-4-5", "claude-haiku-4-5"]},
"dev_mode": {"type": "bool"},
"library_root": {"type": "path"},
})
}
pub fn settings() -> Value {
let mut g = SETTINGS.lock().unwrap();
if g.is_none() {
let mut s = default_settings();
if let Ok(raw) = std::fs::read_to_string(settings_path()) {
if let Ok(Value::Object(saved)) = serde_json::from_str::<Value>(&raw) {
for (k, v) in saved {
s[k] = v;
}
}
}
*g = Some(s);
}
g.clone().unwrap()
}
fn save_settings(s: &Value) {
if let Some(p) = settings_path().parent() {
let _ = std::fs::create_dir_all(p);
}
let _ = std::fs::write(settings_path(), serde_json::to_string_pretty(s).unwrap_or_default());
*SETTINGS.lock().unwrap() = Some(s.clone());
}
// ───────────────────────── library ─────────────────────────
pub fn library_root() -> Option<PathBuf> {
if let Some(r) = settings().get("library_root").and_then(|v| v.as_str()) {
if !r.is_empty() && Path::new(r).is_dir() {
return Some(PathBuf::from(r));
}
}
let home = std::env::var("HOME").unwrap_or_default();
for c in [
format!("{home}/project/chip-fetcher/library"),
format!("{home}/project/adom-chip-fetcher/library"),
"chip-fetcher/library".to_string(),
] {
if Path::new(&c).is_dir() {
return Some(PathBuf::from(c));
}
}
None
}
fn read_json(p: &Path) -> Value {
std::fs::read_to_string(p)
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.unwrap_or(Value::Null)
}
fn mtime(p: &Path) -> u64 {
std::fs::metadata(p)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn pdfs_in(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.filter(|e| e.path().extension().map(|x| x.eq_ignore_ascii_case("pdf")).unwrap_or(false))
.map(|e| e.file_name().to_string_lossy().to_string())
.collect()
})
.unwrap_or_default();
v.sort();
v
}
/// One row of the library rail: everything the UI needs without opening the big JSONs.
fn chip_summary(dir: &Path) -> Value {
let mpn = dir.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let f = |suffix: &str| dir.join(format!("{mpn}{suffix}"));
let result = read_json(&f("-extraction.result.json"));
let symbol = read_json(&f("-symbol.extracted.json"));
let validation = read_json(&f("-validation-status.json"));
let info = read_json(&dir.join("info.json"));
let pdfs = pdfs_in(dir);
let has_pdf = f(".pdf").is_file();
let status = match result.get("status").and_then(|s| s.as_str()) {
Some(s) => s.to_string(),
None if has_pdf || !pdfs.is_empty() => "not_extracted".to_string(),
None => "no_datasheet".to_string(),
};
let pins = symbol.get("pins").and_then(|p| p.as_array()).map(|a| a.len()).unwrap_or(0);
let package = symbol.get("package").cloned()
.or_else(|| info.get("package").cloned())
.unwrap_or(Value::Null);
let manufacturer = symbol.get("manufacturer").cloned()
.or_else(|| info.get("manufacturer").cloned())
.unwrap_or(Value::Null);
let has_3d = f("-3d-iso-named.png").is_file() || f("-3d-iso.png").is_file();
let running = dash(|d| d.job.as_ref().map(|j| j.mpn == mpn).unwrap_or(false));
json!({
"mpn": mpn,
"status": status,
"running": running,
"summary": result.get("summary").cloned().unwrap_or(Value::Null),
"review_count": result.get("review_required").and_then(|r| r.as_array()).map(|a| a.len()).unwrap_or(0),
"hint_count": result.get("hints").and_then(|r| r.as_array()).map(|a| a.len()).unwrap_or(0),
"extractor_version": result.get("extractor_version").cloned().unwrap_or(Value::Null),
"model": result.get("claude_model").cloned().unwrap_or(Value::Null),
"extracted_at": mtime(&f("-extraction.result.json")),
"pins": pins,
"package": package,
"manufacturer": manufacturer,
"concur": validation.get("concur_status").cloned().unwrap_or(Value::Null),
"has_pdf": has_pdf,
"pdfs": pdfs,
"has_symbol": f("-symbol.extracted.json").is_file(),
"has_footprint": f("-footprint.extracted.json").is_file(),
"has_provenance": f("-extraction.provenance.json").is_file(),
"has_3d": has_3d,
"svgs": {
"ds2sf": f("-footprint.ds2sf.svg").is_file(),
"pcbnew": f("-footprint.pcbnew.svg").is_file(),
"symbol": f("-symbol.ds2sf.svg").is_file(),
},
})
}
fn list_chips() -> Vec<Value> {
let root = match library_root() {
Some(r) => r,
None => return vec![],
};
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
.map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect())
.unwrap_or_default();
dirs.sort_by_key(|p| p.file_name().map(|s| s.to_string_lossy().to_lowercase()));
dirs.iter().map(|d| chip_summary(d)).collect()
}
fn safe_mpn(mpn: &str) -> bool {
!mpn.is_empty() && !mpn.contains('/') && !mpn.contains('\\') && !mpn.starts_with('.')
}
fn chip_dir(mpn: &str) -> Option<PathBuf> {
if !safe_mpn(mpn) {
return None;
}
let d = library_root()?.join(mpn);
if d.is_dir() { Some(d) } else { None }
}
fn chip_detail(mpn: &str) -> Value {
let dir = match chip_dir(mpn) {
Some(d) => d,
None => return Value::Null,
};
let f = |suffix: &str| dir.join(format!("{mpn}{suffix}"));
let mut cache: Vec<String> = std::fs::read_dir(dir.join(".ds2sf-cache"))
.map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect())
.unwrap_or_default();
cache.sort();
json!({
"summary": chip_summary(&dir),
"dir": dir.to_string_lossy(),
"result": read_json(&f("-extraction.result.json")),
"symbol": read_json(&f("-symbol.extracted.json")),
"footprint": read_json(&f("-footprint.extracted.json")),
"provenance": read_json(&f("-extraction.provenance.json")),
"validation": read_json(&f("-validation-status.json")),
"concur": read_json(&f("-concur.result.json")),
"info": read_json(&dir.join("info.json")),
"cache_files": cache,
})
}
// ───────────────────────── SSE ─────────────────────────
struct ChanReader {
rx: std::sync::mpsc::Receiver<Vec<u8>>,
buf: Vec<u8>,
pos: usize,
}
impl Read for ChanReader {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
if self.pos >= self.buf.len() {
match self.rx.recv() {
Ok(b) => {
self.buf = b;
self.pos = 0;
}
Err(_) => return Ok(0),
}
}
let n = out.len().min(self.buf.len() - self.pos);
out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]);
self.pos += n;
Ok(n)
}
}
/// tiny_http sits on a 1 KB BufWriter, so every SSE frame is padded past it
/// with a comment line: a write >= the buffer size goes straight to the socket.
fn sse_frame(event: &str, data: &Value) -> Vec<u8> {
let mut s = format!("event: {event}\ndata: {}\n\n", data);
if s.len() < 1200 {
let pad = 1200 - s.len();
s.push_str(": ");
s.push_str(&" ".repeat(pad));
s.push_str("\n\n");
}
s.into_bytes()
}
pub fn broadcast(event: &str, data: Value) {
if matches!(event, "toast" | "click" | "reveal" | "cursor" | "tab" | "reload" | "eval" | "chip") {
let seq = UI_SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut q = UI_EVENTS.lock().unwrap();
q.push_back((seq, event.to_string(), data.clone()));
while q.len() > 50 {
q.pop_front();
}
}
let frame = sse_frame(event, &data);
let mut clients = CLIENTS.lock().unwrap();
clients.retain(|tx| tx.send(frame.clone()).is_ok());
}
fn broadcast_state() {
broadcast("state", state_json());
}
fn push_log(line: String) {
let line = crate::io::strip_ansi(&line);
dash(|d| {
d.log.push_back(line.clone());
while d.log.len() > 600 {
d.log.pop_front();
}
if let Some(j) = d.job.as_mut() {
let l = line.trim();
let stage = if l.starts_with("[1a]") {
Some("symbol pass (Claude reading the pinout)")
} else if l.starts_with("[1b]") {
Some("footprint pass (Claude reading the package drawing)")
} else if l.starts_with("[svg]") {
Some("rendering footprint SVG")
} else if l.starts_with("[normalize]") || l.contains("service-kicad") {
Some("normalizing package against the KiCad stdlib")
} else if l.starts_with("OK:") || l.starts_with("ERROR:") {
Some("writing outputs")
} else {
None
};
if let Some(s) = stage {
j.stage = s.to_string();
}
}
});
broadcast("log", json!({ "line": line }));
}
// ───────────────────────── extraction job ─────────────────────────
fn start_job(body: &Value, ai_thread: &str) -> Result<Value, (u16, Value)> {
let mpn = body.get("mpn").and_then(|m| m.as_str()).unwrap_or("").to_string();
let dir = chip_dir(&mpn).ok_or_else(|| {
(404, json!({ "ok": false, "errorCode": "chip_not_found",
"error": format!("no chip directory for {mpn:?} in the library"),
"hint": "Land it first with `adom-chip-fetcher fetch <MPN>`, then extract." }))
})?;
if let Some(j) = dash(|d| d.job.clone()) {
return Err((409, json!({ "ok": false, "errorCode": "job_running",
"error": format!("an extraction is already running for {}", j.mpn),
"hint": "Wait for it to finish (watch GET /state .job) or open the Log tab. One Claude extraction at a time keeps the datasheet reads honest." })));
}
let model = body.get("model").and_then(|m| m.as_str())
.map(String::from)
.unwrap_or_else(|| settings().get("default_model").and_then(|m| m.as_str()).unwrap_or("claude-opus-4-7").to_string());
let mut args: Vec<String> = vec!["extract".into(), dir.to_string_lossy().to_string(), "--model".into(), model.clone()];
if let Some(p) = body.get("pdf").and_then(|p| p.as_str()).filter(|p| !p.is_empty()) {
let pdf = if p.contains('/') { PathBuf::from(p) } else { dir.join(p) };
if !pdf.is_file() {
return Err((404, json!({ "ok": false, "errorCode": "pdf_not_found",
"error": format!("{} is not a file", pdf.display()),
"hint": "Pick one of GET /api/chip/<MPN> .summary.pdfs, or fetch a datasheet with `adom-chip-fetcher pup-pdf-fetch <MPN> --pdf-url <url>`." })));
}
args.push("--pdf".into());
args.push(pdf.to_string_lossy().to_string());
} else if !dir.join(format!("{mpn}.pdf")).is_file() {
let pdfs = pdfs_in(&dir);
if let Some(first) = pdfs.first() {
args.push("--pdf".into());
args.push(dir.join(first).to_string_lossy().to_string());
} else {
return Err((422, json!({ "ok": false, "errorCode": "no_datasheet",
"error": format!("{mpn} has no datasheet PDF in its chip directory"),
"hint": format!("Fetch one first: `adom-chip-fetcher --ai-thread \"{ai_thread}\" pup-pdf-fetch {mpn} --pdf-url <manufacturer PDF url>`, then POST /extract again.") })));
}
}
if body.get("force").and_then(|f| f.as_bool()).unwrap_or(false) {
args.push("--force".into());
}
if body.get("symbolOnly").and_then(|f| f.as_bool()).unwrap_or(false) {
args.push("--symbol-only".into());
}
if body.get("footprintOnly").and_then(|f| f.as_bool()).unwrap_or(false) {
args.push("--footprint-only".into());
}
let job = Job { mpn: mpn.clone(), started: now_unix(), stage: "starting".into(), args: args.clone(), ai_thread: ai_thread.to_string() };
dash(|d| {
d.job = Some(job.clone());
d.log.clear();
d.current = mpn.clone();
});
push_log(format!("thread \"{ai_thread}\": adom-ds2sf {}", args.join(" ")));
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("adom-ds2sf"));
std::thread::spawn(move || run_job(exe, args, mpn));
broadcast_state();
broadcast("toast", json!({ "text": format!("Extraction started for {}", job.mpn), "kind": "info" }));
Ok(json!({ "ok": true, "job": job_json(&job) }))
}
fn run_job(exe: PathBuf, args: Vec<String>, mpn: String) {
use std::io::BufRead;
use std::process::{Command, Stdio};
let child = Command::new(&exe)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let exit = match child {
Ok(mut ch) => {
let out = ch.stdout.take();
let err = ch.stderr.take();
let t1 = std::thread::spawn(move || {
if let Some(o) = out {
for l in std::io::BufReader::new(o).lines().flatten() {
push_log(l);
}
}
});
let t2 = std::thread::spawn(move || {
if let Some(e) = err {
for l in std::io::BufReader::new(e).lines().flatten() {
push_log(l);
}
}
});
let st = ch.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
let _ = t1.join();
let _ = t2.join();
st
}
Err(e) => {
push_log(format!("ERROR: could not spawn {}: {e}", exe.display()));
-1
}
};
let dir = chip_dir(&mpn);
let result = dir.map(|d| read_json(&d.join(format!("{mpn}-extraction.result.json")))).unwrap_or(Value::Null);
let status = result.get("status").and_then(|s| s.as_str()).unwrap_or(if exit == 0 { "ok" } else { "unrecoverable" }).to_string();
let summary = result.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string();
dash(|d| {
d.job = None;
d.last_job = Some(json!({ "mpn": mpn, "exit": exit, "status": status, "summary": summary, "finished": now_unix() }));
});
push_log(format!("[done] exit {exit} status {status}"));
let kind = match status.as_str() { "ok" => "success", "needs_better_pdf" => "info", _ => "error" };
let text = match status.as_str() {
"ok" => format!("{mpn}: extraction complete"),
"needs_better_pdf" => format!("{mpn}: needs a better datasheet PDF"),
_ => format!("{mpn}: extraction failed (exit {exit})"),
};
broadcast("toast", json!({ "text": text, "kind": kind, "sticky": kind == "error" }));
broadcast_state();
broadcast("chip", json!({ "mpn": mpn }));
touch();
}
fn job_json(j: &Job) -> Value {
json!({ "mpn": j.mpn, "started": j.started, "elapsed": now_unix().saturating_sub(j.started),
"stage": j.stage, "args": j.args, "aiThread": j.ai_thread })
}
// ───────────────────────── state ─────────────────────────
fn state_json() -> Value {
let chips = list_chips();
let counts = |s: &str| chips.iter().filter(|c| c.get("status").and_then(|x| x.as_str()) == Some(s)).count();
let (cur, job, last_job, threads) = dash(|d| (d.current.clone(), d.job.clone(), d.last_job.clone(), d.threads.clone()));
let (thread, port) = OWNER.lock().unwrap().clone().unwrap_or_default();
json!({
"app": APP,
"version": VERSION,
"port": port,
"thread": thread,
"threads": threads,
"library_root": library_root().map(|p| p.to_string_lossy().to_string()),
"counts": {
"total": chips.len(),
"ok": counts("ok"),
"needs_better_pdf": counts("needs_better_pdf"),
"unrecoverable": counts("unrecoverable"),
"not_extracted": counts("not_extracted"),
"no_datasheet": counts("no_datasheet"),
},
"chips": chips,
"current": cur,
"job": job.as_ref().map(job_json),
"last_job": last_job,
"settings": settings(),
"proxy_url": proxy_url_for_port(port),
"log_len": dash(|d| d.log.len()),
"ui": UI_EVENTS.lock().unwrap().iter().map(|(seq, ev, data)| json!({ "seq": seq, "event": ev, "data": data })).collect::<Vec<_>>(),
"_hint": "POST /extract {mpn, force?, pdf?, model?, symbolOnly?, footprintOnly?, aiThread} runs the extractor; POST /select {mpn, aiThread} focuses a chip; POST /ui/toast {text, kind} narrates; GET /events is the SSE channel; GET /console reads the UI log.",
})
}
// ───────────────────────── URLs, registry, lifecycle ─────────────────────────
pub fn proxy_url_for_port(port: u16) -> String {
if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
return format!("https://{host}/proxy/{port}/");
}
if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {
return format!("https://{slug}.adom.cloud/proxy/{port}/");
}
if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") {
if tpl.contains("{{port}}") {
return tpl.replace("{{port}}", &port.to_string());
}
}
format!("http://127.0.0.1:{port}/")
}
pub fn direct_url(port: u16) -> String {
format!("http://127.0.0.1:{port}/")
}
pub fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let t = out.trim_matches('-').to_string();
if t.is_empty() { "anonymous".into() } else { t }
}
pub fn instances_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_default();
Path::new(&home).join(".adom/instances").join(APP)
}
fn registry_path(thread: &str) -> PathBuf {
instances_dir().join(format!("{}.json", slugify(thread)))
}
fn write_registry(thread: &str, port: u16, started: u64) {
let _ = std::fs::create_dir_all(instances_dir());
let entry = json!({
"pid": std::process::id(), "port": port, "thread": thread, "app": APP,
"version": VERSION, "startedAt": started, "lastActivity": now_unix(),
"url": direct_url(port), "proxyUrl": proxy_url_for_port(port),
});
let _ = std::fs::write(registry_path(thread), serde_json::to_string_pretty(&entry).unwrap_or_default());
}
fn snapshot(thread: &str) {
let dir = instances_dir().join("snapshots");
let _ = std::fs::create_dir_all(&dir);
let (cur, log) = dash(|d| (d.current.clone(), d.log.iter().cloned().collect::<Vec<_>>()));
let snap = json!({ "thread": thread, "current": cur, "log": log, "savedAt": now_unix() });
let _ = std::fs::write(dir.join(format!("{}.json", slugify(thread))), serde_json::to_string_pretty(&snap).unwrap_or_default());
}
fn restore_snapshot(thread: &str) -> bool {
let p = instances_dir().join("snapshots").join(format!("{}.json", slugify(thread)));
let v = read_json(&p);
if v.is_null() {
return false;
}
dash(|d| {
d.current = v.get("current").and_then(|c| c.as_str()).unwrap_or("").to_string();
if let Some(lines) = v.get("log").and_then(|l| l.as_array()) {
d.log = lines.iter().filter_map(|l| l.as_str().map(String::from)).collect();
}
});
true
}
/// Registry rows with liveness (dead pids pruned), for `adom-ds2sf ls`.
pub fn list_instances() -> Vec<Value> {
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(instances_dir()) {
for e in rd.flatten() {
let p = e.path();
if p.extension().map(|x| x == "json").unwrap_or(false) {
let v = read_json(&p);
let pid = v.get("pid").and_then(|x| x.as_u64()).unwrap_or(0);
let alive = pid > 0 && Path::new(&format!("/proc/{pid}")).exists();
if alive {
out.push(v);
} else {
let _ = std::fs::remove_file(&p);
}
}
}
}
out
}
pub fn remove_webview_tab() {
let _ = std::process::Command::new("timeout")
.args(["10", "adom-cli", "hydrogen", "workspace", "remove-tab", "--name", WEBVIEW_TAB])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
fn shutdown_sequence(thread: &str, reason: &str) -> ! {
eprintln!("[adom-ds2sf] shutting down ({reason})");
snapshot(thread);
let _ = std::fs::remove_file(registry_path(thread));
remove_webview_tab();
std::process::exit(0)
}
/// Probe a port: Some(version) when an adom-ds2sf answers /health there.
pub fn probe(port: u16) -> Option<Value> {
use std::io::Write;
use std::net::TcpStream;
let addr = format!("127.0.0.1:{port}");
let mut s = TcpStream::connect_timeout(&addr.parse().ok()?, Duration::from_millis(600)).ok()?;
let _ = s.set_read_timeout(Some(Duration::from_millis(1500)));
let _ = s.write_all(format!("GET /health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n").as_bytes());
let mut buf = String::new();
let _ = s.read_to_string(&mut buf);
let body = buf.split("\r\n\r\n").nth(1)?;
let v: Value = serde_json::from_str(body.trim()).ok()?;
if v.get("app").and_then(|a| a.as_str()) == Some(APP) { Some(v) } else { None }
}
pub fn post_json(port: u16, path: &str, body: &Value) -> Option<Value> {
use std::io::Write;
use std::net::TcpStream;
let addr = format!("127.0.0.1:{port}");
let mut s = TcpStream::connect_timeout(&addr.parse().ok()?, Duration::from_millis(800)).ok()?;
let _ = s.set_read_timeout(Some(Duration::from_secs(10)));
let b = body.to_string();
let _ = s.write_all(format!("POST {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", b.len(), b).as_bytes());
let mut buf = String::new();
let _ = s.read_to_string(&mut buf);
let body = buf.split("\r\n\r\n").nth(1)?;
serde_json::from_str(body.trim()).ok()
}
pub fn post_json_host(host: &str, port: u16, path: &str, body: &Value) -> Option<Value> {
use std::io::Write;
use std::net::{TcpStream, ToSocketAddrs};
let addr = (host, port).to_socket_addrs().ok()?.next()?;
let mut s = TcpStream::connect_timeout(&addr, Duration::from_millis(800)).ok()?;
let _ = s.set_read_timeout(Some(Duration::from_secs(10)));
let b = body.to_string();
let _ = s.write_all(format!("POST {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", b.len(), b).as_bytes());
let mut buf = String::new();
let _ = s.read_to_string(&mut buf);
let body = buf.split("\r\n\r\n").nth(1)?;
Some(serde_json::from_str(body.trim()).unwrap_or(json!({ "raw": body })))
}
pub fn get_json(port: u16, path: &str) -> Option<Value> {
use std::io::Write;
use std::net::TcpStream;
let addr = format!("127.0.0.1:{port}");
let mut s = TcpStream::connect_timeout(&addr.parse().ok()?, Duration::from_millis(800)).ok()?;
let _ = s.set_read_timeout(Some(Duration::from_secs(10)));
let _ = s.write_all(format!("GET {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n").as_bytes());
let mut buf = String::new();
let _ = s.read_to_string(&mut buf);
let body = buf.split("\r\n\r\n").nth(1)?;
serde_json::from_str(body.trim()).ok()
}
pub fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0").ok().and_then(|l| l.local_addr().ok()).map(|a| a.port()).unwrap_or(DEFAULT_PORT)
}
// ───────────────────────── server ─────────────────────────
pub struct ServeOpts {
pub port: u16,
pub ai_thread: String,
pub keep_alive: bool,
pub ttl_hours: Option<f64>,
}
pub fn run(opts: ServeOpts) -> anyhow::Result<()> {
let port = opts.port;
let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;
let started = now_unix();
*OWNER.lock().unwrap() = Some((opts.ai_thread.clone(), port));
touch();
dash(|d| d.threads.insert(0, opts.ai_thread.clone()));
if restore_snapshot(&opts.ai_thread) {
eprintln!("[adom-ds2sf] restored snapshot for thread \"{}\"", opts.ai_thread);
}
write_registry(&opts.ai_thread, port, started);
eprintln!("[adom-ds2sf] v{VERSION} serving on {} (thread \"{}\")", direct_url(port), opts.ai_thread);
// Heartbeat + reaper + registry refresh.
let ttl_h = opts.ttl_hours.unwrap_or_else(|| settings().get("ttl_hours").and_then(|t| t.as_f64()).unwrap_or(24.0));
let keep_alive = opts.keep_alive;
let thread = opts.ai_thread.clone();
std::thread::spawn(move || {
let mut tick = 0u64;
loop {
std::thread::sleep(Duration::from_secs(15));
{
let mut clients = CLIENTS.lock().unwrap();
clients.retain(|tx| tx.send(b": hb\n\n".to_vec()).is_ok());
}
tick += 1;
if tick % 4 == 0 {
write_registry(&thread, port, started);
let idle = LAST_ACTIVITY.lock().unwrap().map(|t| t.elapsed()).unwrap_or_default();
let busy = dash(|d| d.job.is_some());
if !keep_alive && !busy && idle > Duration::from_secs_f64(ttl_h * 3600.0) {
shutdown_sequence(&thread, &format!("idle for {:.1}h, ttl {ttl_h}h", idle.as_secs_f64() / 3600.0));
}
}
}
});
for req in server.incoming_requests() {
std::thread::spawn(move || handle(req));
}
Ok(())
}
fn handle(mut req: Request) {
let full = req.url().to_string();
let url = full.split('?').next().unwrap_or("/").to_string();
let query = full.splitn(2, '?').nth(1).unwrap_or("").to_string();
let method = req.method().clone();
let mut body = String::new();
if matches!(method, Method::Post) {
let _ = req.as_reader().read_to_string(&mut body);
}
if url != "/events" && url != "/state" && url != "/health" && url != "/api/status" && url != "/console" {
touch();
}
if matches!(method, Method::Options) {
let _ = req.respond(cors(Response::from_string("").with_status_code(204)));
return;
}
if url == "/events" && matches!(method, Method::Get) {
let (tx, rx) = channel::<Vec<u8>>();
let mut first = Vec::new();
first.extend_from_slice(format!(":{}\n\n", " ".repeat(32768)).as_bytes());
first.extend_from_slice(&sse_frame("state", &state_json()));
let _ = tx.send(first);
CLIENTS.lock().unwrap().push(tx);
let reader = ChanReader { rx, buf: Vec::new(), pos: 0 };
let resp = Response::new(
StatusCode(200),
vec![
hdr("Content-Type", "text/event-stream"),
hdr("Cache-Control", "no-cache"),
hdr("X-Accel-Buffering", "no"),
hdr("Access-Control-Allow-Origin", "*"),
],
reader,
None,
None,
);
let _ = req.respond(resp);
return;
}
let shutting_down = url == "/shutdown" && matches!(method, Method::Post);
let (thread_for_exit, resp) = route(&method, &url, &query, &body);
let _ = req.respond(resp);
if shutting_down {
if let Some(t) = thread_for_exit {
std::thread::sleep(Duration::from_millis(150));
shutdown_sequence(&t, "POST /shutdown");
}
}
}
type Resp = Response<std::io::Cursor<Vec<u8>>>;
fn ai_thread_of(body: &Value) -> Option<String> {
body.get("aiThread").and_then(|t| t.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
fn identity_refusal(verb: &str) -> Resp {
json_status(400, &json!({
"ok": false,
"errorCode": "caller_identity_required",
"error": "This app requires every AI caller to give its ai-thread name.",
"hint": format!("Re-run with the global flag: adom-ds2sf --ai-thread \"<name>\" {verb}, or add a top-level \"aiThread\" field if you are POSTing directly. Pick a short name a HUMAN would recognize for THIS conversation (like 'ds2sf' or 'stm32 board review'), and reuse it on every call to every Adom tool."),
}))
}
fn note_thread(t: &str, what: &str) {
dash(|d| {
d.threads.retain(|x| x != t);
d.threads.insert(0, t.to_string());
d.threads.truncate(12);
d.console.push(format!("[thread] \"{t}\": {what}"));
let n = d.console.len();
if n > 500 {
d.console.drain(0..n - 500);
}
});
}
fn route(method: &Method, url: &str, query: &str, body: &str) -> (Option<String>, Resp) {
let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let r = match (method, url) {
(Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),
(Method::Get, "/favicon.svg") => Response::from_string(FAVICON).with_header(hdr("Content-Type", "image/svg+xml")),
(Method::Get, "/health") | (Method::Get, "/api/status") => {
let (thread, port) = OWNER.lock().unwrap().clone().unwrap_or_default();
cors(json_ok(&json!({ "ok": true, "app": APP, "version": VERSION, "port": port, "thread": thread,
"job": dash(|d| d.job.clone()).map(|j| job_json(&j)), "chips": list_chips().len() })))
}
(Method::Get, "/version") => json_ok(&json!({ "app": APP, "version": VERSION })),
(Method::Get, "/state") => cors(json_ok(&state_json())),
(Method::Get, "/api/chips") => cors(json_ok(&json!({ "chips": list_chips(), "current": dash(|d| d.current.clone()) }))),
(Method::Get, "/settings") => json_ok(&json!({ "settings": settings(), "enums": settings_enums(), "version": VERSION })),
(Method::Post, "/settings") => {
let t = match ai_thread_of(&v) { Some(t) => t, None => return (None, identity_refusal("settings set <key> <value>")) };
let mut s = settings();
let enums = settings_enums();
if let Some(obj) = v.as_object() {
for (k, val) in obj {
if k == "aiThread" { continue; }
let spec = match enums.get(k) {
Some(sp) => sp,
None => return (None, json_status(400, &json!({ "ok": false, "errorCode": "unknown_setting",
"error": format!("no setting named {k:?}"), "hint": format!("Valid keys: {}", enums.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>().join(", ")).unwrap_or_default()) }))),
};
let ok = match spec.get("type").and_then(|t| t.as_str()) {
Some("bool") => val.is_boolean(),
Some("number") => val.as_f64().map(|n| n >= spec["min"].as_f64().unwrap_or(0.0) && n <= spec["max"].as_f64().unwrap_or(1e9)).unwrap_or(false),
Some("enum") => val.as_str().map(|s| spec["values"].as_array().map(|a| a.iter().any(|x| x.as_str() == Some(s))).unwrap_or(false)).unwrap_or(false),
_ => val.is_string() || val.is_null(),
};
if !ok {
return (None, json_status(400, &json!({ "ok": false, "errorCode": "invalid_setting_value",
"error": format!("{k} cannot be {val}"), "hint": format!("Allowed: {spec}") })));
}
s[k] = val.clone();
}
}
save_settings(&s);
note_thread(&t, "settings updated");
broadcast_state();
json_ok(&json!({ "ok": true, "settings": s }))
}
(Method::Get, "/console") => json_ok(&json!({ "lines": dash(|d| d.console.clone()) })),
(Method::Post, "/console") => {
let line = format!("[{}] {}", v.get("level").and_then(|l| l.as_str()).unwrap_or("log"), v.get("msg").and_then(|m| m.as_str()).unwrap_or(""));
dash(|d| {
d.console.push(line);
let n = d.console.len();
if n > 500 { d.console.drain(0..n - 500); }
});
json_ok(&json!({ "ok": true }))
}
(Method::Get, "/log") => json_ok(&json!({ "lines": dash(|d| d.log.iter().cloned().collect::<Vec<_>>()), "job": dash(|d| d.job.clone()).map(|j| job_json(&j)) })),
(Method::Post, "/select") => {
let t = match ai_thread_of(&v) { Some(t) => t, None => return (None, identity_refusal("select <MPN>")) };
let mpn = v.get("mpn").and_then(|m| m.as_str()).unwrap_or("").to_string();
if chip_dir(&mpn).is_none() {
return (None, json_status(404, &json!({ "ok": false, "errorCode": "chip_not_found", "error": format!("{mpn:?} is not in the library"), "hint": "GET /api/chips lists what is." })));
}
dash(|d| d.current = mpn.clone());
note_thread(&t, &format!("select {mpn}"));
broadcast_state();
json_ok(&json!({ "ok": true, "current": mpn }))
}
(Method::Post, "/extract") => {
let t = match ai_thread_of(&v) { Some(t) => t, None => return (None, identity_refusal("extract <MPN>")) };
note_thread(&t, &format!("extract {}", v.get("mpn").and_then(|m| m.as_str()).unwrap_or("?")));
match start_job(&v, &t) {
Ok(j) => json_ok(&j),
Err((code, e)) => json_status(code, &e),
}
}
(Method::Post, "/ui/toast") => {
let text = v.get("text").or(v.get("message")).and_then(|x| x.as_str()).unwrap_or("").to_string();
let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("info").to_string();
broadcast("toast", json!({ "text": text, "kind": kind, "sticky": v.get("sticky").and_then(|s| s.as_bool()).unwrap_or(false) }));
json_ok(&json!({ "ok": true }))
}
(Method::Post, "/ui/click") | (Method::Post, "/ui/reveal") | (Method::Post, "/ui/cursor") | (Method::Post, "/ui/tab") | (Method::Post, "/ui/reload") => {
let ev = &url[4..];
broadcast(ev, v.clone());
json_ok(&json!({ "ok": true, "event": ev }))
}
(Method::Post, "/ui/eval") => {
if !settings().get("dev_mode").and_then(|d| d.as_bool()).unwrap_or(false) {
return (None, json_status(403, &json!({ "ok": false, "errorCode": "dev_mode_off", "error": "eval is only available in dev mode", "hint": "adom-ds2sf --ai-thread \"<name>\" settings set dev_mode true" })));
}
broadcast("eval", v.clone());
json_ok(&json!({ "ok": true }))
}
(Method::Post, "/shutdown") => {
let t = match ai_thread_of(&v) { Some(t) => t, None => return (None, identity_refusal("shutdown")) };
let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("");
if reason.len() < 10 {
return (None, json_status(400, &json!({ "ok": false, "errorCode": "reason_required",
"error": "Shutting down a dashboard other threads may be using requires a reason.",
"hint": "Re-call with a \"reason\" field (>= 10 chars) quoting what the USER asked for, e.g. {\"reason\": \"user said close the ds2sf dashboard\"}. If nobody asked, leave it: the 24h idle reaper cleans up by itself." })));
}
note_thread(&t, &format!("shutdown: {reason}"));
let owner = OWNER.lock().unwrap().clone().map(|o| o.0).unwrap_or(t);
return (Some(owner), json_ok(&json!({ "ok": true, "shutting_down": true })));
}
_ if url.starts_with("/api/chip/") && matches!(method, Method::Get) => {
let rest = &url["/api/chip/".len()..];
let mut parts = rest.splitn(2, '/');
let mpn = urldecode(parts.next().unwrap_or(""));
let sub = parts.next().unwrap_or("");
match chip_dir(&mpn) {
None => json_status(404, &json!({ "ok": false, "error": format!("{mpn:?} not in library") })),
Some(dir) => {
if sub.is_empty() {
cors(json_ok(&chip_detail(&mpn)))
} else if let Some(kind) = sub.strip_prefix("svg/") {
let file = match kind {
"ds2sf" => format!("{mpn}-footprint.ds2sf.svg"),
"pcbnew" => format!("{mpn}-footprint.pcbnew.svg"),
"symbol" => format!("{mpn}-symbol.ds2sf.svg"),
"authoritative" => format!("{mpn}-footprint.authoritative.svg"),
other => format!("{mpn}-{other}.svg"),
};
match std::fs::read(dir.join(&file)) {
Ok(b) => Response::from_data(b).with_header(hdr("Content-Type", "image/svg+xml")).with_header(hdr("Cache-Control", "no-cache")),
Err(_) => not_found(),
}
} else if sub == "pdf" {
let name = query.split('&').find_map(|kv| kv.strip_prefix("name=")).map(urldecode).unwrap_or_else(|| format!("{mpn}.pdf"));
if !safe_mpn(&name) || !name.to_lowercase().ends_with(".pdf") {
return (None, not_found());
}
match std::fs::read(dir.join(&name)) {
Ok(b) => Response::from_data(b).with_header(hdr("Content-Type", "application/pdf")).with_header(hdr("Content-Disposition", &format!("inline; filename=\"{name}\""))),
Err(_) => not_found(),
}
} else if sub == "png" {
let mut found = None;
for c in [format!("{mpn}-3d-iso-named.png"), format!("{mpn}-3d-iso.png")] {
if let Ok(b) = std::fs::read(dir.join(&c)) { found = Some(b); break; }
}
match found {
Some(b) => Response::from_data(b).with_header(hdr("Content-Type", "image/png")),
None => not_found(),
}
} else if let Some(name) = sub.strip_prefix("cache/") {
let name = urldecode(name);
if !safe_mpn(&name) { return (None, not_found()); }
match std::fs::read(dir.join(".ds2sf-cache").join(&name)) {
Ok(b) => Response::from_data(b).with_header(hdr("Content-Type", "text/plain; charset=utf-8")),
Err(_) => not_found(),
}
} else {
not_found()
}
}
}
}
_ => not_found(),
};
(None, r)
}
fn urldecode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() + 0 && i + 2 <= b.len() - 1 {
if let Ok(h) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(h);
i += 3;
continue;
}
}
out.push(if b[i] == b'+' { b' ' } else { b[i] });
i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
fn hdr(k: &str, v: &str) -> Header {
Header::from_bytes(k.as_bytes(), v.as_bytes()).unwrap()
}
fn html(s: &str) -> Resp {
Response::from_string(s).with_header(hdr("Content-Type", "text/html; charset=utf-8"))
}
fn json_ok(v: &Value) -> Resp {
Response::from_string(v.to_string()).with_header(hdr("Content-Type", "application/json"))
}
fn json_status(code: u16, v: &Value) -> Resp {
Response::from_string(v.to_string()).with_status_code(code).with_header(hdr("Content-Type", "application/json"))
}
fn cors(r: Resp) -> Resp {
r.with_header(hdr("Access-Control-Allow-Origin", "*"))
.with_header(hdr("Access-Control-Allow-Headers", "Content-Type"))
.with_header(hdr("Access-Control-Allow-Methods", "GET, POST, OPTIONS"))
}
fn not_found() -> Resp {
json_status(404, &json!({ "ok": false, "error": "not found" }))
}