app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footp
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
//! adom-lbr live app server — the AI-drivable surface (app-creator §7/§7b/§8).
//! Same shell as adom-symbol/adom-footprint: GET / (UI), GET /state (poller),
//! POST /load (push an .lbr — raw XML, re-linted server-side), POST /eval +
//! GET /eval/pending + POST /eval/:id/result + GET /eval/:id, POST/GET /console,
//! POST /shutdown, GET /favicon.svg. No gallia, no Node.
use anyhow::Result;
use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};
#[derive(Default, Clone)]
pub struct AppState {
pub library_name: String,
pub has_content: bool,
pub lint_passed: bool,
pub errors: Vec<(String, String)>, // (code, message)
pub warnings: Vec<(String, String)>, // (code, message)
pub symbol_count: usize,
pub package_count: usize,
pub deviceset_count: usize,
pub pin_count: usize,
pub pad_count: usize,
/// Raw `.lbr` XML retained for "Send to" delivery.
pub lbr: String,
}
static STATE: Mutex<Option<AppState>> = Mutex::new(None);
static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new());
static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None);
static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);
static EVAL_ID: AtomicU64 = AtomicU64::new(1);
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="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H15a1.5 1.5 0 0 0-1.5 1.5V20a1.5 1.5 0 0 1 1.5-1.5h3.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg>"##;
pub fn set_state(s: AppState) {
*STATE.lock().unwrap() = Some(s);
}
/// Parse + lint an `.lbr` (EAGLE XML) into the displayable app state.
pub fn analyze_lbr(content: &str) -> AppState {
let lint = crate::lint_lbr(content);
let lib = extract_attr(content, "deviceset")
.or_else(|| extract_attr(content, "symbol"))
.or_else(|| extract_attr(content, "package"))
.unwrap_or_else(|| "library".to_string());
AppState {
library_name: lib,
has_content: true,
lint_passed: lint.passed,
errors: lint.errors.iter().map(|e| (e.code.clone(), e.message.clone())).collect(),
warnings: lint.warnings.iter().map(|w| (w.code.clone(), w.message.clone())).collect(),
symbol_count: content.matches("<symbol ").count(),
package_count: content.matches("<package ").count(),
deviceset_count: content.matches("<deviceset ").count(),
pin_count: content.matches("<pin ").count(),
pad_count: content.matches("<smd ").count() + content.matches("<pad ").count(),
lbr: content.to_string(),
}
}
/// First `name="…"` attribute of the first `<tag …>` element.
fn extract_attr(content: &str, tag: &str) -> Option<String> {
let needle = format!("<{tag} ");
let i = content.find(&needle)? + needle.len();
let rest = &content[i..];
let k = rest.find("name=\"")? + "name=\"".len();
let j = rest[k..].find('"')? + k;
Some(rest[k..j].to_string())
}
fn pairs_json(v: &[(String, String)]) -> Value {
Value::Array(v.iter().map(|(c, m)| json!({ "code": c, "message": m })).collect())
}
fn state_json() -> String {
match &*STATE.lock().unwrap() {
Some(s) => json!({
"libraryName": s.library_name,
"hasContent": s.has_content,
"lintPassed": s.lint_passed,
"errors": pairs_json(&s.errors),
"warnings": pairs_json(&s.warnings),
"symbolCount": s.symbol_count,
"packageCount": s.package_count,
"devicesetCount": s.deviceset_count,
"pinCount": s.pin_count,
"padCount": s.pad_count,
})
.to_string(),
None => "{}".to_string(),
}
}
pub fn run(port: u16) -> Result<()> {
*EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());
*CONSOLE.lock().unwrap() = Some(Vec::new());
// Loopback by default: /eval queues JS for the user's browser, /deliver
// pushes files to their desktop, /shutdown kills the app — none of that
// should be LAN-reachable. ADOM_LBR_BIND=0.0.0.0 opts back in.
let bind = std::env::var("ADOM_LBR_BIND").unwrap_or_else(|_| "127.0.0.1".into());
let server = Server::http(format!("{bind}:{port}")).map_err(|e| anyhow::anyhow!("bind {bind}:{port}: {e}"))?;
eprintln!("[adom-lbr] serving on http://127.0.0.1:{port}");
// app-creator §6a — best-effort HD port registration (internal/proxy-only).
register_port(port, "adom-lbr", "EAGLE .lbr linter live app (AI-drivable)");
// Cap POST bodies: the biggest legitimate payload is a large .lbr; anything
// beyond this is a bug or abuse, not a library.
const MAX_BODY: u64 = 32 * 1024 * 1024;
for mut req in server.incoming_requests() {
let url = req.url().split('?').next().unwrap_or("/").to_string();
let method = req.method().clone();
let mut body = String::new();
if matches!(method, Method::Post) {
use std::io::Read;
let _ = req.as_reader().take(MAX_BODY).read_to_string(&mut body);
}
let resp = route(&method, &url, &body);
let _ = req.respond(resp);
if url == "/shutdown" && matches!(method, Method::Post) {
break;
}
}
unregister_port(port);
Ok(())
}
/// Best-effort HD port registration (app-creator §6a / adom-port-hints).
/// Raw TCP so we add no HTTP-client dependency; never blocks serving, never
/// fails the app if HD is absent (outside Hydrogen Desktop).
fn register_port(port: u16, name: &str, reason: &str) {
let body = format!(
"{{\"port\":{port},\"action\":\"register\",\"name\":\"{name}\",\"reason\":\"{reason}\",\"expose\":\"internal\"}}"
);
std::thread::spawn(move || hd_post(&body));
}
fn unregister_port(port: u16) {
hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}"));
}
fn hd_post(body: &str) {
use std::io::Write;
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) {
Some(a) => a,
None => return,
};
if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {
let _ = s.set_write_timeout(Some(Duration::from_millis(800)));
let req = format!(
"POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = s.write_all(req.as_bytes());
}
}
fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
match (method, url) {
(Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),
(Method::Get, "/favicon.svg") => svg(FAVICON),
(Method::Get, "/state") => json_resp(&state_json()),
(Method::Post, "/load") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
// Preferred: push raw .lbr XML and let the server lint it.
if let Some(lbr) = v.get("lbr").and_then(|x| x.as_str()) {
set_state(analyze_lbr(lbr));
}
}
json_resp("{\"ok\":true}")
}
(Method::Post, "/eval") => {
let code = serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from))
.unwrap_or_default();
let id = EVAL_ID.fetch_add(1, Ordering::SeqCst);
EVAL_PENDING.lock().unwrap().push_back((id, code));
json_resp(&json!({ "id": id.to_string() }).to_string())
}
(Method::Get, "/eval/pending") => match EVAL_PENDING.lock().unwrap().pop_front() {
Some((id, code)) => json_resp(&json!({ "id": id.to_string(), "code": code }).to_string()),
None => json_resp("{}"),
},
(Method::Get, "/console") => {
let log = CONSOLE.lock().unwrap().clone().unwrap_or_default();
json_resp(&json!({ "lines": log }).to_string())
}
(Method::Post, "/console") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
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("")
);
if let Some(buf) = CONSOLE.lock().unwrap().as_mut() {
buf.push(line);
let len = buf.len();
if len > 500 {
buf.drain(0..len - 500);
}
}
}
json_resp("{\"ok\":true}")
}
(Method::Post, "/deliver") => {
let v = serde_json::from_str::<Value>(body).unwrap_or(json!({}));
let target = v.get("target").and_then(|x| x.as_str()).unwrap_or("kicad").to_string();
let (name, lbr) = match &*STATE.lock().unwrap() {
Some(s) => (s.library_name.clone(), s.lbr.clone()),
None => (String::new(), String::new()),
};
json_resp(&crate::deliver::deliver(&name, &lbr, &target).to_string())
}
(Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),
_ if url.starts_with("/eval/") => {
let rest = &url["/eval/".len()..];
if let Some(id_str) = rest.strip_suffix("/result") {
if matches!(method, Method::Post) {
if let (Ok(id), Ok(v)) = (id_str.parse::<u64>(), serde_json::from_str::<Value>(body)) {
if let Some(map) = EVAL_RESULTS.lock().unwrap().as_mut() {
map.insert(id, v);
}
}
return json_resp("{\"ok\":true}");
}
}
if let Ok(id) = rest.parse::<u64>() {
let mut map = EVAL_RESULTS.lock().unwrap();
// remove, don't get: results are one-shot; leaving them in the
// map leaks for the life of the server.
if let Some(r) = map.as_mut().and_then(|m| m.remove(&id)) {
return json_resp(&json!({ "done": true, "result": r }).to_string());
}
return json_resp("{\"done\":false}");
}
not_found()
}
_ => not_found(),
}
}
fn hdr(ct: &str) -> Header {
Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()
}
fn html(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))
}
fn svg(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(s).with_header(hdr("image/svg+xml"))
}
fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(s).with_header(hdr("application/json"))
}
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(hdr("application/json"))
}