app
adom-nucleus
Public Made by Adomby adom
Adom Nucleus — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manager / ado
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977
//! Adom Nucleus, a standalone web app (tiny_http → Hydrogen webview),
//! built on the `adom-lbr` codec. Lists your wiki boards/projects (Personal /
//! Organization / Public), and on click opens a viewer (symbol + footprint + 3D,
//! plus the footprint LAYER STACKUP showing what layer each graphic becomes in
//! KiCad and Altium), adds components to your KiCad/Altium libraries, and
//! delivers self-contained KiCad projects.
//!
//! Routes:
//! GET / the UI (list + viewer)
//! GET /api/components list of wiki components
//! GET /api/part/<slug> {part, symbol_svg, footprint_svg, layers, glb}
//! GET /glb/<slug> proxied 3D model (GLB)
use altium_codec::{export_kicad_footprint, export_kicad_symbol, AdomLbrPart, Footprint, FpGraphic, FpLayer, Graphic, Symbol};
use anyhow::Result;
use serde_json::{json, Value};
use std::io::{Cursor, Read};
use std::process::Command;
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};
fn main() {
// `--render-board <slug>`: regenerate + upload the page's render/ assets, then
// exit (used by the publish flow). Otherwise serve on `--port <n>` (default 8785).
let mut port = 8785u16;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
if a == "--port" || a == "-p" {
if let Some(v) = args.next() {
port = v.parse().unwrap_or(port);
}
} else if let Some(v) = a.strip_prefix("--port=") {
port = v.parse().unwrap_or(port);
} else if a == "--render-board" {
let slug = args.next().unwrap_or_default();
std::process::exit(if !slug.is_empty() && render_and_upload(&slug) { 0 } else { 1 });
}
}
if let Err(e) = run(port) {
eprintln!("ERROR: {e}");
std::process::exit(1);
}
}
/// Render a board's GLB + PCB SVG + schematic SVGs live and publish them to the
/// page's `render/` folder (with a manifest), so the Manager can serve them
/// instantly. Run on every board publish/update to keep the assets fresh.
fn render_and_upload(slug: &str) -> bool {
eprintln!("rendering {slug} …");
let (sheets, pcb_svg, glb) = render_board_media(slug);
if glb.is_none() && pcb_svg.is_none() && sheets.is_empty() {
eprintln!("nothing rendered (not a KiCad board?)");
return false;
}
let work = format!("{SCRATCH}/render-{}", slug.replace(['/', ' '], "_"));
let rdir = format!("{work}/render");
let _ = std::fs::remove_dir_all(&work);
if std::fs::create_dir_all(&rdir).is_err() {
return false;
}
if let Some(g) = &glb {
let _ = std::fs::write(format!("{rdir}/board.glb"), g);
}
if let Some(s) = &pcb_svg {
let _ = std::fs::write(format!("{rdir}/pcb.svg"), s);
}
for (i, (_, svg)) in sheets.iter().enumerate() {
let _ = std::fs::write(format!("{rdir}/sheet-{i:03}.svg"), svg);
}
let sheet_names: Vec<String> = sheets.iter().map(|(n, _)| n.clone()).collect();
// ---- version archive -------------------------------------------------
// Content-hash the just-rendered assets. A new version is archived ONLY when
// the render actually differs from the last archived one (a deps/metadata-only
// republish reuses the prior version → no duplicate visual version). Older
// render/<version>/ folders on the page are preserved (we push targeted paths).
let new_hash = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
if let Some(g) = &glb { g.hash(&mut h); }
if let Some(s) = &pcb_svg { s.as_bytes().hash(&mut h); }
for (_, svg) in &sheets { svg.as_bytes().hash(&mut h); }
sheet_names.hash(&mut h);
format!("{:016x}", h.finish())
};
let version = wiki_text(slug, "package.json")
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| "0.0.0".to_string());
// prior versions[] from the existing manifest (empty on first archive / legacy pages)
let mut versions: Vec<Value> = wiki_bytes(slug, "render/manifest.json")
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|m| m.get("versions").and_then(|a| a.as_array()).cloned())
.unwrap_or_default();
let latest_hash = versions.first().and_then(|v| v.get("hash").and_then(|h| h.as_str())).unwrap_or("");
let changed = latest_hash != new_hash;
if changed {
// stage this version's assets under render/<version>/ and index it
let vdir = format!("{rdir}/{version}");
let _ = std::fs::create_dir_all(&vdir);
if let Some(g) = &glb { let _ = std::fs::write(format!("{vdir}/board.glb"), g); }
if let Some(s) = &pcb_svg { let _ = std::fs::write(format!("{vdir}/pcb.svg"), s); }
for (i, (_, svg)) in sheets.iter().enumerate() { let _ = std::fs::write(format!("{vdir}/sheet-{i:03}.svg"), svg); }
// drop any existing entry for this version string, then prepend the fresh one
versions.retain(|v| v.get("version").and_then(|x| x.as_str()) != Some(version.as_str()));
versions.insert(0, json!({"version": version, "hash": new_hash,
"glb": glb.is_some(), "pcb": pcb_svg.is_some(), "sheets": sheet_names}));
}
let current = versions.first().and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
.unwrap_or_else(|| version.clone());
let manifest = json!({
"v": 2,
"current": current,
"glb": glb.is_some(),
"pcb": pcb_svg.is_some(),
"sheets": sheet_names,
"versions": versions,
});
let _ = std::fs::write(format!("{rdir}/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap());
// Push TARGETED paths (not the whole render/ dir) so previously-archived
// render/<version>/ folders on the page are not clobbered.
let bare = slug.rsplit('/').next().unwrap_or(slug);
let mut files: Vec<String> = vec!["render/manifest.json".into()];
if glb.is_some() { files.push("render/board.glb".into()); }
if pcb_svg.is_some() { files.push("render/pcb.svg".into()); }
for i in 0..sheets.len() { files.push(format!("render/sheet-{i:03}.svg")); }
if changed { files.push(format!("render/{version}")); }
let mut args: Vec<String> = vec!["repo".into(), "push".into(), bare.into(), "--files".into()];
args.extend(files);
args.push("-m".into());
args.push(format!("render assets{}", if changed { format!(" + archive v{version}") } else { String::new() }));
let ok = Command::new("adom-wiki").args(&args).current_dir(&work)
.status().map(|s| s.success()).unwrap_or(false);
let msg = if !ok { "upload FAILED".to_string() }
else if changed { format!("uploaded render/ + archived v{version}") }
else { "uploaded render/ (no content change → no new version)".to_string() };
eprintln!("{msg}");
ok
}
const WIKI: &str = "https://wiki.adom.inc";
// Hosted headless KiCad 10 (service-kicad), renders board SVG/GLB server-side,
// so board views need no local kicad-cli and no desktop bridge.
const KICAD_SVC: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";
/// The wiki component list is slow (~8s) but stable, fetch it once, serve
/// instantly after. First click blocks; the rest are snappy.
static LIST_CACHE: Mutex<Option<String>> = Mutex::new(None);
fn cached_list() -> String {
let mut c = LIST_CACHE.lock().unwrap();
if let Some(s) = c.as_ref() {
return s.clone();
}
let s = list_components().to_string();
*c = Some(s.clone());
s
}
/// Follow a dotted path (`a.b.c`) into a JSON value.
fn dig<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = v;
for k in path.split('.') {
cur = cur.get(k)?;
}
Some(cur)
}
/// The signed-in wiki identity (username + org names) from `adom-wiki whoami`,
/// cached. Tolerant of JSON-shape differences across CLI versions; if it can't
/// be read it leaves the identity EMPTY rather than guessing, so the Personal and
/// Organization tabs simply show nothing instead of mislabeling every user.
static WHOAMI: Mutex<Option<(String, Vec<String>)>> = Mutex::new(None);
fn whoami() -> (String, Vec<String>) {
if let Some(v) = WHOAMI.lock().unwrap().clone() {
return v;
}
let mut me = String::new();
let mut orgs: Vec<String> = vec![];
if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() {
if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {
me = ["data.user.username", "data.user.name", "data.user.handle", "user.username", "data.username", "username"]
.iter()
.find_map(|p| dig(&v, p).and_then(|x| x.as_str()).filter(|s| !s.is_empty()))
.unwrap_or_default()
.to_string();
if let Some(a) = dig(&v, "data.orgs").or_else(|| v.get("orgs")).and_then(|o| o.as_array()) {
orgs = a.iter().filter_map(|o| o.get("name").and_then(|s| s.as_str()).or_else(|| o.as_str()).filter(|s| !s.is_empty()).map(String::from)).collect();
}
}
}
let res = (me, orgs);
*WHOAMI.lock().unwrap() = Some(res.clone());
res
}
// Per-user "starred" comes straight from the wiki page data (pending the wiki API
// adding a per-user `starred` field, tracked in adom/wiki issues). Starred boards
// pin to the top of each list; there is no star toggle in this app (you star on
// the wiki). Until the field lands, `starred` is simply absent → false for all.
pub fn run(port: u16) -> Result<()> {
// Loopback by default: /api/apply writes files and runs commands on the
// user's machine via the bridge, so it must not be LAN-reachable.
// ADOM_LBR_BIND=0.0.0.0 opts back in (e.g. Hydrogen Desktop port-forward).
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}"))?;
println!("OK: Adom Nucleus on http://localhost:{port}");
println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");
println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");
// Handle each request on its own thread. Many endpoints block on the desktop
// bridge (folder listing, library scans, renders); a single-threaded loop would
// serialize them, so e.g. the folder browser could wait tens of seconds behind
// the Add-Project library scan. All shared state is Mutex-guarded, so concurrent
// handlers are safe.
for mut req in server.incoming_requests() {
std::thread::spawn(move || {
let method = req.method().clone();
let url = req.url().to_string();
let mut body = String::new();
if matches!(method, Method::Post) {
// /api/apply bodies are small JSON, cap far above any real payload.
let _ = req.as_reader().take(1024 * 1024).read_to_string(&mut body);
}
let resp = route(&method, &url, &body);
let _ = req.respond(resp);
});
}
Ok(())
}
fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {
let path = url.split('?').next().unwrap_or(url);
match (method, path) {
(Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),
// toast channel: the AI narrates over the running UI with `POST /ui/toast`
// ({message,type}); the frontend drains the queue via `GET /ui/toasts`.
(Method::Post, "/ui/toast") => {
let v: serde_json::Value = serde_json::from_str(body).unwrap_or(json!({}));
let msg = v.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string();
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("info").to_string();
if !msg.is_empty() { toast_queue().lock().unwrap().push(json!({"message": msg, "type": ty})); }
json_resp("{\"ok\":true}")
}
(Method::Get, "/ui/toasts") => {
let drained: Vec<serde_json::Value> = std::mem::take(&mut *toast_queue().lock().unwrap());
json_resp(&json!({"toasts": drained}).to_string())
}
// ── embedded Tools (chip-fetcher-lite, kicad-library-manager): Nucleus
// launches each Node tool on demand and reverse-proxies it under
// /tools/<slug>/ so it renders in an iframe on Nucleus's own origin (the
// tools use document.baseURI-relative URLs, so a base-path proxy just works). ──
(Method::Post, p) if p.starts_with("/tools/launch/") => {
let slug = &p["/tools/launch/".len()..];
match ensure_tool(slug) {
Some(_) => json_resp(&json!({"ok": true, "base": format!("tools/{slug}/")}).to_string()),
None => Response::from_string(json!({"ok": false, "error": "could not start tool"}).to_string()).with_status_code(502).with_header(hdr("application/json")),
}
}
(m, p) if p.starts_with("/tools/") => {
let after = &p["/tools/".len()..];
let (slug, rest) = after.split_once('/').unwrap_or((after, ""));
let query = url.split_once('?').map(|(_, q)| q).unwrap_or("");
proxy_tool(slug, rest, query, m, body)
}
(Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()),
(Method::Get, "/api/env") => json_resp(&api_env().to_string()),
(Method::Get, "/api/libraries") => json_resp(&user_libraries().to_string()),
(Method::Get, "/api/pick-folder") => json_resp(&pick_folder().to_string()),
(Method::Get, p) if p.starts_with("/api/pick-folder-result") => {
let tok = url.split_once("?f=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
json_resp(&pick_folder_result(&tok).to_string())
}
(Method::Get, p) if p.starts_with("/api/project-diff") => {
let param = |k: &str| url.split_once(&format!("{k}=")).map(|(_, v)| urldecode(v.split('&').next().unwrap_or(v))).unwrap_or_default();
json_resp(&project_diff(¶m("board"), ¶m("dest")).to_string())
}
(Method::Get, p) if p.starts_with("/api/in-library") => {
let slugs: Vec<String> = url.split_once("?slugs=")
.map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q)))
.unwrap_or_default()
.split(',').filter(|s| !s.is_empty()).map(String::from).collect();
let matched: std::collections::HashMap<String, Value> = components_in_libs(&slugs)
.into_iter().map(|(s, by, lib, fp)| (s, json!({"by": by, "lib": lib, "fpLib": fp}))).collect();
json_resp(&json!(matched).to_string())
}
(Method::Get, p) if p.starts_with("/api/suggest-path") => {
let prefix = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
json_resp(&suggest_path(&prefix).to_string())
}
(Method::Get, p) if p.starts_with("/api/list-dirs") => {
let pp = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();
json_resp(&list_dirs(&pp).to_string())
}
(Method::Get, "/") => html(INDEX_HTML),
(Method::Get, "/pm/version") => Response::from_string(
format!("{{\"version\":\"{}\"}}", env!("CARGO_PKG_VERSION"))).with_header(hdr("application/json")),
// Update check: compares the running binary's version against the latest
// published on the wiki (cached ~10min) so the UI can flag an available update.
(Method::Get, "/api/update") => json_resp(&update_status().to_string()),
(Method::Get, "/loader.gif") => Response::from_data(LOADER_GIF.to_vec())
.with_header(hdr("image/gif"))
.with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=86400"[..]).unwrap()),
(Method::Get, "/api/components") => {
// ?refresh=1 busts the server-side cache so newly-pushed components appear
if url.contains("refresh=1") {
*LIST_CACHE.lock().unwrap() = None;
}
json_resp(&cached_list())
}
(Method::Get, p) if p.starts_with("/api/part/") => {
let slug = &p["/api/part/".len()..];
json_resp(&get_part(slug).to_string())
}
(Method::Get, p) if p.starts_with("/part-embed/") => {
// Interactive symbol/footprint viewer from adom-symbol/adom-footprint `embed`,
// served for an <iframe>. Forms: /part-embed/<slug>/symbol | /part-embed/<slug>/footprint
let rest = &p["/part-embed/".len()..];
let (slug, kind) = rest.rsplit_once('/').unwrap_or((rest, ""));
match part_embed(slug, kind) {
Some(html) => Response::from_string(html).with_header(hdr("text/html; charset=utf-8")),
None => Response::from_string("<p style='font:13px system-ui;color:#8b93a3;padding:20px'>viewer not available</p>")
.with_header(hdr("text/html; charset=utf-8")).with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/api/board/") => {
let slug = &p["/api/board/".len()..];
json_resp(&board_deps(slug).to_string())
}
(Method::Get, p) if p.starts_with("/api/project/") => {
let slug = &p["/api/project/".len()..];
json_resp(&project_boards(slug).to_string())
}
(Method::Get, p) if p.starts_with("/api/board-activity/") => {
let slug = &p["/api/board-activity/".len()..];
json_resp(&board_activity(slug).to_string())
}
(Method::Get, p) if p.starts_with("/api/board-eda/") => {
let slug = &p["/api/board-eda/".len()..];
json_resp(&json!({"eda": board_eda_cached(slug)}).to_string())
}
(Method::Get, p) if p.starts_with("/api/board-upaxis/") => {
// The board GLB's declared up-axis (from step2glb's stamp). Version-aware.
// Fetched once per board by the frontend, which caches it; not in the polled
// board-media path. Absent stamp → null → frontend applies no rotation.
let (path, ver) = split_ver(url);
let slug = &path["/api/board-upaxis/".len()..];
let glb = ver
.and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))
.or_else(|| wiki_bytes(slug, "render/board.glb"))
.or_else(|| page_model_glb(slug));
let up = glb.as_deref().and_then(glb_up_axis);
json_resp(&json!({"up_axis": up}).to_string())
}
(Method::Get, p) if p.starts_with("/api/board-media/") => {
let slug = &p["/api/board-media/".len()..];
json_resp(&board_media(slug).to_string())
}
(Method::Get, p) if p.starts_with("/board-hero/") => {
// Proxy a page's hero image (used as the project 3D-view fallback). Resolve
// the hero descriptor → its image URL → serve the bytes. Works for billboard
// SVG heroes and plain-image heroes alike, and avoids a cross-origin fetch.
let slug = &p["/board-hero/".len()..];
let hero = curl(&format!("{WIKI}/api/v1/pages/{slug}/hero"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok());
let ct = hero.as_ref().and_then(|h| h.get("content_type").and_then(|c| c.as_str()))
.unwrap_or("image/png").to_string();
let img = hero.as_ref()
.and_then(|h| h.get("url").and_then(|u| u.as_str()))
.and_then(|u| { let full = if u.starts_with("http") { u.to_string() } else { format!("{WIKI}{u}") }; curl(&full) });
match img {
Some(bytes) => Response::from_data(bytes).with_header(hdr(&ct)),
None => Response::from_string("no hero").with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/org-avatar/") => {
// Proxy an organization's real avatar. Org avatars live in Carbon/Hydrogen; the
// public Carbon API (carbon.adom.inc/orgs/<org>) returns an avatar_url on the
// public cdn.adom.inc, which we fetch and serve (avoids a cross-origin fetch).
let org = &p["/org-avatar/".len()..];
let org = org.split(['/', '?']).next().unwrap_or(org);
let img = curl(&format!("https://carbon.adom.inc/orgs/{org}"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|m| m.get("avatar_url").and_then(|u| u.as_str()).map(String::from))
.and_then(|u| curl(&u));
match img {
Some(bytes) => Response::from_data(bytes).with_header(hdr("image/png")),
None => Response::from_string("no avatar").with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/board-svg/") => {
// forms: /board-svg/<slug>/pcb or /board-svg/<slug>/sch/<idx> [?v=<version>]
let (path, ver) = split_ver(url);
let segs: Vec<&str> = path["/board-svg/".len()..].splitn(3, '/').collect();
let slug = segs.first().copied().unwrap_or("");
let sub = |dir: &str| match segs.get(1).copied() {
Some("pcb") => Some(format!("{dir}pcb.svg")),
Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).map(|i| format!("{dir}sheet-{i:03}.svg")),
_ => None,
};
// a selected PAST version → serve from its render/<version>/ archive;
// else the live in-memory render; else the current stored top-level render/.
let svg = ver.as_ref()
.and_then(|v| sub(&format!("render/{v}/")))
.and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())
.or_else(|| cached_board(slug).and_then(|(sheets, pcb, _)| match segs.get(1).copied() {
Some("pcb") => pcb,
Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).and_then(|i| sheets.get(i).map(|(_, s)| s.clone())),
_ => None,
}))
.or_else(|| sub("render/").and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok()));
match svg {
Some(s) => Response::from_string(s).with_header(hdr("image/svg+xml")),
None => Response::from_string("not rendered").with_status_code(404),
}
}
// /board-view/<slug>/enrich?mpn=&lcsc=, stock/price/MPN lookup on behalf of an
// EMBEDDED viewer (an embed is a static HTML blob with no server of its own, and
// its fetch("enrich") resolves here). Delegates to the viewer's own `enrich` verb.
(Method::Get, p) if p.starts_with("/board-view/") && p.contains("/enrich") => {
Response::from_string(board_view_enrich(url)).with_header(hdr("application/json"))
}
(Method::Get, p) if p.starts_with("/board-view/") => {
// Interactive PCB / schematic view via adom-layout-viewer / adom-schematic-viewer.
// forms: /board-view/<slug>/pcb or /board-view/<slug>/sch [?v=<version>]
let (path, ver) = split_ver(url);
let segs: Vec<&str> = path["/board-view/".len()..].splitn(2, '/').collect();
let slug = segs.first().copied().unwrap_or("");
let kind = match segs.get(1).copied() { Some("pcb") => "pcb", Some("sch") => "sch", _ => "" };
// ?sheet=<file>: render a specific hierarchical child sheet instead of the root
let sheet = url.split_once('?').and_then(|(_, q)| q.split('&')
.find_map(|kv| kv.strip_prefix("sheet="))).map(|s| pct(s))
.filter(|s| !s.is_empty() && !s.contains("..") && !s.contains('/') && s.ends_with(".kicad_sch"));
match render_board_view_sheet(slug, kind, ver.as_deref(), sheet.as_deref()) {
Some(h) => Response::from_string(h).with_header(hdr("text/html; charset=utf-8")),
None => Response::from_string("<p style='font:13px system-ui;color:#8b93a3;padding:24px'>interactive view unavailable (no KiCad file on this page)</p>").with_header(hdr("text/html; charset=utf-8")).with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/board-glb/") => {
let (path, ver) = split_ver(url);
let slug = &path["/board-glb/".len()..];
// A selected PAST version → its archived render/<version>/board.glb. Otherwise
// serve the SAME 3D the wiki page shows: the page's model_3d GLB (Drew's
// molecule-publish emits `<slug>.glb` and points model_3d_path at it), so the
// Manager's 3D matches the wiki page. Fall back to live cache, then render/board.glb.
let glb = ver.as_ref()
.and_then(|v| wiki_bytes(slug, &format!("render/{v}/board.glb")))
.or_else(|| page_model_glb(slug))
.or_else(|| cached_board(slug).and_then(|(_, _, g)| g))
.or_else(|| wiki_bytes(slug, "render/board.glb"));
match glb {
Some(g) => Response::from_data(g).with_header(hdr("model/gltf-binary")),
None => Response::from_string("not rendered").with_status_code(404),
}
}
(Method::Get, p) if p.starts_with("/glb/") => {
let slug = &p["/glb/".len()..];
match list_page_files(slug).iter().find(|n| n.ends_with(".glb")).and_then(|n| wiki_bytes(slug, n)) {
Some(b) => Response::from_data(b).with_header(hdr("model/gltf-binary")),
None => Response::from_string("no glb").with_status_code(404),
}
}
_ => Response::from_string("not found").with_status_code(404),
}
}
// ---------------------------------------------------------------------------
// Wiki access (via curl, no HTTP-client dep)
// ---------------------------------------------------------------------------
/// The wiki API token (same source the adom-wiki CLI uses), cached. Without it,
/// unauthenticated requests return only PUBLIC pages, so private/org component
/// pages would never be listed. Read once; None if unavailable.
static WIKI_TOKEN: Mutex<Option<Option<String>>> = Mutex::new(None);
fn wiki_token() -> Option<String> {
let mut c = WIKI_TOKEN.lock().unwrap();
if let Some(v) = c.as_ref() {
return v.clone();
}
let t = std::fs::read_to_string("/var/run/adom/api-key").ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("ADOM_API_KEY").ok().filter(|s| !s.is_empty()));
*c = Some(t.clone());
t
}
fn curl(url: &str) -> Option<Vec<u8>> {
// `--fail`: on an HTTP error (e.g. a missing file's 404) the wiki returns a JSON
// error body; without --fail curl hands us those bytes and callers mistake the
// error object for real content (a 404 manifest read as a valid render/ folder).
// With --fail curl exits non-zero and emits nothing, so we correctly return None.
let mut args: Vec<String> = vec!["-s".into(), "-f".into(), "-L".into(), "--max-time".into(), "12".into()];
// authenticate wiki requests so private/org pages are included (issue #120)
if url.contains("wiki.adom.inc") {
if let Some(tok) = wiki_token() {
args.push("-H".into());
args.push(format!("Authorization: Bearer {tok}"));
}
}
args.push(url.to_string());
let out = Command::new("curl").args(&args).output().ok()?;
if out.status.success() && !out.stdout.is_empty() {
Some(out.stdout)
} else {
None
}
}
fn wiki_text(slug: &str, file: &str) -> Option<String> {
let url = format!("{WIKI}/api/v1/pages/{slug}/files/{file}");
curl(&url).map(|b| String::from_utf8_lossy(&b).to_string())
}
fn wiki_bytes(slug: &str, file: &str) -> Option<Vec<u8>> {
curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{file}"))
}
/// `a > b` for dotted numeric versions ("0.8.7" > "0.8.6"), tolerant of pre-release
/// suffixes and missing components (treated as 0).
fn semver_gt(a: &str, b: &str) -> bool {
let parse = |s: &str| -> (u64, u64, u64) {
let mut it = s.trim().trim_start_matches('v').split('.').map(|x| {
x.split(|c: char| !c.is_ascii_digit()).next().unwrap_or("0").parse::<u64>().unwrap_or(0)
});
(it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
};
parse(a) > parse(b)
}
/// Running version vs the latest published on the wiki (dist-tags.latest), cached for
/// ~10 minutes so the header badge doesn't hit the registry on every poll.
static UPDATE_CACHE: Mutex<Option<(std::time::Instant, Value)>> = Mutex::new(None);
fn update_status() -> Value {
if let Some((t, v)) = UPDATE_CACHE.lock().unwrap().as_ref() {
if t.elapsed().as_secs() < 600 { return v.clone(); }
}
let current = env!("CARGO_PKG_VERSION");
let latest = curl(&format!("{WIKI}/api/packages/adom/adom-nucleus/dist-tags"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| dig(&v, "dist_tags.latest").and_then(|s| s.as_str()).map(String::from));
let update = latest.as_deref().map(|l| semver_gt(l, current)).unwrap_or(false);
let v = json!({
"current": current,
"latest": latest,
"update": update,
"url": format!("{WIKI}/adom/adom-nucleus"),
});
*UPDATE_CACHE.lock().unwrap() = Some((std::time::Instant::now(), v.clone()));
v
}
/// The page's model_3d GLB, the exact 3D file the wiki page displays. Drew's
/// molecule-publish emits `<slug>.glb` and sets `model_3d_path` to it, so serving
/// this keeps the Manager's 3D view consistent with the wiki page. Prefers the
/// explicit `model_3d_path` from package.json, else the `<slug>.glb` convention.
fn page_model_glb(slug: &str) -> Option<Vec<u8>> {
if let Some(txt) = wiki_text(slug, "package.json") {
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
if let Some(p) = v.get("model_3d_path").and_then(|x| x.as_str()).filter(|s| !s.is_empty()) {
let p = p.trim_start_matches("./").trim_start_matches('/');
if let Some(b) = wiki_bytes(slug, p) {
return Some(b);
}
}
}
}
wiki_bytes(slug, &format!("{slug}.glb"))
}
// Read the up-axis a molecule GLB declares in its glTF header. step2glb --molecule
// stamps asset.extras.adomMolecule.upAxis ("z") on anchored/normalized GLBs; raw
// (un-pipelined) Fusion exports carry nothing. The 3D viewer uses this to decide the
// Z-up→Y-up rotation per board instead of guessing by EDA. Returns e.g. Some("z").
fn glb_up_axis(bytes: &[u8]) -> Option<String> {
if bytes.len() < 20 || &bytes[0..4] != b"glTF" {
return None;
}
let json_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
let end = 20usize.checked_add(json_len)?;
if bytes.len() < end {
return None;
}
let j: Value = serde_json::from_slice(&bytes[20..end]).ok()?;
j.get("asset")?.get("extras")?.get("adomMolecule")?
.get("upAxis")?.as_str().map(|s| s.to_lowercase())
}
/// Split a request path into (path_without_query, the `?v=<version>` value if present).
/// Used by the board asset routes to serve a past version from `render/<version>/`.
fn split_ver(p: &str) -> (&str, Option<String>) {
match p.split_once('?') {
Some((path, q)) => {
let v = q.split('&').find_map(|kv| kv.strip_prefix("v=")).map(|s| s.to_string());
(path, v.filter(|s| !s.is_empty() && !s.contains("..") && !s.contains('/')))
}
None => (p, None),
}
}
/// Uppercase-MPN filename stem for a slug (files are named `<MPN>.ext`).
fn up(slug: &str) -> String {
slug.to_uppercase()
}
/// List component pages from the wiki API (fast + reliable; `adompkg` is dead).
fn list_components() -> Value {
let mut items = Vec::new();
// The wiki caps each list response at ~200 rows regardless of `limit`, and there
// are now hundreds of components, a single fetch drops most of them (boards
// included). Page through with `offset` until we've collected every component.
let mut offset = 0usize;
loop {
let body = match curl(&format!("{WIKI}/api/v1/pages?type=component&limit=200&offset={offset}")) {
Some(b) => b,
None => break,
};
let v = match serde_json::from_slice::<Value>(&body) {
Ok(v) => v,
Err(_) => break,
};
let pages = match v.get("pages").and_then(|p| p.as_array()) {
Some(p) if !p.is_empty() => p.clone(),
_ => break,
};
let n = pages.len();
for pg in &pages {
let slug = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("").to_string();
if slug.is_empty() {
continue;
}
let title = pg.get("title").and_then(|s| s.as_str()).unwrap_or(&slug).to_string();
let brief = pg.get("brief").and_then(|s| s.as_str()).unwrap_or("").to_string();
// tags may arrive as a JSON-string or a real array
let tags = pg.get("tags").map(|t| match t.as_str() {
Some(s) => serde_json::from_str::<Value>(s).unwrap_or_else(|_| json!([])),
None => t.clone(),
}).unwrap_or_else(|| json!([]));
let owner = pg.get("author_name").and_then(|s| s.as_str()).unwrap_or("").to_string();
let org = pg.get("org_name").and_then(|s| s.as_str()).unwrap_or("").to_string();
let is_starred = pg.get("starred").and_then(|s| s.as_bool()).unwrap_or(false);
let visibility = pg.get("visibility").and_then(|s| s.as_str()).unwrap_or("").to_string();
let version = pg.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string();
let score = pg.get("composite_score").and_then(|s| s.as_f64()).unwrap_or(0.0);
items.push(json!({"slug": slug, "title": title, "brief": brief, "tags": tags, "owner": owner, "org": org, "starred": is_starred, "visibility": visibility, "version": version, "score": score}));
}
offset += n;
let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;
// stop when we've read everything, the page came back short, or as a hard cap
if offset >= total || n < 200 || offset >= 5000 {
break;
}
}
if items.is_empty() {
for s in ["cl21a476mqynnne", "cq03saf4702t5e", "lqm18pnr47mfhd", "machinepinlargestandard", "machinecontactmedium"] {
items.push(json!({"slug": s, "title": up(s), "brief": ""}));
}
}
// Fold the EDA type into every BOARD item up front, computed concurrently.
// The server is single-threaded, so lazy per-row `/api/board-eda` fetches would
// otherwise serialize N wiki file-listings (KiCad tags trickling in one by one).
let board_slugs: Vec<String> = items.iter().filter_map(|it| {
let is_board = it.get("tags").and_then(|t| t.as_array()).is_some_and(|a| {
a.iter().any(|x| matches!(x.as_str().unwrap_or("").to_lowercase().as_str(), "board" | "molecule" | "circuit"))
});
if is_board { it.get("slug").and_then(|s| s.as_str()).map(String::from) } else { None }
}).collect();
let eda_map: std::collections::HashMap<String, Vec<&'static str>> = std::thread::scope(|sc| {
let handles: Vec<_> = board_slugs.iter().map(|s| {
let s = s.clone();
sc.spawn(move || { let e = board_eda_cached(&s); (s, e) })
}).collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
});
for it in items.iter_mut() {
if let Some(e) = it.get("slug").and_then(|s| s.as_str()).and_then(|s| eda_map.get(s)) {
it["eda"] = json!(e);
}
}
let (me, orgs) = whoami();
json!({ "status": "ok", "components": items, "me": me, "orgs": orgs,
"hints": ["click a component to open its viewer"] })
}
/// Fetch a component and build its adom-lbr part FRESH on every call (nothing is
static EMBED_CACHE: Mutex<Option<std::collections::HashMap<String, String>>> = Mutex::new(None);
/// Self-contained INTERACTIVE symbol/footprint viewer HTML from `adom-symbol embed` /
/// `adom-footprint embed` (pan/zoom + pin-info / layers panel). We export the part's
/// KiCad source to a temp file and run the embed CLI. Cached per (slug, kind) since the
/// CLI takes a beat and the HTML is stable for a given part.
fn part_embed(slug: &str, kind: &str) -> Option<String> {
if kind != "symbol" && kind != "footprint" {
return None;
}
let key = format!("{slug}/{kind}");
if let Some(m) = EMBED_CACHE.lock().unwrap().as_ref() {
if let Some(h) = m.get(&key) {
return Some(h.clone());
}
}
let mpn = up(slug);
let (part, _) = load_part(slug, &mpn).ok()?;
let dir = std::env::temp_dir().join(format!("nucleus-embed-{slug}"));
std::fs::create_dir_all(&dir).ok()?;
let (text, ext, tool) = match kind {
"symbol" => (export_kicad_symbol(&part.symbol), "kicad_sym", "adom-symbol"),
_ => (export_kicad_footprint(&part.footprint), "kicad_mod", "adom-footprint"),
};
let src = dir.join(format!("{mpn}.{ext}"));
let out = dir.join(format!("{kind}.html"));
std::fs::write(&src, text).ok()?;
let ok = Command::new(tool)
.args(["embed", "--file", src.to_str()?, "--out", out.to_str()?])
.output()
.ok()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
return None;
}
let raw = std::fs::read_to_string(&out).ok()?;
// Fit the embed into the PM's narrow 3-up cards: in the card, hide the info sidebar
// (<aside>) AND the "adom-symbol/footprint" top bar so the viewer gets the whole card;
// both reappear only when the card is fullscreened (iframe grows past the breakpoint).
// Also shrink the top-left zoom controls (their default 30px is chunky in a small card).
let fit = "<style id=\"nucleus-fit\">\
@media(max-width:639px){aside{display:none!important}header{display:none!important}}\
.tools,.av-tools{gap:2px!important;padding:2px!important;left:8px!important;top:8px!important}\
.tools button,.av-tools button{width:22px!important;height:22px!important}\
.tools button svg,.av-tools button svg{width:13px!important;height:13px!important}\
</style>";
let html = match raw.find("</head>") {
Some(i) => format!("{}{}{}", &raw[..i], fit, &raw[i..]),
None => format!("{fit}{raw}"),
};
EMBED_CACHE
.lock()
.unwrap()
.get_or_insert_with(Default::default)
.insert(key, html.clone());
Some(html)
}
/// cached), then render symbol/footprint SVG on the fly. The canonical adom-lbr
/// JSON is the primary source; if the page has none, we import the KiCad files.
fn get_part(slug: &str) -> Value {
let mpn = up(slug);
let (part, src): (AdomLbrPart, &str) = match load_part(slug, &mpn) {
Ok(v) => v,
Err(e) => {
return json!({"status":"error","error":e,
"hints":["the page needs an adom-lbr JSON, or <MPN>.kicad_sym + <MPN>.kicad_mod"]})
}
};
let has_glb = list_page_files(slug).iter().any(|n| n.ends_with(".glb"));
json!({
"status": "ok",
"slug": slug,
"mpn": part.mpn,
"source": src, // "adom-lbr json" or "kicad (generated)"
"symbol_svg": symbol_svg(&part.symbol), // rendered on the fly, every click
"footprint_svg": footprint_svg(&part.footprint, &pin_names(&part.symbol)),
"layers": layer_stackup(&part.footprint),
"glb": has_glb,
"pins": part.symbol.pins.len(),
"pads": part.footprint.pads.len(),
})
}
/// A board/molecule's component dependencies, the other wiki components it's
/// built from. Read from the board's **`package.json` `dependencies`** (the same
/// npm-style manifest the wiki dependency graph is built from, so the two agree).
/// Values may be `{slug: ver}`, `[slug,…]`, or `[{slug|ref|name}, …]`; owner
/// prefixes (`adom/foo`) are stripped to the slug.
/// A page's package.json dependencies as bare slugs (owner prefix stripped).
fn deps_of(slug: &str) -> Vec<String> {
let bare = |k: &str| k.rsplit('/').next().unwrap_or(k).trim().to_string();
if let Some(txt) = wiki_text(slug, "package.json") {
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
return match v.get("dependencies") {
Some(Value::Object(m)) => m.keys().map(|k| bare(k)).collect(),
Some(Value::Array(a)) => a
.iter()
.filter_map(|d| {
d.as_str().map(|s| s.to_string()).or_else(|| {
d.get("slug").or_else(|| d.get("ref")).or_else(|| d.get("name")).and_then(|x| x.as_str()).map(|s| s.to_string())
})
})
.map(|s| bare(&s))
.collect(),
_ => vec![],
};
}
}
vec![]
}
/// Components on a board. Architecture: a board depends on a component LIBRARY,
/// and the library depends on the components. Resolve one hop, any dependency
/// that itself has dependencies is a library and is expanded to its components;
/// a dependency with none is treated as a direct component (backward-compatible
/// with boards that still list components directly).
/// A circuit's bill of materials, derived from the `adom-block.json` on its page
/// (the neutral design-block model). Aggregates the block's component instances by
/// (value, lcsc, footprint) with a quantity and the reference designators. A part
/// links to a wiki component page only when one is resolved (`page`); otherwise it's
/// an informative BOM line. This is how `circuit`-tagged pages get their dependencies
/// without every passive needing its own wiki page.
fn circuit_bom(slug: &str) -> Vec<Value> {
let block: Value = match wiki_bytes(slug, "adom-block.json").and_then(|b| serde_json::from_slice(&b).ok()) {
Some(v) => v,
None => return vec![],
};
let instances = match block.get("instances").and_then(|i| i.as_array()) {
Some(a) => a,
None => return vec![],
};
// aggregate by (value, lcsc, footprint)
let mut order: Vec<String> = vec![];
let mut agg: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
for inst in instances {
let get = |k: &str| inst.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let (value, lcsc, footprint) = (get("value"), get("lcsc"), get("footprint"));
let refdes = get("id");
let key = format!("{value}|{lcsc}|{footprint}");
let entry = agg.entry(key.clone()).or_insert_with(|| {
order.push(key.clone());
json!({
"value": if value.is_empty() { Value::Null } else { json!(value) },
"lcsc": if lcsc.is_empty() { Value::Null } else { json!(lcsc) },
"footprint": if footprint.is_empty() { Value::Null } else { json!(footprint) },
"mpn": inst.get("mpn").cloned().unwrap_or(Value::Null),
"page": inst.get("page").cloned().unwrap_or(Value::Null),
"refs": json!([]),
"qty": 0,
})
});
entry["qty"] = json!(entry["qty"].as_u64().unwrap_or(0) + 1);
if let Some(a) = entry["refs"].as_array_mut() { if !refdes.is_empty() { a.push(json!(refdes)); } }
}
order.into_iter().filter_map(|k| agg.remove(&k)).collect()
}
fn board_deps(slug: &str) -> Value {
let mut components: Vec<String> = vec![];
let mut seen = std::collections::HashSet::new();
let own_lib = format!("{slug}-library");
for d in deps_of(slug) {
let sub = deps_of(&d);
if sub.is_empty() {
// The page's own library (a <slug>-library that lists no components yet, as
// with a circuit whose BOM lives in its block model) is surfaced via the
// "Libraries" expander, not as a fake component row. Skip it so the BOM shows.
if d == own_lib { continue; }
if seen.insert(d.clone()) {
components.push(d);
}
} else {
for c in sub {
if seen.insert(c.clone()) {
components.push(c);
}
}
}
}
let items: Vec<Value> = components.into_iter().map(|s| json!({"slug": s})).collect();
// No resolved component pages? fall back to the block-model BOM (circuit pages).
let bom = if items.is_empty() { circuit_bom(slug) } else { vec![] };
// the page's git commit history (the wiki's Activity tab)
let log = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("log").cloned())
.unwrap_or_else(|| json!([]));
json!({ "status": "ok", "board": slug, "deps": items, "bom": bom, "log": log,
"hints": ["board → component library → components (the library's dependencies)"] })
}
/// A PROJECT's direct board dependencies, a page tagged `project` depends on
/// boards (not components), so unlike `board_deps` these are NOT expanded. The
/// Manager lists them, and clicking one opens it as a normal board.
fn project_boards(slug: &str) -> Value {
let boards: Vec<Value> = deps_of(slug).into_iter().map(|s| json!({"slug": s})).collect();
let log = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("log").cloned())
.unwrap_or_else(|| json!([]));
json!({ "status": "ok", "project": slug, "boards": boards, "log": log })
}
/// The board's EDA toolchain, inferred from the files on its page:
/// "kicad" | "altium" | "fusion" | "".
/// Every EDA toolchain whose files are present on the board, a board can carry
/// more than one (e.g. a KiCad PCB alongside a Fusion/Eagle `.brd` + `.lbr`), so
/// this returns ALL matches, in a stable order, not just the first.
fn board_eda(slug: &str) -> Vec<&'static str> {
let files = list_page_files(slug);
let has = |exts: &[&str]| files.iter().any(|n| { let l = n.to_lowercase(); exts.iter().any(|e| l.ends_with(e)) });
let mut out = Vec::new();
if has(&[".kicad_pcb", ".kicad_sch", ".kicad_pro"]) {
out.push("kicad");
}
if has(&[".pcbdoc", ".schdoc", ".prjpcb", ".intlib", ".pcblib", ".schlib"]) {
out.push("altium");
}
if has(&[".fbrd", ".fsch", ".brd", ".sch", ".f3d", ".fusion", ".lbr"]) {
out.push("fusion");
}
out
}
/// `board_eda` cached across requests. The EDA set never changes for a given
/// board within a session, and the raw call costs a wiki file-listing round-trip;
/// caching turns the left-list's per-row EDA into a one-time cost.
static EDA_CACHE: Mutex<Option<std::collections::HashMap<String, Vec<&'static str>>>> = Mutex::new(None);
fn board_eda_cached(slug: &str) -> Vec<&'static str> {
{
let mut g = EDA_CACHE.lock().unwrap();
if let Some(e) = g.get_or_insert_with(Default::default).get(slug) {
return e.clone();
}
}
let e = board_eda(slug);
EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e.clone());
e
}
/// The board's commit history enriched with what each commit changed: components
/// added/removed (footprint reference designators on the PCB) and whether the PCB
/// or schematic changed. Fetches each commit's .kicad_pcb + .kicad_sch via ?ref=.
fn board_activity(slug: &str) -> Value {
let log: Vec<Value> = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("log").and_then(|l| l.as_array().cloned()))
.unwrap_or_default();
if log.is_empty() {
return json!({ "activity": [] });
}
let files = list_page_files(slug);
let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();
let sch = files.iter().find(|n| n.ends_with(".kicad_sch")).cloned();
let at = |file: &Option<String>, hash: &str| -> Option<String> {
file.as_ref().and_then(|f| curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{f}?ref={hash}")).and_then(|b| String::from_utf8(b).ok()))
};
// component reference designators placed on the PCB
let refs = |pcb: &str| -> std::collections::HashSet<String> {
pcb.split("(property \"Reference\" \"").skip(1)
.filter_map(|c| c.split('"').next())
.filter(|r| !r.is_empty() && *r != "REF**")
.map(String::from)
.collect()
};
let hashes: Vec<String> = log.iter().filter_map(|c| c.get("hash").and_then(|h| h.as_str()).map(String::from)).collect();
let pcbs: Vec<Option<String>> = hashes.iter().map(|h| at(&pcb, h)).collect();
let schs: Vec<Option<String>> = hashes.iter().map(|h| at(&sch, h)).collect();
let mut out = vec![];
for i in 0..log.len() {
let mut e = log[i].clone();
// compare against the previous (older) commit, the next entry in the log
if i + 1 < log.len() {
e["pcb_changed"] = json!(pcbs[i] != pcbs[i + 1]);
e["sch_changed"] = json!(schs[i] != schs[i + 1]);
if let (Some(n), Some(o)) = (&pcbs[i], &pcbs[i + 1]) {
let (rn, ro) = (refs(n), refs(o));
e["comp_added"] = json!(rn.difference(&ro).count());
e["comp_removed"] = json!(ro.difference(&rn).count());
}
}
out.push(e);
}
json!({ "activity": out })
}
/// List the file names on a page (robust to any MPN→filename convention, the
/// slug can't be uppercased to the filename when the MPN has `/`, `.`, etc.).
fn list_page_files(slug: &str) -> Vec<String> {
let body = match curl(&format!("{WIKI}/api/v1/pages/{slug}/files")) {
Some(b) => b,
None => return vec![],
};
let v: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => return vec![],
};
v.get("files")
.and_then(|f| f.as_array())
.map(|a| {
a.iter()
.filter_map(|f| f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).map(String::from))
.collect()
})
.unwrap_or_default()
}
/// Load a part, preferring the canonical adom-lbr JSON on the page; else generate
/// it from the KiCad files. Discovers the ACTUAL filenames from the page listing
/// (never guesses from the slug). Returns the part + where it came from.
fn load_part(slug: &str, _mpn: &str) -> Result<(AdomLbrPart, &'static str), String> {
let files = list_page_files(slug);
let find = |suf: &str| files.iter().find(|n| n.ends_with(suf)).cloned();
// 1) canonical adom-lbr JSON
if let Some(name) = find(".adom-lbr.json").or_else(|| find(".adomlbr.json")) {
if let Some(txt) = wiki_text(slug, &name) {
match serde_json::from_str::<AdomLbrPart>(&txt) {
Ok(p) => return Ok((p, "adom-lbr json")),
Err(e) => return Err(format!("{name} is not valid adom-lbr JSON: {e}")),
}
}
}
// 2) fall back to the KiCad source files
match (
find(".kicad_sym").and_then(|n| wiki_text(slug, &n)),
find(".kicad_mod").and_then(|n| wiki_text(slug, &n)),
) {
(Some(s), Some(m)) => altium_codec::import_kicad(&s, &m, &up(slug))
.map(|p| (p, "kicad (generated)"))
.map_err(|e| e.to_string()),
_ => Err(format!("no adom-lbr JSON or KiCad files on {slug}")),
}
}
// ---------------------------------------------------------------------------
// Layer stackup, the cross-EDA mapping (KiCad + Altium)
// ---------------------------------------------------------------------------
fn role_name(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "Silkscreen",
FpLayer::Courtyard => "Courtyard",
FpLayer::Fab => "Fabrication / Assembly",
FpLayer::Copper => "Copper",
FpLayer::Other { .. } => "Other",
}
}
fn kicad_layer(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "F.SilkS",
FpLayer::Courtyard => "F.CrtYd",
FpLayer::Fab => "F.Fab",
FpLayer::Copper => "F.Cu",
FpLayer::Other { .. } => "F.Fab",
}
}
/// Barrett's house convention (from the C0402 reference): outline+silk → Mech13
/// "Assembly Top", courtyard → Mech15 "Courtyard Top".
fn altium_layer(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "Mechanical 13 (Assembly Top)",
FpLayer::Fab => "Mechanical 13 (Assembly Top)",
FpLayer::Courtyard => "Mechanical 15 (Courtyard Top)",
FpLayer::Copper => "Top Layer",
FpLayer::Other { .. } => "Mechanical 1",
}
}
/// CSS-safe token per layer role, used to class SVG elements so the footprint
/// view can toggle layers on/off.
fn layer_key(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "silk",
FpLayer::Courtyard => "courtyard",
FpLayer::Fab => "fab",
FpLayer::Copper => "copper",
FpLayer::Other { .. } => "other",
}
}
fn layer_stackup(fp: &Footprint) -> Value {
use std::collections::BTreeMap;
// count graphics per role (+ the pad copper layer)
let mut counts: BTreeMap<&str, (FpLayer, usize)> = BTreeMap::new();
if !fp.pads.is_empty() {
counts.insert("Copper", (FpLayer::Copper, fp.pads.len()));
}
for g in &fp.graphics {
let l = match g {
FpGraphic::Line { layer, .. }
| FpGraphic::Rect { layer, .. }
| FpGraphic::Circle { layer, .. }
| FpGraphic::Arc { layer, .. } => *layer,
};
let e = counts.entry(role_name(&l)).or_insert((l, 0));
e.1 += 1;
}
let rows: Vec<Value> = counts
.values()
.map(|(l, n)| {
json!({
"role": role_name(l),
"key": layer_key(l),
"count": n,
"kicad": kicad_layer(l),
"altium": altium_layer(l),
})
})
.collect();
Value::Array(rows)
}
// ---------------------------------------------------------------------------
// SVG renderers (symbol in mils→viewport, footprint in mm)
// ---------------------------------------------------------------------------
/// Layer → SVG colour (KiCad-ish palette for the footprint view).
fn layer_color(l: &FpLayer) -> &'static str {
match l {
FpLayer::Silk => "#d8b4fe",
FpLayer::Courtyard => "#4ade80",
FpLayer::Fab => "#f472b6",
FpLayer::Copper => "#f59e0b",
FpLayer::Other { .. } => "#94a3b8",
}
}
fn symbol_svg(sym: &Symbol) -> String {
// bounds in mils over pins + graphics
let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut upd = |x: f64, y: f64| {
a = a.min(x);
b = b.min(y);
c = c.max(x);
d = d.max(y);
};
for p in &sym.pins {
upd(p.x as f64, p.y as f64);
}
for g in &sym.graphics {
match g {
Graphic::Rect { x1, y1, x2, y2, .. } => { upd(*x1, *y1); upd(*x2, *y2); }
Graphic::Circle { cx, cy, r, .. } | Graphic::Arc { cx, cy, r, .. } => { upd(cx - r, cy - r); upd(cx + r, cy + r); }
Graphic::Polyline { points, .. } => { for pt in points { upd(pt[0], pt[1]); } }
}
}
if a > c { return empty_svg("no symbol"); }
let pad = 50.0;
let (a, b, c, d) = (a - pad, b - pad, c + pad, d + pad);
// SVG y-down; symbol y-up → flip y
let fy = |y: f64| -y;
let mut body = String::new();
for g in &sym.graphics {
match g {
Graphic::Rect { x1, y1, x2, y2, filled, .. } => {
let fill = if *filled { "#e35d5d" } else { "none" };
body += &format!("<rect x='{:.1}' y='{:.1}' width='{:.1}' height='{:.1}' fill='{}' stroke='#e35d5d' stroke-width='4'/>",
x1.min(*x2), fy(y1.max(*y2)), (x2 - x1).abs(), (y2 - y1).abs(), fill);
}
Graphic::Circle { cx, cy, r, .. } => {
body += &format!("<circle cx='{:.1}' cy='{:.1}' r='{:.1}' fill='none' stroke='#e35d5d' stroke-width='4'/>", cx, fy(*cy), r);
}
Graphic::Arc { cx, cy, r, start, end, .. } => {
let p = |deg: f64| { let t = deg.to_radians(); (cx + r * t.cos(), fy(cy + r * t.sin())) };
let (sx, sy) = p(*start); let (ex, ey) = p(*end);
let large = if ((end - start + 360.0) % 360.0) > 180.0 { 1 } else { 0 };
body += &format!("<path d='M{:.1} {:.1} A{:.1} {:.1} 0 {} 0 {:.1} {:.1}' fill='none' stroke='#e35d5d' stroke-width='4'/>", sx, sy, r, r, large, ex, ey);
}
Graphic::Polyline { points, .. } => {
let pts: String = points.iter().map(|pt| format!("{:.1},{:.1} ", pt[0], fy(pt[1]))).collect();
body += &format!("<polyline points='{}' fill='none' stroke='#e35d5d' stroke-width='4'/>", pts.trim());
}
}
}
// Show pin numbers/names on ICs; hide on passives (respect the source flag,
// else default by pin count, same rule the KiCad exporter uses).
let passive = sym.pins.len() <= 2;
let show_num = !sym.pin_numbers_hidden.unwrap_or(passive);
let show_name = !sym.pin_names_hidden.unwrap_or(passive);
for p in &sym.pins {
let len = p.length.unwrap_or(200) as f64;
let (dx, dy) = match (p.rotation / 90) % 4 { 0 => (len, 0.0), 1 => (0.0, len), 2 => (-len, 0.0), _ => (0.0, -len) };
let (x1, y1) = (p.x as f64, fy(p.y as f64)); // free (connection) end
let (x2, y2) = (p.x as f64 + dx, fy(p.y as f64 + dy)); // body end, SVG space
body += &format!(
"<g class='pingroup' data-num='{}' data-name='{}'>\
<line class='pinvis' x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='#dfe4ec' stroke-width='4'/>\
<line x1='{:.1}' y1='{:.1}' x2='{:.1}' y2='{:.1}' stroke='transparent' stroke-width='26' pointer-events='stroke'/></g>",
esc_attr(&p.number), esc_attr(&p.name), x1, y1, x2, y2, x1, y1, x2, y2
);
let ln = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt().max(1.0);
let (ux, uy) = ((x2 - x1) / ln, (y2 - y1) / ln); // free→body unit
let (px, py) = (-uy, ux);
// KiCad's default pin text is 1.27mm = 50 mil; match that (nudge with length).
let fs = (len * 0.5).clamp(45.0, 60.0);
if show_num {
body += &format!(
"<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#9fb0c8' text-anchor='middle' dominant-baseline='central'>{}</text>",
x1 + ux * ln * 0.5 + px * fs * 0.75, y1 + uy * ln * 0.5 + py * fs * 0.75, fs, esc_attr(&p.number)
);
}
if show_name && p.name != "~" && !p.name.is_empty() {
let anchor = if ux > 0.3 { "start" } else if ux < -0.3 { "end" } else { "middle" };
body += &format!(
"<text x='{:.1}' y='{:.1}' font-size='{:.0}' fill='#e6e9ef' text-anchor='{}' dominant-baseline='central'>{}</text>",
x2 + ux * fs * 0.5, y2 + uy * fs * 0.5, fs, anchor, esc_attr(&p.name)
);
}
}
format!(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.0} {:.0} {:.0} {:.0}' style='width:100%;height:100%'>{}</svg>",
a, -d, c - a, d - b, body
)
}
/// number → pin name, so a pad can show the connected pin's name on hover.
fn pin_names(sym: &Symbol) -> std::collections::HashMap<String, String> {
sym.pins
.iter()
.filter(|p| !p.name.is_empty() && p.name != "~")
.map(|p| (p.number.clone(), p.name.clone()))
.collect()
}
fn footprint_svg(fp: &Footprint, names: &std::collections::HashMap<String, String>) -> String {
let (mut a, mut b, mut c, mut d) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut upd = |x: f64, y: f64| { a = a.min(x); b = b.min(y); c = c.max(x); d = d.max(y); };
for p in &fp.pads {
upd(p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0);
upd(p.x_mm + p.w_mm / 2.0, p.y_mm + p.h_mm / 2.0);
}
for g in &fp.graphics {
match g {
FpGraphic::Line { x1, y1, x2, y2, .. } | FpGraphic::Rect { x1, y1, x2, y2, .. } => { upd(*x1, *y1); upd(*x2, *y2); }
FpGraphic::Circle { cx, cy, r, .. } | FpGraphic::Arc { cx, cy, r, .. } => { upd(cx - r, cy - r); upd(cx + r, cy + r); }
}
}
if a > c { return empty_svg("no footprint"); }
let pad = 0.4;
let (a, b, c, d) = (a - pad, b - pad, c + pad, d + pad);
let mut body = String::new();
// graphics first (under pads)
for g in &fp.graphics {
let (layer, s) = match g {
FpGraphic::Line { layer, x1, y1, x2, y2, width } =>
(layer, format!("<line class='ly-{}' x1='{:.3}' y1='{:.3}' x2='{:.3}' y2='{:.3}' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), x1, y1, x2, y2, width.max(0.05))),
FpGraphic::Rect { layer, x1, y1, x2, y2, width } =>
(layer, format!("<rect class='ly-{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), x1.min(*x2), y1.min(*y2), (x2-x1).abs(), (y2-y1).abs(), width.max(0.05))),
FpGraphic::Circle { layer, cx, cy, r, width } =>
(layer, format!("<circle class='ly-{}' cx='{:.3}' cy='{:.3}' r='{:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), cx, cy, r, width.max(0.05))),
FpGraphic::Arc { layer, cx, cy, r, start, end, width } => {
let p = |deg: f64| { let t = deg.to_radians(); (cx + r * t.cos(), cy + r * t.sin()) };
let (sx, sy) = p(*start); let (ex, ey) = p(*end);
(layer, format!("<path class='ly-{}' d='M{:.3} {:.3} A{:.3} {:.3} 0 0 1 {:.3} {:.3}' fill='none' stroke='{{}}' stroke-width='{:.3}'/>", layer_key(layer), sx, sy, r, r, ex, ey, width.max(0.05)))
}
};
body += &s.replace("{}", layer_color(layer));
}
for p in &fp.pads {
let col = layer_color(&FpLayer::Copper);
let rx = if p.shape == "circle" || p.shape == "roundrect" { p.w_mm.min(p.h_mm) * 0.25 } else { 0.0 };
// the connected pin name (if the symbol has one) becomes the pad's label
let name = names.get(&p.number).cloned().unwrap_or_default();
body += &format!("<rect class='ly-copper pad' data-num='{}' data-name='{}' x='{:.3}' y='{:.3}' width='{:.3}' height='{:.3}' rx='{:.3}' fill='{}' opacity='0.9'/>",
esc_attr(&p.number), esc_attr(&name), p.x_mm - p.w_mm / 2.0, p.y_mm - p.h_mm / 2.0, p.w_mm, p.h_mm, rx, col);
}
format!(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='{:.3} {:.3} {:.3} {:.3}' style='width:100%;height:100%'>{}</svg>",
a, b, c - a, d - b, body
)
}
fn esc_attr(s: &str) -> String {
s.replace('&', "&").replace('\'', "'").replace('"', """).replace('<', "<")
}
fn empty_svg(msg: &str) -> String {
format!("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 40'><text x='50' y='24' text-anchor='middle' fill='#888' font-size='8'>{msg}</text></svg>")
}
// ---------------------------------------------------------------------------
// Add to Library, convert selected components → the user's KiCad libraries,
// delivered to their machine via the Adom Desktop bridge.
// ---------------------------------------------------------------------------
// TODO: detect from the bridge rather than hard-coding this user's home.
const SCRATCH: &str = "/tmp/adom-lbr-apply";
/// Run a command on the user's machine via the bridge, returning the command's
/// own stdout (not the adom-desktop JSON envelope). None if empty / bridge down.
fn ad_run(cmd: &str) -> Option<String> {
let raw = ad_shell_out(cmd)?;
let v: Value = serde_json::from_str(&raw).ok()?;
v.get("stdout").and_then(|s| s.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
/// The user's machine `(os, home)`, detected via the bridge and cached. Files are
/// delivered to the user's REAL machine (Windows/macOS/Linux), not this Linux
/// container, so delivery paths must be built for it.
static MACHINE: Mutex<Option<(String, String)>> = Mutex::new(None);
fn machine() -> (String, String) {
if let Some(v) = MACHINE.lock().unwrap().clone() {
return v;
}
// a Unix shell expands `$HOME` to an absolute /path; a Windows cmd shell does not.
let res = match ad_run("echo $HOME").filter(|h| h.starts_with('/')) {
Some(home) => {
let os = if ad_run("uname -s").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };
(os.to_string(), home)
}
None => {
let home = ad_run("echo %USERPROFILE%").filter(|h| h.contains(':')).unwrap_or_else(|| "C:\\Users\\Default".into());
("windows".to_string(), home)
}
};
*MACHINE.lock().unwrap() = Some(res.clone());
res
}
/// The path separator for the user's OS.
fn sep() -> &'static str {
if machine().0 == "windows" { "\\" } else { "/" }
}
/// Join parts onto the user's home directory with the OS-appropriate separator.
fn home_path(parts: &[&str]) -> String {
let mut p = machine().1.trim_end_matches(['/', '\\']).to_string();
for x in parts {
p.push_str(sep());
p.push_str(x);
}
p
}
/// A KiCad library/project subdir on the user's machine (`sub` = Symbols /
/// Footprints / Projects).
fn kicad_dir(sub: &str) -> String {
home_path(&["Documents", "KiCad", sub])
}
/// Everything before the last path separator (the containing directory).
fn parent_dir(path: &str) -> String {
match path.rfind(['/', '\\']) {
Some(i) => path[..i].to_string(),
None => ".".to_string(),
}
}
/// GET /api/env, the user's machine OS + home + OS-appropriate default delivery
/// folders, so the UI's "Deliver project to" picker isn't hard-coded to Linux.
fn api_env() -> Value {
let (os, home) = machine();
json!({
"os": os,
"home": home,
"sep": sep(),
"dest_folders": [
kicad_dir("Projects"),
home_path(&["Documents"]),
home_path(&["Downloads"]),
home_path(&["Desktop"]),
],
})
}
/// The KiCad config dir on the user's machine (holds the global lib tables).
fn kicad_config_base() -> String {
let (os, home) = machine();
match os.as_str() {
"macos" => format!("{home}/Library/Preferences/kicad"),
"windows" => format!("{home}\\AppData\\Roaming\\kicad"),
_ => format!("{home}/.config/kicad"),
}
}
/// True for a KiCad-bundled standard library (as opposed to the user's own) -
/// identified by a `${KICAD*_…_DIR}` variable or an install-dir path.
fn is_stock_kicad_lib(uri: &str) -> bool {
let u = uri.to_lowercase();
uri.starts_with("${KICAD")
|| u.contains("/usr/share/kicad")
|| u.contains("/usr/lib/kicad")
|| u.contains("program files\\kicad")
|| u.contains("program files/kicad")
|| u.contains("/applications/kicad")
}
/// Parse `(lib (name "X") … (uri "Y"))` entries out of a KiCad lib-table.
fn parse_lib_table(text: &str) -> Vec<Value> {
text.split("(lib ")
.skip(1)
.filter_map(|chunk| {
let name = chunk.find("(name \"").and_then(|i| chunk[i + 7..].split('"').next())?;
let uri = chunk.find("(uri \"").and_then(|i| chunk[i + 6..].split('"').next())?;
if name.is_empty() {
return None;
}
Some(json!({ "name": name, "uri": uri }))
})
.collect()
}
/// GET /api/libraries, the user's REAL KiCad symbol + footprint libraries (name
/// + on-disk path) read from their global sym-lib-table / fp-lib-table via the
/// bridge, so placement suggests the user's own libraries, never hard-coded ones.
fn user_libraries() -> Value {
let (os, _) = machine();
let base = kicad_config_base();
let read = |table: &str| -> Vec<Value> {
let cmd = if os == "windows" {
format!("for /d %d in (\"{base}\\*\") do @type \"%d\\{table}\" 2>nul")
} else {
format!("cat {base}/*/{table} 2>/dev/null")
};
// merge across KiCad versions, first occurrence of each name wins
let mut seen = std::collections::HashSet::new();
ad_run(&cmd)
.map(|t| parse_lib_table(&t))
.unwrap_or_default()
.into_iter()
.filter(|l| {
let n = l.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string();
let uri = l.get("uri").and_then(|x| x.as_str()).unwrap_or("");
// keep only the user's PERSONAL libraries: drop KiCad's bundled
// standard libs and the built-in default-table pointer.
n != "KiCad" && !is_stock_kicad_lib(uri) && seen.insert(n)
})
.collect()
};
json!({ "symLibs": read("sym-lib-table"), "fpLibs": read("fp-lib-table") })
}
/// Minimal percent-decoding for query values (`%20`, `+`, `%2F`, …).
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 + 3 <= b.len() {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(if b[i] == b'+' { b' ' } else { b[i] });
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// GET /api/pick-folder, pop a NATIVE folder-picker dialog on the user's machine
/// (via the bridge) and return the chosen directory. Blocks until the user picks
/// or cancels (empty path = cancelled / no dialog available).
fn pick_folder() -> Value {
let (os, _) = machine();
let id = unique_id();
// A native folder dialog is MODAL and blocks until the user picks/cancels. If we
// ran it synchronously through the bridge, the bridge's shell_execute would wait
// for it and WEDGE the whole AD command channel. So launch it DETACHED, have it
// write the chosen path (or __CANCELLED__) to a temp file, and let the frontend
// poll /api/pick-folder-result. shell_execute returns instantly; AD stays free.
// The token is just the id; each side rebuilds the temp path itself (on Windows
// inside PowerShell via GetTempPath, since cmd mangles %TEMP%/forward-slash paths).
let launch = match os.as_str() {
"windows" => {
// inner: the TopMost dialog (owner form pulls it to the foreground), writing the result
let inner = format!(
"$ProgressPreference='SilentlyContinue'\n\
Add-Type -AssemblyName System.Windows.Forms\n\
$o=New-Object System.Windows.Forms.Form\n\
$o.TopMost=$true;$o.ShowInTaskbar=$false;$o.Opacity=0\n\
$o.Show();$o.Activate()\n\
$d=New-Object System.Windows.Forms.FolderBrowserDialog\n\
$d.Description='Select the project folder'\n\
$r=$d.ShowDialog($o)\n\
$o.Close()\n\
$out=if($r -eq [System.Windows.Forms.DialogResult]::OK){{$d.SelectedPath}}else{{'__CANCELLED__'}}\n\
$p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'\n\
Set-Content -LiteralPath $p -Value $out -NoNewline -Encoding UTF8");
let inner_b64 = b64(&utf16le(&inner));
// outer: fire the inner off detached, then exit immediately so shell_execute
// returns. The inner carries -STA (needed for the dialog); the OUTER must NOT
// (an -STA outer adds ~7s of startup here). Base64-encoded so cmd/PS don't echo it.
let outer = format!("Start-Process -WindowStyle Hidden powershell -ArgumentList @('-NoProfile','-STA','-EncodedCommand','{inner_b64}')");
format!("powershell -NoProfile -EncodedCommand {}", b64(&utf16le(&outer)))
}
"macos" => format!("(osascript -e 'POSIX path of (choose folder)' > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),
_ => format!("(zenity --file-selection --directory > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),
};
let _ = ad_shell_out(&launch);
json!({ "token": id })
}
/// GET /api/pick-folder-result?f=<id>, poll the detached folder dialog: no file
/// yet = still open (`pending`); `__CANCELLED__`/empty = cancelled; else the path.
/// Reads (and deletes) the temp file so a stale result can't linger.
fn pick_folder_result(token: &str) -> Value {
// the token is our own generated id, keep only its safe characters
let id: String = token.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if id.is_empty() {
return json!({ "cancelled": true });
}
let (os, _) = machine();
let content = if os == "windows" {
// read+delete entirely inside PowerShell (cmd can't read the forward-slash %TEMP% path)
ad_run(&ps_command(&format!(
"$p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'; \
if(Test-Path -LiteralPath $p){{[Console]::Out.Write((Get-Content -LiteralPath $p -Raw)); Remove-Item -LiteralPath $p -Force}}else{{[Console]::Out.Write('__PENDING__')}}")))
} else {
let tmp = format!("/tmp/apm_pick_{id}.txt");
let c = ad_run(&format!("cat '{tmp}' 2>/dev/null"));
if c.is_some() {
let _ = ad_shell_out(&format!("rm -f '{tmp}'"));
}
c.or_else(|| Some("__PENDING__".into()))
};
match content.as_deref().map(str::trim) {
None | Some("__PENDING__") => json!({ "pending": true }),
Some("") | Some("__CANCELLED__") => json!({ "cancelled": true }),
Some(path) => json!({ "path": path }),
}
}
/// GET /api/list-dirs?p=<abspath>, the sub-folders of a directory on the user's
/// machine (each with its full path) plus the parent, powering the in-app folder
/// browser. Empty p starts at the home directory. This replaces the native OS
/// folder dialog (which can't render from a detached bridge process and can't be
/// run modally without wedging the bridge), and, unlike a browser file input, it
/// yields the real absolute path, which delivery needs.
fn list_dirs(path: &str) -> Value {
let (os, home) = machine();
let base = if path.trim().is_empty() { home } else { path.to_string() };
if os == "windows" {
let esc = base.replace('\'', "''");
let script = format!(
"$ErrorActionPreference='SilentlyContinue'; \
$i=Get-Item -LiteralPath '{esc}'; \
if(-not $i){{[Console]::Out.Write('@@ERR@@'); return}}; \
$full=$i.FullName; $par=''; if($i.Parent){{$par=$i.Parent.FullName}}; \
$names=Get-ChildItem -LiteralPath $full -Directory -Force | ForEach-Object {{ $_.Name + [char]9 + $_.FullName }} | Sort-Object; \
[Console]::Out.Write($full + [char]10 + $par + [char]10 + '@@' + [char]10 + ($names -join [char]10))");
let out = ad_run(&ps_command(&script)).unwrap_or_default().replace('\r', "");
if out.is_empty() || out.contains("@@ERR@@") {
return json!({ "path": base, "parent": "", "dirs": [] });
}
let (head, rest) = out.split_once("\n@@\n").unwrap_or((out.as_str(), ""));
let mut hl = head.splitn(2, '\n');
let full = hl.next().unwrap_or(&base).to_string();
let par = hl.next().unwrap_or("").to_string();
let dirs: Vec<Value> = rest.split('\n').filter(|l| !l.trim().is_empty())
.filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))
.collect();
json!({ "path": full, "parent": par, "dirs": dirs })
} else {
let q = sh_quote(&base);
let full = ad_run(&format!("cd {q} 2>/dev/null && pwd")).unwrap_or_else(|| base.clone());
let fq = sh_quote(&full);
let par = ad_run(&format!("dirname {fq}")).filter(|p| *p != full).unwrap_or_default();
let out = ad_run(&format!("for d in {fq}/*/ ; do [ -d \"$d\" ] && printf '%s\\t%s\\n' \"$(basename \"$d\")\" \"${{d%/}}\" ; done 2>/dev/null")).unwrap_or_default();
let dirs: Vec<Value> = out.lines()
.filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))
.collect();
json!({ "path": full, "parent": par, "dirs": dirs })
}
}
/// GET /api/suggest-path?p=<prefix>, existing directories on the user's machine
/// whose path starts with the typed prefix (for the delivery autosuggest).
fn suggest_path(prefix: &str) -> Value {
let (os, _) = machine();
let clean: String = prefix.chars().filter(|c| !"\"'`;|&\n\r$".contains(*c)).collect();
let cmd = if os == "windows" {
format!("dir /b /ad \"{clean}*\" 2>nul")
} else {
format!("for d in {clean}*/ ; do [ -d \"$d\" ] && echo \"$d\" ; done 2>/dev/null")
};
let dirs: Vec<String> = ad_run(&cmd)
.map(|t| t.lines().map(|l| l.trim_end_matches(['/', '\\']).to_string()).filter(|l| !l.is_empty()).take(14).collect())
.unwrap_or_default();
json!({ "dirs": dirs })
}
const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py");
/// The `.kicad_mod` filenames present in a directory on the user's machine.
fn list_kicad_mods(dir: &str) -> Vec<String> {
let (os, _) = machine();
let out = if os == "windows" {
ad_run(&format!("dir /b \"{}\\*.kicad_mod\" 2>nul", dir.replace('"', "")))
} else {
ad_run(&format!("ls {}/*.kicad_mod 2>/dev/null", sh_quote(dir)))
};
out.map(|o| {
o.lines()
.filter_map(|l| l.rsplit(['/', '\\']).next().map(|s| s.trim().to_string()))
.filter(|s| s.ends_with(".kicad_mod"))
.collect()
})
.unwrap_or_default()
}
/// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`,
/// `.kicad_pcb`, root + hierarchical `.kicad_sch`). When `project_only`, make the
/// project self-contained: extract the embedded symbols/footprints into a project
/// library, re-point every `lib_id`/footprint reference to it, and write the
/// project-local sym/fp-lib-tables. Then deliver everything to `dest`.
fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String, Vec<String>) {
let dest = if dest.trim().is_empty() { format!("{}{}{board}", kicad_dir("Projects"), sep()) } else { dest.trim().to_string() };
let work = format!("{SCRATCH}/proj-{}", board.replace(['/', ' '], "_"));
let _ = std::fs::remove_dir_all(&work);
let _ = std::fs::create_dir_all(&work);
let mut got = vec![];
let listing = match curl(&format!("{WIKI}/api/v1/pages/{board}/files")) {
Some(b) => b,
None => return (dest, got),
};
let v: Value = match serde_json::from_slice(&listing) {
Ok(v) => v,
Err(_) => return (dest, got),
};
let files = v.get("files").and_then(|f| f.as_array()).cloned().unwrap_or_default();
let file_names: Vec<String> = files.iter()
.filter_map(|f| f.get("path").or_else(|| f.get("name")).and_then(|n| n.as_str()).map(String::from))
.collect();
let shapes_dir = file_names.iter().find(|n| n.ends_with(".3dshapes")).cloned();
let mut names = vec![];
let mut models: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut proj_models: std::collections::HashSet<String> = std::collections::HashSet::new();
for name in &file_names {
if name.ends_with(".kicad_pro") || name.ends_with(".kicad_pcb") || name.ends_with(".kicad_sch") {
if let Some(bytes) = wiki_bytes(board, name) {
let base = name.rsplit('/').next().unwrap_or(name).to_string();
// Rewrite the PCB's custom (model "…") refs to portable ${KIPRJMOD}/3d/<base>
// so the recipient's KiCad finds the bundled STEPs, not the uploader's
// absolute paths (issue #6). Collect what to download.
let data: Vec<u8> = if name.ends_with(".kicad_pcb") {
match String::from_utf8(bytes) {
Ok(pcb) => {
let (out, m, pm) = normalize_pcb_models(&pcb);
models.extend(m);
proj_models.extend(pm);
out.into_bytes()
}
Err(e) => e.into_bytes(),
}
} else {
bytes
};
if std::fs::write(format!("{work}/{base}"), data).is_ok() {
names.push(base);
}
}
}
}
if names.is_empty() {
return (dest, got);
}
// Download the 3D models the board references into work/3d/ so they ship with the
// project and the ${KIPRJMOD}/3d/<base> refs resolve on the recipient's machine.
stage_board_models(board, &work, &models, &proj_models, shapes_dir.as_deref());
ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));
if project_only {
// self-contain: run the Python helper, which rewrites files in `work`,
// creates <board>.kicad_sym / .pretty / lib-tables, and reports the split.
let libnick = board.replace(['/', ' '], "_");
let script = format!("{work}/_selfcontain.py");
let _ = std::fs::write(&script, PYTHON_SELFCONTAIN);
let out = Command::new("python3")
.arg(&script)
.arg(&work)
.arg(&libnick)
.args(&names)
.output();
if let Ok(o) = out {
if let Ok(v) = serde_json::from_slice::<Value>(&o.stdout) {
let root: Vec<String> = v.get("root").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
let pretty: Vec<String> = v.get("pretty").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
if !root.is_empty() {
ad_send(&root, &dest);
}
if !pretty.is_empty() {
let pdir = format!("{dest}/{libnick}.pretty");
// if the project library already exists, update it as a DIFF: send
// the current footprints (add/replace), then delete any .kicad_mod
// that's no longer part of the board.
let keep: std::collections::HashSet<String> = pretty.iter().filter_map(|p| p.rsplit(['/', '\\']).next().map(String::from)).collect();
let stale: Vec<String> = list_kicad_mods(&pdir).into_iter().filter(|f| !keep.contains(f)).collect();
ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));
ad_send(&pretty, &pdir);
for f in stale {
ad_shell(&format!("rm -f {}", sh_quote(&format!("{pdir}/{f}"))));
}
}
got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();
got.extend(deliver_models(&work, &dest)); // ship the STEPs the board references
return (dest, got);
}
}
}
// personal mode (or self-contain failed): deliver the files as fetched.
let locals: Vec<String> = names.iter().map(|n| format!("{work}/{n}")).collect();
ad_send(&locals, &dest);
let mut names = names;
names.extend(deliver_models(&work, &dest)); // ship the STEPs the board references
(dest, names)
}
/// Single-quote a string for safe interpolation into an `sh -c` command run on
/// the user's machine. Everything is inert inside single quotes except `'`,
/// which becomes the standard `'\''` splice.
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// Library nicknames become file/dir names on the user's machine and are
/// interpolated into shell commands, restrict to a boring identifier charset.
fn valid_lib(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
}
/// Wiki slugs (`owner/name`): same conservative charset plus `/`.
fn valid_slug(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& !s.contains("..")
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
}
/// Pull a file off the user's machine to the container; None if it doesn't
/// exist OR the pull failed, callers that overwrite must disambiguate via
/// `ad_file_exists` before treating None as "absent".
fn ad_pull(remote: &str) -> Option<Vec<u8>> {
let _ = std::fs::create_dir_all(SCRATCH);
let args = json!({"filePaths": [remote], "saveTo": SCRATCH}).to_string();
Command::new("adom-desktop").args(["pull_file", &args]).output().ok()?;
let base = remote.rsplit('/').next()?;
std::fs::read(format!("{SCRATCH}/{base}")).ok()
}
/// Send container files to a directory on the user's machine.
fn ad_send(files: &[String], dest: &str) -> bool {
let args = json!({"filePaths": files, "dest": dest}).to_string();
Command::new("adom-desktop")
.args(["send_files", &args])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Ship the board's bundled 3D models (staged into `work/3d/` by stage_board_models)
/// to `<dest>/3d/`, so the delivered project's rewritten `${KIPRJMOD}/3d/<base>` model
/// refs resolve on the recipient's machine (issue #6). Returns the delivered filenames.
fn deliver_models(work: &str, dest: &str) -> Vec<String> {
let m3 = format!("{work}/3d");
let paths: Vec<String> = match std::fs::read_dir(&m3) {
Ok(rd) => rd.filter_map(|e| e.ok()).map(|e| e.path().to_string_lossy().to_string()).collect(),
Err(_) => return vec![],
};
if paths.is_empty() {
return vec![];
}
let d3 = format!("{dest}/3d");
ad_shell(&format!("mkdir -p {}", sh_quote(&d3)));
if ad_send(&paths, &d3) {
paths.iter().filter_map(|p| p.rsplit(['/', '\\']).next().map(|n| format!("3d/{n}"))).collect()
} else {
vec![]
}
}
fn ad_shell(cmd: &str) {
let args = json!({"command": cmd}).to_string();
let _ = Command::new("adom-desktop").args(["shell_execute", &args]).output();
}
/// Run a shell command on the user's machine and return its captured reply
/// (whole adom-desktop stdout); None if the bridge call itself failed.
// The desktop bridge (AD) processes shell commands one at a time. Firing several
// at once (now that the HTTP server is multi-threaded) makes concurrent filesystem
// searches thrash the disk and each balloon 5-10x. Serialize them here so every
// bridge call stays fast; the server still handles local/cached requests in parallel.
static BRIDGE_LOCK: Mutex<()> = Mutex::new(());
fn ad_shell_out(cmd: &str) -> Option<String> {
let _guard = BRIDGE_LOCK.lock().unwrap();
let args = json!({"command": cmd}).to_string();
let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).to_string())
}
/// Base64 (standard alphabet, padded).
fn b64(data: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for c in data.chunks(3) {
let (b0, b1, b2) = (c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0));
out.push(T[(b0 >> 2) as usize] as char);
out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
out.push(if c.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' });
out.push(if c.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' });
}
out
}
fn utf16le(s: &str) -> Vec<u8> {
s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()
}
/// Wrap a PowerShell script as a `-EncodedCommand` invocation. The AD Windows
/// shell is cmd; passing `powershell -Command "…"` lets the double quotes survive
/// to PowerShell, which then treats the script as a string literal and ECHOES it
/// instead of running it. Base64 UTF-16LE has no quotes/spaces to mangle, so the
/// script runs verbatim regardless of the intervening cmd/JSON quoting.
fn ps_command(script: &str) -> String {
format!("powershell -NoProfile -STA -EncodedCommand {}", b64(&utf16le(script)))
}
/// A process-unique id (millis + counter) for temp filenames.
fn unique_id() -> String {
static C: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = C.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
format!("{t}_{n}")
}
/// Board media rendered by the hosted service-kicad container (KiCad 10) and
/// cached per board. Tuple: (schematic sheets `[(name, svg)]`, PCB svg, board GLB).
type BoardMedia = (Vec<(String, String)>, Option<String>, Option<Vec<u8>>);
static BOARD_MEDIA: Mutex<Option<std::collections::HashMap<String, BoardMedia>>> = Mutex::new(None);
static BOARD_RENDERING: Mutex<Option<std::collections::HashSet<String>>> = Mutex::new(None);
fn cached_board(slug: &str) -> Option<BoardMedia> {
BOARD_MEDIA.lock().unwrap().as_ref().and_then(|m| m.get(slug).cloned())
}
/// POST a KiCad file to service-kicad and return the rendered asset bytes.
/// `kind` = "pcb" | "sch", `fmt` = "svg" | "glb". None on any failure.
fn kicad_export(slug: &str, kind: &str, fmt: &str, query: &str, input: &[u8]) -> Option<Vec<u8>> {
let _ = std::fs::create_dir_all(SCRATCH);
let tmp = format!("{SCRATCH}/kx-{}-{kind}-{fmt}.in", slug.replace('/', "_"));
std::fs::write(&tmp, input).ok()?;
let url = format!("{KICAD_SVC}/kicad/{kind}/export/{fmt}{query}");
let out = Command::new("curl")
.args(["-s", "--max-time", "150", "-X", "POST", "--data-binary", &format!("@{tmp}"), &url])
.output()
.ok()?;
let _ = std::fs::remove_file(&tmp);
// the service returns the asset directly on success; proxy errors are short text
if out.status.success() && out.stdout.len() > 64 && !out.stdout.starts_with(b"error code") {
Some(out.stdout)
} else {
None
}
}
/// Render a board's full hierarchical schematic via service-kicad's project
/// endpoint: fetch every .kicad_sch (+ .kicad_pro) from the wiki with real
/// filenames, tar them, POST, untar one SVG per sheet (root sheet first).
fn render_sch_project(slug: &str) -> Vec<(String, String)> {
let files = list_page_files(slug);
let schs: Vec<&String> = files.iter().filter(|n| n.ends_with(".kicad_sch")).collect();
if schs.is_empty() {
return vec![];
}
let pro = files.iter().find(|n| n.ends_with(".kicad_pro"));
let key = slug.replace('/', "_");
let dir = format!("{SCRATCH}/sch-{key}");
let _ = std::fs::remove_dir_all(&dir);
if std::fs::create_dir_all(&dir).is_err() {
return vec![];
}
for n in schs.iter().copied().chain(pro) {
if let Some(b) = wiki_bytes(slug, n) {
let base = n.rsplit('/').next().unwrap_or(n);
let _ = std::fs::write(format!("{dir}/{base}"), b);
}
}
let tar_path = format!("{SCRATCH}/sch-{key}.tar");
if !Command::new("tar").args(["-cf", &tar_path, "-C", &dir, "."]).status().map(|s| s.success()).unwrap_or(false) {
return vec![];
}
let tar_bytes = std::fs::read(&tar_path).unwrap_or_default();
let out_tar = match kicad_export(slug, "sch", "svg-project", "", &tar_bytes) {
Some(b) => b,
None => return vec![],
};
let outdir = format!("{SCRATCH}/schout-{key}");
let _ = std::fs::remove_dir_all(&outdir);
let _ = std::fs::create_dir_all(&outdir);
let outtar = format!("{SCRATCH}/schout-{key}.tar");
if std::fs::write(&outtar, &out_tar).is_err() {
return vec![];
}
let _ = Command::new("tar").args(["-xf", &outtar, "-C", &outdir]).status();
let pro_stem = pro.map(|n| n.rsplit('/').next().unwrap_or(n).trim_end_matches(".kicad_pro").to_string());
let mut sheets: Vec<(String, String)> = vec![];
if let Ok(rd) = std::fs::read_dir(&outdir) {
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) == Some("svg") {
if let Ok(s) = std::fs::read_to_string(&p) {
// kicad names them "<Project>" (root) and "<Project>-<Sheet>"; clean up
let raw = p.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet").to_string();
let name = if pro_stem.as_deref() == Some(raw.as_str()) {
"Root".to_string()
} else if let Some(st) = &pro_stem {
raw.strip_prefix(&format!("{st}-")).unwrap_or(&raw).to_string()
} else {
raw
};
sheets.push((name, s));
}
}
}
}
// Root sheet first, then the rest alphabetically
sheets.sort_by(|(a, _), (b, _)| (b == "Root").cmp(&(a == "Root")).then_with(|| a.cmp(b)));
sheets
}
/// Render the board GLB with the REAL component 3D models. The .kicad_pcb refers
/// to standard KiCad models by `${KICAD9_3DMODEL_DIR}/…` (the service has those)
/// and to custom models by absolute user paths (the service can't). The custom
/// models are bundled on the wiki page's `.3dshapes`; fetch them, rewrite the
/// absolute refs to `${KIPRJMOD}/3d/<name>`, tar the PCB + models, and POST to
/// service-kicad's glb-package endpoint (which sets KIPRJMOD + --subst-models).
/// Rewrite every custom `(model "…")` ref in a .kicad_pcb to a portable
/// `${KIPRJMOD}/3d/<basename>` and collect what to stage. Custom = anything NOT a
/// `${KICAD*_3DMODEL_DIR}` standard ref: a machine-absolute Unix path (/home/…), a
/// Windows path (C:\Users\…), OR a project-relative path (<Board>.3dshapes/…). This
/// makes the board machine-independent, the same fix both the GLB render and the
/// delivered project need (issue #6: uploader absolute paths leak to the recipient).
/// Returns (rewritten_pcb, custom-model basenames, `${KIPRJMOD}/<rel>` project models).
fn normalize_pcb_models(
pcb: &str,
) -> (String, std::collections::HashSet<String>, std::collections::HashSet<String>) {
let mut models = std::collections::HashSet::new();
let mut proj_models = std::collections::HashSet::new();
let mut out = String::with_capacity(pcb.len());
let mut rest = pcb;
while let Some(pos) = rest.find("(model \"") {
out.push_str(&rest[..pos + 8]);
let after = &rest[pos + 8..];
match after.find('"') {
Some(end) => {
let path = &after[..end];
if path.starts_with("${") {
if let Some(rel) = path.replace('\\', "/").strip_prefix("${KIPRJMOD}/") {
proj_models.insert(rel.to_string());
}
out.push_str(path);
} else {
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
models.insert(base.to_string());
out.push_str(&format!("${{KIPRJMOD}}/3d/{base}"));
}
rest = &after[end..];
}
None => break,
}
}
out.push_str(rest);
(out, models, proj_models)
}
/// Download a board's custom + project-relative 3D models from its wiki page into
/// `<dir>` (custom → `<dir>/3d/<base>`, project-relative → `<dir>/<rel>`), so a
/// `${KIPRJMOD}`-rewritten .kicad_pcb resolves its models locally. A model may live in
/// the page's `.3dshapes` folder, a `3d/` folder, or at the referenced relative path.
fn stage_board_models(
slug: &str,
dir: &str,
models: &std::collections::HashSet<String>,
proj_models: &std::collections::HashSet<String>,
shapes_dir: Option<&str>,
) {
let _ = std::fs::create_dir_all(format!("{dir}/3d"));
for base in models {
let candidates: Vec<String> = shapes_dir
.iter()
.map(|sd| format!("{sd}/{base}"))
.chain([format!("3d/{base}"), base.clone()])
.collect();
for cand in candidates {
if let Some(mb) = wiki_bytes(slug, &cand) {
let _ = std::fs::write(format!("{dir}/3d/{base}"), mb);
break;
}
}
}
for rel in proj_models {
if let Some(mb) = wiki_bytes(slug, rel) {
let dest = format!("{dir}/{rel}");
if let Some(parent) = std::path::Path::new(&dest).parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&dest, mb);
}
}
}
fn render_board_glb(slug: &str) -> Option<Vec<u8>> {
let files = list_page_files(slug);
let pcb_name = files.iter().find(|n| n.ends_with(".kicad_pcb"))?;
let shapes_dir = files.iter().find(|n| n.ends_with(".3dshapes")).cloned();
let pcb = String::from_utf8(wiki_bytes(slug, pcb_name)?).ok()?;
let (out, models, proj_models) = normalize_pcb_models(&pcb);
// Show DNP (Do-Not-Populate) parts in the 3D view. KiCad's 3D export omits DNP
// footprints by default, so a part left/mis-flagged DNP (e.g. a main IC) renders
// blank with no body, surprising in a design-browsing tool. Drop the `dnp` attr
// token so every placed footprint gets its model.
let out = out.replace(" dnp)", ")").replace(" dnp ", " ");
// assemble the bundle: board.kicad_pcb (rewritten) + 3d/<custom models>
let key = slug.replace('/', "_");
let bundle = format!("{SCRATCH}/glb-{key}");
let _ = std::fs::remove_dir_all(&bundle);
std::fs::create_dir_all(format!("{bundle}/3d")).ok()?;
std::fs::write(format!("{bundle}/board.kicad_pcb"), &out).ok()?;
stage_board_models(slug, &bundle, &models, &proj_models, shapes_dir.as_deref());
let targz = format!("{SCRATCH}/glb-{key}.tar.gz");
if !Command::new("tar").args(["-czf", &targz, "-C", &bundle, "."]).status().map(|s| s.success()).unwrap_or(false) {
return None;
}
let bytes = std::fs::read(&targz).ok()?;
let raw = kicad_export(slug, "pcb", "glb-package", "?F=--include-silkscreen&F=--include-pads", &bytes)?;
// kicad-cli emits one primitive per part feature (a complex board is thousands
// of draw calls) and no compression, so a dense board like the USB3->Ethernet
// molecule loads and orbits slowly. Collapse it before publishing.
Some(optimize_glb(&raw, &key))
}
/// Make the raw kicad-cli board GLB lean for the 3D viewer: merge primitives by
/// material (thousands of draw calls -> a few dozen), weld/prune, and apply
/// KHR_mesh_quantization (Babylon-native, needs no decoder — NOT draco, per the
/// molecule pipeline's "don't draco molecule-sized GLBs" rule). Geometry is
/// unchanged (same triangles). Falls back to the raw bytes if the optimizer or
/// node is unavailable, so a board still renders even without the toolchain.
fn optimize_glb(bytes: &[u8], key: &str) -> Vec<u8> {
// Locate glb_optimize.mjs: env override, then next to the binary, then the
// install share dir (where install.sh puts it + its node_modules), then the
// dev checkout. First that exists wins; if none, return the raw GLB unchanged.
let mut cands: Vec<String> = Vec::new();
if let Ok(p) = std::env::var("ADOM_GLB_OPTIMIZE") { cands.push(p); }
if let Ok(exe) = std::env::current_exe() {
if let Some(d) = exe.parent() { cands.push(d.join("glb_optimize.mjs").to_string_lossy().into_owned()); }
}
if let Ok(home) = std::env::var("HOME") {
cands.push(format!("{home}/.local/share/adom-nucleus/glb_optimize.mjs"));
}
cands.push("/home/adom/project/glb_optimize.mjs".to_string());
let opt = match cands.into_iter().find(|p| std::path::Path::new(p).exists()) {
Some(p) => p, None => return bytes.to_vec(),
};
let tin = format!("{SCRATCH}/glbopt-{key}-in.glb");
let tout = format!("{SCRATCH}/glbopt-{key}-out.glb");
if std::fs::write(&tin, bytes).is_err() { return bytes.to_vec(); }
let _ = std::fs::remove_file(&tout);
let ok = Command::new("node").args([opt.as_str(), &tin, &tout]).status().map(|s| s.success()).unwrap_or(false);
let out = if ok { std::fs::read(&tout).ok().filter(|b| b.len() > 100) } else { None };
let _ = std::fs::remove_file(&tin); let _ = std::fs::remove_file(&tout);
out.unwrap_or_else(|| bytes.to_vec())
}
/// Fetch a board's .kicad_pcb + full hierarchical schematic and render the
/// schematic sheets, the PCB SVG, and the board GLB via service-kicad.
fn toast_queue() -> &'static Mutex<Vec<serde_json::Value>> {
static Q: std::sync::OnceLock<Mutex<Vec<serde_json::Value>>> = std::sync::OnceLock::new();
Q.get_or_init(|| Mutex::new(Vec::new()))
}
fn board_view_cache() -> &'static Mutex<std::collections::HashMap<String, String>> {
static C: std::sync::OnceLock<Mutex<std::collections::HashMap<String, String>>> = std::sync::OnceLock::new();
C.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
/// Render the interactive PCB / schematic view HTML by shelling the standalone
/// viewers (`adom-layout-viewer` / `adom-schematic-viewer` `embed`) on the board's
/// raw KiCad file (fetched from the wiki page). Cached per (slug, kind, version).
fn render_board_view(slug: &str, kind: &str, ver: Option<&str>) -> Option<String> {
render_board_view_sheet(slug, kind, ver, None)
}
/// `sheet` selects a specific hierarchical child `.kicad_sch` (by basename) instead
/// of the project's root sheet, so a viewer can navigate into sub-sheets.
fn render_board_view_sheet(slug: &str, kind: &str, ver: Option<&str>, sheet: Option<&str>) -> Option<String> {
let key = format!("{slug}|{kind}|{}|{}", ver.unwrap_or("cur"), sheet.unwrap_or(""));
if let Some(h) = board_view_cache().lock().unwrap().get(&key) { return Some(h.clone()); }
let files = list_page_files(slug);
// Source files for EVERY supported EDA, best-first. The viewers detect the
// format from the file itself, so the binary only depends on pcb-vs-sch:
// KiCad .kicad_pcb/.kicad_sch · Altium .PcbDoc/.SchDoc · Fusion/EAGLE .brd/.sch
let (exts, bin) = match kind {
"pcb" => (["kicad_pcb", "pcbdoc", "brd"].as_slice(), "adom-layout-viewer"),
"sch" => (["kicad_sch", "schdoc", "sch"].as_slice(), "adom-schematic-viewer"),
_ => return None,
};
let has_ext = |n: &str, e: &str| n.rsplit('.').next().map(|x| x.eq_ignore_ascii_case(e)).unwrap_or(false);
// for schematics prefer the ROOT sheet (the one matching the project stem)
let root_stem = files.iter()
.find(|n| has_ext(n, "kicad_pro") || has_ext(n, "prjpcb"))
.and_then(|n| n.rsplit('/').next())
.and_then(|n| n.rsplit_once('.'))
.map(|(stem, _)| stem.to_string());
let mut fname = None;
// an explicit child sheet wins: match the requested basename on the page
if kind == "sch" {
if let Some(want) = sheet {
fname = files.iter().find(|n| n.rsplit('/').next().unwrap_or(n).eq_ignore_ascii_case(want)).cloned();
}
}
if fname.is_none() {
for e in exts {
if kind == "sch" {
if let Some(st) = &root_stem {
if let Some(f) = files.iter().find(|n| {
let base = n.rsplit('/').next().unwrap_or(n);
has_ext(base, e) && base.rsplit_once('.').map(|(s, _)| s == st).unwrap_or(false)
}) { fname = Some(f.clone()); break; }
}
}
if let Some(f) = files.iter().find(|n| has_ext(n, e)) { fname = Some(f.clone()); break; }
}
}
let fname = fname?;
let ext = fname.rsplit('.').next().unwrap_or("bin").to_string();
let bytes = wiki_bytes(slug, &fname)?;
let dir = format!("{SCRATCH}/view-{}", slug.replace(['/', ' '], "_"));
std::fs::create_dir_all(&dir).ok()?;
// include the sheet basename so concurrent requests for different child sheets
// don't race on one temp file
let tag = sheet.map(|s| s.trim_end_matches(".kicad_sch")).unwrap_or("root");
let inp = format!("{dir}/{kind}-{tag}.{ext}");
let outp = format!("{dir}/{kind}-{tag}.html");
std::fs::write(&inp, &bytes).ok()?;
// For a KiCad schematic, also stage every OTHER .kicad_sch on the page next to
// the input file and pass --sheet-dir, so the viewer bundles all child sheets
// into the embed and hierarchical navigation is instant (no reload).
let mut args: Vec<String> = vec!["embed".into(), "--file".into(), inp.clone(), "--out".into(), outp.clone()];
if kind == "sch" && ext == "kicad_sch" {
for n in files.iter().filter(|n| has_ext(n, "kicad_sch")) {
let bn = n.rsplit('/').next().unwrap_or(n);
if bn == inp.rsplit('/').next().unwrap_or(&inp) { continue; }
if let Some(b) = wiki_bytes(slug, n) { let _ = std::fs::write(format!("{dir}/{bn}"), b); }
}
// Stage the AUTHENTIC pre-rendered sheet SVGs (molecule-publish renders the
// FULL project, so these have real KiCad fonts and the hierarchical root is
// complete) as `<sheetfile>.base.svg`, which the viewer uses verbatim instead
// of re-exporting single files via service-kicad (which can't render a lone
// hierarchical root). render/manifest.json lists sheet NAMES in sheet-NNN order.
stage_authentic_sheets(slug, &dir, &inp, &bytes);
args.push("--sheet-dir".into()); args.push(dir.clone());
}
let ok = Command::new(bin).args(&args).status().map(|s| s.success()).unwrap_or(false);
if !ok { eprintln!("board-view: {bin} embed failed for {slug}"); return None; }
let html = std::fs::read_to_string(&outp).ok()?;
board_view_cache().lock().unwrap().insert(key, html.clone());
Some(html)
}
/// Fetch the page's authentic pre-rendered `render/sheet-NNN.svg` and stage each as
/// `<dir>/<sheetfile>.base.svg` so the viewer renders every sheet from the real
/// KiCad export. `render/manifest.json` gives sheet NAMES in sheet-NNN order; we map
/// name -> file via the root's own `(sheet (property "Sheetname")(property "Sheetfile"))`.
fn stage_authentic_sheets(slug: &str, dir: &str, root_inp: &str, root_bytes: &[u8]) {
// manifest sheet names, in sheet-NNN.svg order
let names: Vec<String> = match wiki_bytes(slug, "render/manifest.json")
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|m| m.get("sheets").and_then(|s| s.as_array()).map(|a|
a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()))
{ Some(v) => v, None => return };
// name -> child file, parsed from the root sheet (no regex crate here). A sheet
// symbol carries `(property "Sheetname" "X")` then `(property "Sheetfile" "x")`.
let root_txt = String::from_utf8_lossy(root_bytes);
let quoted_after = |hay: &str, key: &str| -> Option<(String, usize)> {
let k = format!("\"{key}\"");
let ki = hay.find(&k)? + k.len();
let q1 = hay[ki..].find('"')? + ki + 1;
let q2 = hay[q1..].find('"')? + q1;
Some((hay[q1..q2].to_string(), q2))
};
let mut name_file: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut cur = &root_txt[..];
while let Some((name, end)) = quoted_after(cur, "Sheetname") {
let rest = &cur[end..];
// Sheetfile must be within the same sheet block (nearby)
if let Some((file, fend)) = quoted_after(&rest[..rest.len().min(600)], "Sheetfile") {
name_file.insert(name, file);
cur = &rest[fend..];
} else { break; }
}
let root_base = root_inp.rsplit('/').next().unwrap_or(root_inp).to_string();
for (i, name) in names.iter().enumerate() {
let svg = match wiki_bytes(slug, &format!("render/sheet-{i:03}.svg")) { Some(s) => s, None => continue };
// index 0 (the "Root") -> the root input file; others -> the mapped child file
let target = if i == 0 { root_base.clone() } else { match name_file.get(name) { Some(f) => f.clone(), None => continue } };
let _ = std::fs::write(format!("{dir}/{target}.base.svg"), svg);
}
}
/// Serve `/enrich` for an embedded viewer: run `adom-schematic-viewer enrich` and
/// return its JSON. Cached per part so repeated hovers are instant (and so we don't
/// hammer JLCPCB from a mousemove handler).
fn board_view_enrich(url: &str) -> String {
let q = url.split_once('?').map(|(_, q)| q).unwrap_or("");
let get = |k: &str| -> String {
q.split('&')
.find_map(|kv| kv.strip_prefix(&format!("{k}=")))
.map(|v| pct(v))
.unwrap_or_default()
};
let (mpn, lcsc) = (get("mpn"), get("lcsc"));
if mpn.is_empty() && lcsc.is_empty() { return "{\"error\":\"no query\"}".into(); }
let key = format!("enrich|{mpn}|{lcsc}");
if let Some(hit) = board_view_cache().lock().unwrap().get(&key) { return hit.clone(); }
let out = Command::new("adom-schematic-viewer")
.args(["enrich", "--mpn", &mpn, "--lcsc", &lcsc])
.output().ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| s.starts_with('{'))
.unwrap_or_else(|| "{\"error\":\"lookup failed\"}".into());
board_view_cache().lock().unwrap().insert(key, out.clone());
out
}
/// minimal percent-decode (+ as space) for query values
fn pct(s: &str) -> String {
let b = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => { out.push(' '); i += 1; }
b'%' if i + 2 < b.len() => {
match u8::from_str_radix(&s[i + 1..i + 3], 16) {
Ok(v) => { out.push(v as char); i += 3; }
Err(_) => { out.push('%'); i += 1; }
}
}
c => { out.push(c as char); i += 1; }
}
}
out
}
fn render_board_media(slug: &str) -> BoardMedia {
let files = list_page_files(slug);
let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();
// schematic: render the whole hierarchical project so the root's sheet
// symbols resolve (a single-file render can't), one SVG per sheet, root first
let sheets = render_sch_project(slug);
let pcb_bytes = pcb.as_ref().and_then(|n| wiki_bytes(slug, n));
// draw order = z-order (last on top): bottom/inner copper first (dimmed in
// the UI), then F.Cu on top (kept opaque = the solid top layer), then the
// silk/edge/fab docs. No F.Mask (its magenta would paint the pads purple).
let pcb_svg = pcb_bytes.as_ref().and_then(|b| kicad_export(slug, "pcb", "svg", "?layers=B.Cu,In2.Cu,In1.Cu,F.Cu,F.Silkscreen,Edge.Cuts", b)).and_then(|b| String::from_utf8(b).ok());
// GLB with the real component 3D models (bundle the custom .3dshapes models)
let glb = render_board_glb(slug);
(sheets, pcb_svg, glb)
}
/// The pre-rendered assets published to a board's `render/` folder, if any
/// (manifest = which assets exist + the ordered sheet names). When present the
/// Manager serves them directly, no service-kicad, no wait, instead of
/// rendering live. Regenerated on every board publish (`--render-board`).
fn stored_render(slug: &str) -> Option<Value> {
wiki_bytes(slug, "render/manifest.json")
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
// must actually look like a manifest, not e.g. a 404 error object
.filter(|m| m.get("v").is_some() || m.get("glb").is_some() || m.get("sheets").is_some())
}
/// {rendering, has_3d, has_sch, has_pcb}. With pre-rendered assets it returns
/// immediately; otherwise rendering runs in a background thread (a board GLB can
/// take ~a minute) so the single-threaded server stays responsive and the UI
/// polls until `rendering` is false.
fn board_media(slug: &str) -> Value {
// fast path: assets pre-rendered to the page's render/ folder at publish time
if let Some(m) = stored_render(slug) {
let sheets: Vec<String> = m.get("sheets").and_then(|s| s.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.unwrap_or_default();
return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": true, "has_assets": true, "sheets": sheets,
"has_3d": m.get("glb").and_then(|b| b.as_bool()).unwrap_or(false),
"has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false),
// version archive (empty on legacy flat manifests → the UI shows single-current)
"versions": m.get("versions").cloned().unwrap_or_else(|| json!([])),
"current": m.get("current").cloned().unwrap_or(Value::Null)});
}
// no stored render/ folder from here on, the board live-renders instead.
if let Some((sheets, pcb, glb)) = cached_board(slug) {
let names: Vec<String> = sheets.into_iter().map(|(n, _)| n).collect();
return json!({"rendering": false, "is_kicad": true, "has_render": false, "has_assets": true, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});
}
// only KiCad boards (with a .kicad_pcb / .kicad_sch) can be rendered
let files = list_page_files(slug);
let has = |ext: &str| files.iter().any(|n| n.ends_with(ext));
let is_kicad = has(".kicad_pcb") || has(".kicad_sch");
// the assets the viewer needs to display the board even WITHOUT a stored render/
// folder: the KiCad source to render the PCB + hierarchical schematic (3D comes
// from a page .glb / model_3d_path, else is live-rendered from the .kicad_pcb).
// Only when these are absent is the board truly un-renderable → show the red bar.
let has_assets = has(".kicad_pcb") && has(".kicad_sch");
if !is_kicad {
// Not a KiCad board (EAGLE/Fusion .brd/.sch, or STEP/GLB-only). We can't
// render the 2D PCB/schematic (service-kicad is KiCad-only), but if the page
// ships a model GLB we can still show the 3D, served by the board-glb route's
// <slug>.glb fallback. So report has_3d from the GLB rather than giving up.
let has_3d = has(".glb");
return json!({"rendering": false, "is_kicad": false, "has_render": false,
"has_assets": has_3d, "sheets": [], "has_3d": has_3d, "has_pcb": false});
}
let start = {
let mut g = BOARD_RENDERING.lock().unwrap();
let set = g.get_or_insert_with(std::collections::HashSet::new);
set.insert(slug.to_string())
};
if start {
let s = slug.to_string();
std::thread::spawn(move || {
let v = render_board_media(&s);
BOARD_MEDIA.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert(s.clone(), v);
if let Some(set) = BOARD_RENDERING.lock().unwrap().as_mut() {
set.remove(&s);
}
});
}
json!({"rendering": true, "is_kicad": true, "has_render": false, "has_assets": has_assets, "sheets": [], "has_3d": false, "has_pcb": false})
}
/// Does a file exist on the user's machine? None = couldn't determine
/// (bridge/desktop error), callers must treat that as "assume it exists".
fn ad_file_exists(path: &str) -> Option<bool> {
let out = ad_shell_out(&format!("[ -f {} ] && echo __EXISTS__ || echo __ABSENT__", sh_quote(path)))?;
if out.contains("__EXISTS__") {
Some(true)
} else if out.contains("__ABSENT__") {
Some(false)
} else {
None
}
}
/// Extract the top-level `(symbol "NAME" …)` block (balanced parens) from a
/// single-symbol .kicad_sym → (name, block).
fn extract_symbol_block(t: &str) -> Option<(String, String)> {
let i = t.find("(symbol \"")?;
let name = t[i + 9..].split('"').next()?.to_string();
let b = t.as_bytes();
let mut depth = 0i32;
for j in i..b.len() {
match b[j] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some((name, t[i..=j].to_string()));
}
}
_ => {}
}
}
None
}
/// Merge symbol blocks into an existing (or new) `.kicad_sym`, skipping names
/// already present.
fn merge_symbols(existing: Option<String>, blocks: &[(String, String)]) -> String {
let base = existing.filter(|s| s.contains("kicad_symbol_lib")).unwrap_or_else(|| {
// Default tier (V8), this lib lands on the user's desktop, whose
// KiCad (9.0.9) rejects files newer than itself.
let kv = altium_codec::kicad_export::KicadVersion::default();
format!(
"(kicad_symbol_lib\n\t(version {})\n\t(generator \"adom-lbr\")\n\t(generator_version \"{}\")\n)\n",
kv.sym_version(),
kv.generator_version().unwrap_or("8.0")
)
});
let mut names = std::collections::HashSet::new();
let mut idx = 0;
while let Some(p) = base[idx..].find("(symbol \"") {
let s = idx + p + 9;
match base[s..].find('"') {
Some(e) => {
names.insert(base[s..s + e].to_string());
idx = s + e;
}
None => break,
}
}
let mut add = String::new();
for (name, block) in blocks {
if !names.contains(name) {
add.push('\t');
add.push_str(block);
add.push('\n');
}
}
let cut = base.rfind(')').unwrap_or(base.len());
format!("{}{}{}", &base[..cut], add, &base[cut..])
}
/// POST /api/apply, body {items:[{slug,symLib,fpLib}], target, project?, board?}.
/// Converts each component, merges symbols into the mapped KiCad symbol library,
/// and drops footprints into the footprint `.pretty`, all on the user's machine.
// Live progress for the background apply, polled by the UI via /api/apply-progress.
static PROGRESS: Mutex<Option<Value>> = Mutex::new(None);
fn set_progress(v: Value) {
*PROGRESS.lock().unwrap() = Some(v);
}
fn get_progress() -> Value {
PROGRESS.lock().unwrap().clone().unwrap_or_else(|| json!({"stage":"idle","done":true}))
}
/// POST /api/apply, validate, then run the work on a background thread so the UI
/// can poll live stage updates. Returns immediately with `{status:"started"}`.
fn start_apply(body: &str) -> Value {
let req: Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => return json!({"status":"error","error":format!("bad body: {e}")}),
};
if req.get("target").and_then(|t| t.as_str()).unwrap_or("kicad") != "kicad" {
return json!({"status":"error","error":"only KiCad add-to-library is implemented right now"});
}
// One apply at a time, concurrent runs share SCRATCH and the PROGRESS cell.
{
let p = PROGRESS.lock().unwrap();
if let Some(v) = p.as_ref() {
if v.get("done").and_then(|d| d.as_bool()) == Some(false) {
return json!({"status":"error","error":"an apply is already running",
"hint":"poll /api/apply-progress until done:true, then retry"});
}
}
}
let board = req.get("board").and_then(|b| b.as_str()).map(String::from);
if let Some(b) = &board {
if !valid_slug(b) {
return json!({"status":"error","error":format!("invalid board slug: {b:?}")});
}
}
let mut bad: Vec<String> = vec![];
let items: Vec<(String, String, String)> = req
.get("items")
.and_then(|i| i.as_array())
.map(|a| {
a.iter()
.filter_map(|it| {
let slug = it.get("slug").and_then(|s| s.as_str())?.to_string();
if slug.is_empty() {
return None;
}
let sym = it.get("symLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();
let fp = it.get("fpLib").and_then(|s| s.as_str()).unwrap_or("adom_lbr").to_string();
// These become paths + shell args on the user's machine.
if !valid_slug(&slug) || !valid_lib(&sym) || !valid_lib(&fp) {
bad.push(slug);
return None;
}
Some((slug, sym, fp))
})
.collect()
})
.unwrap_or_default();
if !bad.is_empty() {
return json!({"status":"error",
"error":format!("invalid slug or library name on: {}", bad.join(", ")),
"hint":"library names may only contain [A-Za-z0-9_.-]"});
}
if items.is_empty() && board.is_none() {
return json!({"status":"error","error":"no components selected"});
}
let dest = req.get("dest").and_then(|d| d.as_str()).unwrap_or("").to_string();
if dest.contains(['\n', '\r', '\0']) {
return json!({"status":"error","error":"invalid dest path"});
}
let project_only = req.get("project").and_then(|p| p.as_bool()).unwrap_or(false);
let total = items.len();
set_progress(json!({"stage":"Starting…","current":0,"total":total,"done":false}));
std::thread::spawn(move || run_apply(items, board, dest, project_only));
json!({"status":"started","total":total})
}
/// Every symbol name already present anywhere in the user's symbol libraries,
/// mapped to the library that has it, so an existing symbol is never re-added.
/// Best-effort (Unix grep / Windows Select-String); empty on failure.
fn existing_symbol_index(files: &[String]) -> std::collections::HashMap<String, String> {
let mut idx = std::collections::HashMap::new();
let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();
if files.is_empty() {
return idx;
}
let (os, _) = machine();
let out = if os == "windows" {
let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");
ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '\\(symbol \"([^\"]+)\"' | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Groups[1].Value }}")))
} else {
let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");
ad_run(&format!("grep -HoE '\\(symbol \"[^\"]+\"' {list} 2>/dev/null"))
};
if let Some(out) = out {
for line in out.lines() {
let (file, name) = if os == "windows" {
match line.rsplit_once('\t') { Some((f, n)) => (f, n.trim().to_string()), None => continue }
} else {
match line.split_once(":(symbol \"") { Some((f, r)) => (f, r.trim_end_matches('"').to_string()), None => continue }
};
if !name.is_empty() {
let lib = file.rsplit(['/', '\\']).next().unwrap_or(file).trim_end_matches(".kicad_sym").to_string();
idx.entry(name).or_insert(lib);
}
}
}
idx
}
/// Which of the given MPNs appear (case-insensitively) in the user's symbol
/// libraries → a map from the found MPN (lowercased) to the library it's in.
/// Catches parts whose library symbol embeds the manufacturer part number.
fn mpns_present_in_libs(files: &[String], mpns: &[String]) -> std::collections::HashMap<String, String> {
let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();
let pats: Vec<String> = mpns.iter()
.map(|m| m.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect::<String>())
.filter(|s| s.len() >= 4)
.collect();
if files.is_empty() || pats.is_empty() {
return Default::default();
}
let (os, _) = machine();
let pat = pats.join("|");
let out = if os == "windows" {
let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");
ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '(?i)({pat})' -AllMatches | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Value }}")))
} else {
let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");
ad_run(&format!("grep -HioE '({pat})' {list} 2>/dev/null"))
};
// parse "<file>:<match>" (unix, split on the LAST ':') / "<path>\t<match>" (windows)
let mut map = std::collections::HashMap::new();
if let Some(out) = out {
for line in out.lines() {
let split = if os == "windows" { line.rsplit_once('\t') } else { line.rsplit_once(':') };
if let Some((file, m)) = split {
let m = m.trim().to_lowercase();
if m.is_empty() {
continue;
}
let lib = file.rsplit(['/', '\\']).next().unwrap_or(file).trim_end_matches(".kicad_sym").to_string();
map.entry(m).or_insert(lib);
}
}
}
map
}
/// Normalize a value/name for fuzzy compare: lowercase, µ→u, alphanumerics only.
fn norm_val(s: &str) -> String {
s.to_lowercase().replace(['µ', 'μ'], "u").chars().filter(|c| c.is_ascii_alphanumeric()).collect()
}
/// The imperial size code in a package string (0402/0603/0805/1206/…), or "".
fn pkg_size(package: &str) -> String {
let b = package.as_bytes();
let mut i = 0;
while i + 4 <= b.len() {
if b[i..i + 4].iter().all(|c| c.is_ascii_digit())
&& (i == 0 || !b[i - 1].is_ascii_digit())
&& (i + 4 == b.len() || !b[i + 4].is_ascii_digit())
{
return package[i..i + 4].to_string();
}
i += 1;
}
String::new()
}
/// Derive a (normalized-value, package) signature from a component's symbol name
/// and footprint name, the wiki symbols encode it (`C_10uF_0402`, footprint
/// `C_0402_1005Metric`) even when the part JSON's value/package fields are blank.
/// None when there's no package code (e.g. an IC), those match by MPN only.
fn value_package_sig(sym_name: &str, fp_name: &str) -> Option<(String, String)> {
let pkg = {
let p = pkg_size(fp_name);
if p.is_empty() { pkg_size(sym_name) } else { p }
};
if pkg.is_empty() {
return None;
}
// the value token: the first name segment with a digit that isn't the package
let val = sym_name
.split(|c: char| !c.is_ascii_alphanumeric() && c != '.')
.find(|t| t.chars().any(|c| c.is_ascii_digit()) && pkg_size(t).is_empty())?;
let v = norm_val(val);
if v.len() >= 2 { Some((v, pkg)) } else { None }
}
/// Footprint name (normalized) → the user's `.pretty` library that contains it.
fn footprint_index(dirs: &[String]) -> std::collections::HashMap<String, String> {
let mut idx = std::collections::HashMap::new();
let dirs: Vec<&String> = dirs.iter().filter(|d| !d.is_empty()).collect();
if dirs.is_empty() {
return idx;
}
let (os, _) = machine();
let out = if os == "windows" {
let cmds: Vec<String> = dirs.iter().map(|d| format!("dir /b /s \"{}\\*.kicad_mod\" 2>nul", d.replace('"', ""))).collect();
ad_run(&cmds.join(" & "))
} else {
let quoted = dirs.iter().map(|d| sh_quote(d)).collect::<Vec<_>>().join(" ");
ad_run(&format!("for d in {quoted}; do ls \"$d\"/*.kicad_mod 2>/dev/null; done"))
};
if let Some(out) = out {
for line in out.lines() {
let p = line.trim();
if p.is_empty() {
continue;
}
let fpname = p.rsplit(['/', '\\']).next().unwrap_or(p).trim_end_matches(".kicad_mod");
let lib = parent_dir(p).rsplit(['/', '\\']).next().unwrap_or("").trim_end_matches(".pretty").to_string();
if !fpname.is_empty() {
idx.entry(norm_val(fpname)).or_insert_with(|| lib.clone());
// also index the base part name (before the package suffix), e.g.
// "BMA580" from "BMA580_WLCSP-6", so stub parts match by base-part.
if let Some(base) = fpname.split('_').next() {
if base != fpname && base.len() >= 3 {
idx.entry(norm_val(base)).or_insert_with(|| lib.clone());
}
}
}
}
}
idx
}
/// Which of the given component slugs are ALREADY in the user's libraries, with
/// the reason and the SYMBOL + FOOTPRINT library each is found in, so neither a
/// duplicate symbol nor a duplicate footprint gets added. Symbol match: MPN (grep
/// or a lib symbol name that PREFIXES the MPN, covering stub parts), exact symbol
/// name, else value + package. Footprint match: the `.kicad_mod` is present in a
/// `.pretty`. Returns (slug, reason, symLib, fpLib), either lib may be empty.
fn components_in_libs(slugs: &[String]) -> Vec<(String, String, String, String)> {
let libs = user_libraries();
let uris = |key: &str| -> Vec<String> {
libs.get(key).and_then(|a| a.as_array())
.map(|a| a.iter().filter_map(|l| l.get("uri").and_then(|u| u.as_str()).map(String::from)).collect())
.unwrap_or_default()
};
let sym_files = uris("symLibs");
let fp_dirs = uris("fpLibs");
if (sym_files.is_empty() && fp_dirs.is_empty()) || slugs.is_empty() {
return vec![];
}
let mpn_map = mpns_present_in_libs(&sym_files, &slugs.iter().map(|s| up(s)).collect::<Vec<_>>());
let norm_index: std::collections::HashMap<String, String> =
existing_symbol_index(&sym_files).into_iter().map(|(n, l)| (norm_val(&n), l)).collect();
let fp_index = footprint_index(&fp_dirs);
let mut out = vec![];
for slug in slugs {
let nmpn = norm_val(&up(slug));
let part = load_part(slug, &up(slug)).ok().map(|(p, _)| p);
// symbol library this part already lives in (if any) + why
let (reason, sym_lib): (&str, String) = if let Some(lib) = mpn_map.get(&up(slug).to_lowercase()) {
("mpn", lib.clone())
} else if let Some((_, lib)) = norm_index.iter().find(|(n, _)| n.len() >= 5 && nmpn.starts_with(n.as_str())) {
("mpn", lib.clone())
} else if let Some(p) = &part {
let sn = norm_val(&p.symbol.name);
if sn.len() >= 3 && norm_index.contains_key(&sn) {
("symbol", norm_index[&sn].clone())
} else if let Some((v, pk)) = value_package_sig(&p.symbol.name, &p.footprint.name) {
match norm_index.iter().find(|(n, _)| n.contains(&v) && n.contains(&pk)) {
Some((_, lib)) => ("value+package", lib.clone()),
None => ("", String::new()),
}
} else {
("", String::new())
}
} else {
("", String::new())
};
// footprint library this part's .kicad_mod already lives in (if any) -
// by exact footprint name, else a footprint base name that PREFIXES the MPN
let fp_lib = part.as_ref()
.and_then(|p| fp_index.get(&norm_val(&p.footprint.name)).cloned())
.or_else(|| fp_index.iter().find(|(n, _)| n.len() >= 5 && nmpn.starts_with(n.as_str())).map(|(_, l)| l.clone()))
.unwrap_or_default();
if !sym_lib.is_empty() || !fp_lib.is_empty() {
let reason = if sym_lib.is_empty() { "footprint" } else { reason };
out.push((slug.clone(), reason.to_string(), sym_lib, fp_lib));
}
}
out
}
/// Diff a board's components against its self-contained project library at `dest`
/// on the user's machine. Returns which components are being ADDED (not yet in the
/// project lib), KEPT (already there), and which library symbols would be REMOVED
/// (in the lib, no longer on the board). `exists` = the project lib was found.
fn project_diff(board: &str, dest: &str) -> Value {
let libnick = board.replace(['/', ' '], "_");
let s = sep();
let mut lib_path = format!("{}{s}{libnick}.kicad_sym", dest.trim_end_matches(['/', '\\']));
// if it's not at the given destination, search the user's Documents for it
// (the default path guess can be wrong, e.g. "KiCad" vs "KiCAD")
if ad_file_exists(&lib_path) != Some(true) {
let (os, home) = machine();
let search = if os == "windows" {
format!("dir /b /s \"{home}\\Documents\\{libnick}.kicad_sym\" 2>nul")
} else {
format!("find {home}/Documents -maxdepth 6 -name '{libnick}.kicad_sym' 2>/dev/null | head -1")
};
if let Some(found) = ad_run(&search).filter(|f| !f.trim().is_empty()) {
lib_path = found.lines().next().unwrap_or("").trim().to_string();
}
}
// top-level symbol names already in the project lib (skip _N_N unit sub-symbols)
let existing: Vec<String> = ad_pull(&lib_path)
.and_then(|b| String::from_utf8(b).ok())
.map(|t| {
let mut names = vec![];
let mut idx = 0;
while let Some(p) = t[idx..].find("(symbol \"") {
let start = idx + p + 9;
match t[start..].find('"') {
Some(e) => {
let raw = &t[start..start + e];
let unit = { let tail: Vec<&str> = raw.rsplitn(3, '_').collect(); tail.len() == 3 && tail[0].chars().all(|c| c.is_ascii_digit()) && tail[1].chars().all(|c| c.is_ascii_digit()) && !tail[0].is_empty() && !tail[1].is_empty() };
if !unit {
names.push(raw.to_string());
}
idx = start + e;
}
None => break,
}
}
names
})
.unwrap_or_default();
let exists = !existing.is_empty() || ad_file_exists(&lib_path) == Some(true);
// The project library names its symbols the way the board's schematic does
// (generic "C"/"R"/"BMA580"), and neither carries MPNs, so compare the library
// against the board's actual SCHEMATIC symbol set, not the components' names.
let sch_syms = board_schematic_symbols(board);
let slugs: Vec<String> = board_deps(board).get("deps").and_then(|d| d.as_array())
.map(|a| a.iter().filter_map(|x| x.get("slug").and_then(|s| s.as_str()).map(String::from)).collect())
.unwrap_or_default();
// library symbols no longer used anywhere on the board → flagged for deletion
let remove: Vec<String> = existing.iter().filter(|n| !sch_syms.contains(&norm_val(n))).cloned().collect();
// the project library is built from THIS board: if it already exists every
// component is already in it (kept); otherwise every component is created (added).
let (add, keep) = if exists { (Vec::<String>::new(), slugs) } else { (slugs, Vec::<String>::new()) };
json!({ "exists": exists, "add": add, "keep": keep, "remove": remove })
}
/// The set of symbol names (normalized) the board's schematic actually uses, the
/// name after the `lib_id "Lib:Name"` colon, across every `.kicad_sch` on the page.
fn board_schematic_symbols(board: &str) -> std::collections::HashSet<String> {
let mut syms = std::collections::HashSet::new();
for name in list_page_files(board).iter().filter(|n| n.ends_with(".kicad_sch")) {
if let Some(txt) = wiki_text(board, name) {
for chunk in txt.split("(lib_id \"").skip(1) {
if let Some(id) = chunk.split('"').next() {
let n = id.rsplit(':').next().unwrap_or(id);
if !n.is_empty() {
syms.insert(norm_val(n));
}
}
}
}
}
syms
}
/// The actual work, converts + merges + delivers, updating PROGRESS at each step.
fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: String, project_only: bool) {
let _ = std::fs::create_dir_all(SCRATCH);
use std::collections::BTreeMap;
let mut by_symlib: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut by_fplib: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (slug, sym, fp) in &items {
by_symlib.entry(sym.clone()).or_default().push(slug.clone());
by_fplib.entry(fp.clone()).or_default().push(slug.clone());
}
// resolve each library NAME to its real on-disk path from the user's lib tables;
// a name not in their tables (a brand-new library) falls back to the KiCad dir.
let libs = user_libraries();
let path_map = |key: &str| -> std::collections::HashMap<String, String> {
libs.get(key).and_then(|a| a.as_array()).map(|a| {
a.iter().filter_map(|l| Some((l.get("name")?.as_str()?.to_string(), l.get("uri")?.as_str()?.to_string()))).collect()
}).unwrap_or_default()
};
let sym_paths = path_map("symLibs");
let fp_paths = path_map("fpLibs");
// for "integrate into my libraries": index every symbol/footprint already in
// the user's libraries so an existing one is skipped instead of duplicated.
// (Project-only mode builds a fresh isolated library, nothing to dedup.)
// which components are ALREADY in the user's libraries: match by MPN (which
// some library symbols embed), else by value + package. Skip those entirely
// (both symbol and footprint) so nothing gets duplicated.
// No blanket "already in a library" skip: the target library is pre-filled in
// the UI with wherever the part already lives, so merge_symbols (per-target
// dedup) keeps it out when unchanged and adds it when the user picks a
// different library. Changing the dropdown → a new symbol/footprint there.
let skipped: Vec<Value> = vec![];
let total = items.len();
let mut done_n = 0usize;
let mut added: Vec<Value> = vec![];
let mut errors: Vec<String> = vec![];
let prog = |stage: String, cur: usize| set_progress(json!({"stage":stage,"current":cur,"total":total,"done":false}));
for (symlib, slugs) in &by_symlib {
let mut blocks: Vec<(String, String)> = vec![];
for slug in slugs {
done_n += 1;
prog(format!("Converting {slug} → {symlib}"), done_n);
match load_part(slug, &up(slug)) {
Ok((part, _)) => {
if let Some(b) = extract_symbol_block(&export_kicad_symbol(&part.symbol)) {
blocks.push(b);
}
}
Err(e) => errors.push(format!("{slug}: {e}")),
}
}
prog(format!("Merging {} symbol(s) into {symlib}", blocks.len()), done_n);
let remote = sym_paths.get(symlib).cloned().unwrap_or_else(|| format!("{}{}{symlib}.kicad_sym", kicad_dir("Symbols"), sep()));
let existing = ad_pull(&remote).map(|b| String::from_utf8_lossy(&b).to_string());
// NEVER overwrite a library we failed to read. ad_pull returns None for
// both "absent" and "pull failed", only proceed with a fresh library
// when the desktop confirms the file genuinely doesn't exist.
if existing.is_none() {
match ad_file_exists(&remote) {
Some(false) => {} // genuinely absent, start a fresh library
Some(true) => {
errors.push(format!(
"{symlib}.kicad_sym exists on the desktop but could not be pulled, skipped to avoid overwriting it"
));
continue;
}
None => {
errors.push(format!(
"could not verify {symlib}.kicad_sym on the desktop (bridge error), skipped to avoid overwriting it"
));
continue;
}
}
}
let merged = merge_symbols(existing.clone(), &blocks);
// Never-shrink guard: the merge only appends, so the result must hold at
// least as many symbols as the library already had.
let n_syms = |s: &str| s.matches("(symbol \"").count();
if let Some(ex) = &existing {
if n_syms(&merged) < n_syms(ex) {
errors.push(format!("{symlib}.kicad_sym merge would shrink the library, aborted"));
continue;
}
}
// write back to the library's real file (send its basename to its dir)
let base = remote.rsplit(['/', '\\']).next().unwrap_or("lib.kicad_sym").to_string();
let local = format!("{SCRATCH}/{base}");
if let Err(e) = std::fs::write(&local, &merged) {
errors.push(format!("failed to stage {symlib}.kicad_sym locally: {e}"));
continue;
}
if ad_send(&[local], &parent_dir(&remote)) {
for (name, _) in &blocks {
added.push(json!({"symbol": name, "symLib": symlib}));
}
} else {
errors.push(format!("failed to write {symlib}.kicad_sym"));
}
}
for (fplib, slugs) in &by_fplib {
prog(format!("Delivering footprints → {fplib}.pretty"), total);
let dest = fp_paths.get(fplib).cloned().unwrap_or_else(|| format!("{}{}{fplib}.pretty", kicad_dir("Footprints"), sep()));
ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));
let mut files = vec![];
for slug in slugs {
if let Ok((part, _)) = load_part(slug, &up(slug)) {
let name = if part.footprint.name.is_empty() { up(slug) } else { part.footprint.name.clone() };
let local = format!("{SCRATCH}/{}.kicad_mod", name.replace(['/', ' '], "_"));
match std::fs::write(&local, export_kicad_footprint(&part.footprint)) {
Ok(()) => files.push(local),
Err(e) => errors.push(format!("{slug}: failed to stage footprint locally: {e}")),
}
}
}
if !files.is_empty() && !ad_send(&files, &dest) {
errors.push(format!("failed to deliver footprints to {fplib}.pretty"));
}
}
let project = match &board {
Some(b) => {
prog(if project_only { "Building self-contained project (symbols, footprints, lib-tables)…".into() } else { "Fetching KiCad project files (schematic, PCB, sheets)…".to_string() }, total);
let (folder, files) = fetch_project_files(b, &dest, project_only);
json!({"folder": folder, "files": files})
}
None => json!(null),
};
let result = json!({"status":"ok","count":added.len(),"added":added,"skipped":skipped,"errors":errors,"project":project});
set_progress(json!({"stage":"Done","current":total,"total":total,"done":true,"result":result}));
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
fn hdr(ct: &str) -> Header {
Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()
}
fn html(s: &str) -> Response<Cursor<Vec<u8>>> {
Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))
}
fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> {
Response::from_string(s).with_header(hdr("application/json"))
}
// ── embedded-tool launch + reverse proxy ──────────────────────────────────────
// Nucleus is the single entry point; each tool is a Node app it launches on demand
// and proxies under /tools/<slug>/ so it renders in an iframe on Nucleus's origin.
fn tool_spec(slug: &str) -> Option<(u16, &'static str)> {
match slug {
"klm" => Some((7823, "kicad-library-manager")),
"cfl" => Some((7821, "chip-fetcher-lite")),
_ => None,
}
}
// Prefer the dep-installed copy (~/.claude/skills/<name>), fall back to the dev source.
fn tool_dir(name: &str) -> Option<std::path::PathBuf> {
let home = std::env::var("HOME").ok()?;
let installed = std::path::PathBuf::from(format!("{home}/.claude/skills/{name}"));
if installed.join("server.js").exists() { return Some(installed); }
let src = std::path::PathBuf::from(format!("/home/adom/project/{name}"));
if src.join("server.js").exists() { return Some(src); }
None
}
fn port_open(port: u16) -> bool {
std::net::TcpStream::connect_timeout(&std::net::SocketAddr::from(([127, 0, 0, 1], port)), std::time::Duration::from_millis(300)).is_ok()
}
// Ensure the tool's Node server is up (spawn `node server.js` with PORT set if not).
fn ensure_tool(slug: &str) -> Option<u16> {
let (port, name) = tool_spec(slug)?;
if port_open(port) { return Some(port); }
let dir = tool_dir(name)?;
let _ = Command::new("node").arg(dir.join("server.js"))
.env("PORT", port.to_string()).current_dir(&dir)
.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.spawn();
for _ in 0..30 { if port_open(port) { return Some(port); } std::thread::sleep(std::time::Duration::from_millis(200)); }
None
}
// Minimal HTTP/1.0 reverse proxy to the tool's localhost port. Handles binary
// bodies (GLB models); Connection:close means read-to-EOF gets the whole response.
fn proxy_tool(slug: &str, rest: &str, query: &str, method: &Method, body: &str) -> Response<Cursor<Vec<u8>>> {
use std::io::Write;
let port = match tool_spec(slug) { Some((p, _)) => p, None => return Response::from_string("{\"error\":\"unknown tool\"}").with_status_code(404).with_header(hdr("application/json")) };
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = match std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(600)) {
Ok(s) => s,
Err(_) => return Response::from_string("{\"error\":\"tool not running\"}").with_status_code(502).with_header(hdr("application/json")),
};
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(60)));
let path = if query.is_empty() { format!("/{rest}") } else { format!("/{rest}?{query}") };
let m = match method { Method::Post => "POST", Method::Put => "PUT", Method::Delete => "DELETE", _ => "GET" };
let req = format!("{m} {path} HTTP/1.0\r\nHost: localhost\r\nAccept: */*\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
if stream.write_all(req.as_bytes()).is_err() { return Response::from_string("{\"error\":\"proxy write failed\"}").with_status_code(502).with_header(hdr("application/json")); }
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
let sep = buf.windows(4).position(|w| w == b"\r\n\r\n");
let (head, bodyb): (&[u8], &[u8]) = match sep { Some(i) => (&buf[..i], &buf[i + 4..]), None => (&buf[..], &[][..]) };
let head_str = String::from_utf8_lossy(head);
let code = head_str.lines().next().and_then(|l| l.split_whitespace().nth(1)).and_then(|c| c.parse::<u16>().ok()).unwrap_or(200);
let ct = head_str.lines().find(|l| l.to_ascii_lowercase().starts_with("content-type:"))
.and_then(|l| l.split_once(':')).map(|(_, v)| v.trim().to_string())
.unwrap_or_else(|| "application/octet-stream".into());
Response::from_data(bodyb.to_vec()).with_status_code(code).with_header(hdr(&ct))
}
const INDEX_HTML: &str = include_str!("manage.html");
const LOADER_GIF: &[u8] = include_bytes!("loader.gif");