app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
//! The Hydrogen Dock Bar contract for the adom-lbr dashboard (the Manager):
//! idempotent, version-aware, self-backgrounding `dashboard serve --print-url`;
//! a cached `/api/status` with per-facet LEDs and a top-level rollup; webview
//! ownership through ADOM_DOCK_WEBVIEW; the `say` phrases and the "Try it"
//! staging through the Hydrogen control API; settings; and lifecycle jobs
//! with a live console.
//!
//! Guide: https://wiki.adom.inc/adom/hydrogen/files/docs/dockbar.md
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const APP: &str = "adom-lbr";
pub const PAGE: &str = "adom/adom-lbr";
const CONTROL_API: &str = "http://127.0.0.1:47084";
/// What `serve --print-url` prints: the DIRECT port, always. The dock's
/// workspace shares the port namespace (WSL2 mirrored networking), so the
/// `:7380/proxy/<port>/` hop is pure indirection whose prefix broke the dock's
/// health-URL join; and `127.0.0.1` rather than the name `localhost`, which
/// Windows resolves IPv6-first while WSL binds IPv4 (~820 ms vs 13 ms).
pub fn direct_url(port: u16) -> String {
format!("http://127.0.0.1:{port}/")
}
// ---------------------------------------------------------------------------
// Settings: ~/.config/adom-lbr/settings.json, local truth, typed by default
// ---------------------------------------------------------------------------
fn config_dir() -> PathBuf {
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home().join(".config"));
base.join("adom-lbr")
}
pub fn home() -> PathBuf {
std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/tmp"))
}
/// Every setting, its default, and the label the UI shows for it. The defaults
/// ARE the type: a stored value of the wrong JSON type is dropped on read.
pub fn settings_defaults() -> Value {
json!({
"show_say_strip": true,
"owner_override": "",
"list_cache_minutes": 30
})
}
pub fn settings_path() -> PathBuf {
config_dir().join("settings.json")
}
pub fn load_settings() -> Value {
let mut out = settings_defaults();
if let Ok(raw) = std::fs::read_to_string(settings_path()) {
if let Ok(Value::Object(stored)) = serde_json::from_str::<Value>(&raw) {
let defaults = settings_defaults();
for (k, v) in stored {
let same_type = match (defaults.get(&k), &v) {
(Some(Value::Bool(_)), Value::Bool(_)) => true,
(Some(Value::String(_)), Value::String(_)) => true,
(Some(Value::Number(_)), Value::Number(_)) => true,
_ => false,
};
if same_type {
out[k] = v;
}
}
}
}
out
}
/// Merge a partial update over the stored settings. Unknown keys and values of
/// the wrong type are rejected, named in the reply, and never written.
pub fn save_settings(patch: &Value) -> Value {
let defaults = settings_defaults();
let mut current = load_settings();
let mut rejected = vec![];
if let Value::Object(m) = patch {
for (k, v) in m {
let ok = match (defaults.get(k), v) {
(Some(Value::Bool(_)), Value::Bool(_)) => true,
(Some(Value::String(_)), Value::String(_)) => true,
(Some(Value::Number(_)), Value::Number(_)) => true,
_ => false,
};
if ok {
current[k] = v.clone();
} else {
rejected.push(k.clone());
}
}
}
let _ = std::fs::create_dir_all(config_dir());
match std::fs::write(settings_path(), serde_json::to_string_pretty(¤t).unwrap()) {
Ok(()) => json!({"status":"ok","settings":current,"rejected":rejected,"path":settings_path()}),
Err(e) => json!({"status":"error","error":format!("write {}: {e}", settings_path().display()),"settings":current}),
}
}
pub fn reset_settings() -> Value {
let _ = std::fs::remove_file(settings_path());
json!({"status":"ok","settings":settings_defaults(),"path":settings_path()})
}
// ---------------------------------------------------------------------------
// say: the phrases users say to their AI, from dockbar.json
// ---------------------------------------------------------------------------
const SAY_FALLBACK: &[&str] = &[
"open the lbr dashboard",
"convert this kicad symbol to an eagle lbr",
"validate my lbr for fusion",
"make an altium library from this lbr",
"show me this part in the lbr manager",
];
/// Where the installed dockbar.json lives: beside the binary's assets (install.sh
/// copies it there), the source tree when running from a checkout, else the
/// built-in list so the strip is never empty.
pub fn say_phrases() -> Vec<String> {
let mut cands = vec![home().join(".local/share/adom-lbr/dockbar.json")];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
cands.push(dir.join("dockbar.json"));
cands.push(dir.join("../dockbar.json"));
cands.push(dir.join("../../dockbar.json"));
}
}
for c in cands {
if let Ok(raw) = std::fs::read_to_string(&c) {
if let Ok(v) = serde_json::from_str::<Value>(&raw) {
if let Some(arr) = v.get("say").and_then(|s| s.as_array()) {
let list: Vec<String> = arr.iter().filter_map(|s| s.as_str().map(String::from)).take(6).collect();
if !list.is_empty() {
return list;
}
}
}
}
}
SAY_FALLBACK.iter().map(|s| s.to_string()).collect()
}
fn curl_json(url: &str, body: Option<&Value>, timeout_s: u32) -> Option<Value> {
let mut cmd = Command::new("curl");
cmd.args(["-s", "--max-time", &timeout_s.to_string()]);
if let Some(b) = body {
cmd.args(["-X", "POST", "-H", "Content-Type: application/json", "--data-binary", &b.to_string()]);
}
cmd.arg(url);
let out = cmd.output().ok()?;
if !out.status.success() || out.stdout.is_empty() {
return None;
}
serde_json::from_slice(&out.stdout).ok()
}
/// The workspace control API base: ~/.adom/hydrogen-control-url when present
/// (the canonical pointer per the say-strip contract), else the well-known port.
fn control_api_base() -> String {
std::fs::read_to_string(home().join(".adom/hydrogen-control-url"))
.map(|s| s.trim().trim_end_matches('/').to_string())
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| CONTROL_API.to_string())
}
/// "Send to AI" (say-strip contract, John's ruling 2026-08-24): open a NEW AI
/// thread named for the app and tagged dock:adom-lbr, then submit the phrase.
/// NEW THREADS ONLY - resuming or typing into existing threads stays parked
/// (try_phrase below remains dormant behind TRY_PARKED).
/// Un-parked 2026-08-24 on John's ruling; the parked early-return is DELETED
/// rather than flag-flipped (adom-ui-design 1.30.0: a dead branch cannot fail
/// under test, so a flag set false proves nothing). A future park re-adds the
/// branch as its own commit.
/// Transparent proxies for the SHARED newbie-panel module (adom-ui-design 2.0.0+).
/// The module speaks to the Hydrogen control API directly, which a page served from
/// this server cannot do cross-origin, so these two routes put the control API on the
/// page's OWN origin and pass BYTES through unchanged, both directions, status code
/// and Content-Type included (control-api-proxy contract). A parse-then-re-emit here
/// alphabetises keys, loses integer precision past 2^53, and turns a non-JSON upstream
/// body into an invented error object; the module reads delivered_to itself and
/// distinguishes delivered from opened-but-unconfirmed, so any reshaping makes it
/// retry a send that actually landed. Parse for what YOU decide (say_send below does),
/// never for what you hand back.
pub struct ProxyReply {
pub status: u16,
pub content_type: String,
pub body: Vec<u8>,
}
/// Err means the control API is unreachable on this host; the route answers 503
/// with a hint (never 404, which is indistinguishable from "route never wired").
pub fn control_proxy_raw(path: &str, post_body: Option<&str>, timeout_s: u32) -> Result<ProxyReply, String> {
let base = control_api_base();
let tmp = std::env::temp_dir().join(format!(
"adom-lbr-proxy-{}-{:x}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0)
));
let mut cmd = Command::new("curl");
cmd.args(["-s", "-o"]).arg(&tmp)
.args(["-w", "%{http_code}\t%{content_type}", "--max-time", &timeout_s.to_string()]);
if post_body.is_some() {
cmd.args(["-X", "POST", "-H", "Content-Type: application/json", "--data-binary", "@-"]);
cmd.stdin(Stdio::piped());
} else {
cmd.stdin(Stdio::null());
}
cmd.arg(format!("{base}{path}"));
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
let mut run = || -> Option<(String, Vec<u8>)> {
let mut child = cmd.spawn().ok()?;
if let Some(b) = post_body {
use std::io::Write;
child.stdin.take()?.write_all(b.as_bytes()).ok()?;
}
let out = child.wait_with_output().ok()?;
if !out.status.success() {
return None;
}
let meta = String::from_utf8_lossy(&out.stdout).to_string();
let body = std::fs::read(&tmp).unwrap_or_default();
Some((meta, body))
};
let result = run();
let _ = std::fs::remove_file(&tmp);
let Some((meta, body)) = result else {
return Err(format!("no Hydrogen control API is reachable at {base} (from ~/.adom/hydrogen-control-url, read per request)"));
};
let mut parts = meta.splitn(2, '\t');
let status: u16 = parts.next().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
if status == 0 {
return Err(format!("no Hydrogen control API is reachable at {base} (from ~/.adom/hydrogen-control-url, read per request)"));
}
let content_type = parts.next().unwrap_or("").trim().to_string();
let content_type = if content_type.is_empty() { "application/json".to_string() } else { content_type };
Ok(ProxyReply { status, content_type, body })
}
/// "Try it": stage the phrase, unsubmitted, into this app's own AI thread via
/// the Hydrogen control API. Census first (open threads), history second
/// (closed ones, resumed first), else tell the page to copy. Reachable from a
/// WSL2 workspace under mirrored networking; from a cloud container it is not,
/// and copying is the right fallback there.
/// Ship-night park (dock thread ruling, 2026-08-23): while ah's typing-path
/// fix is uncertified, NOTHING may reach /ai-threads/run-prompt — a single
/// call can crash the ah the user is standing in, and any /ai-threads call
/// wakes ah's parked backend. The UI is copy-only; this closes the scripted
/// path too (a direct POST refuses before any control-API call). Flip with
/// the page's TRY_ENABLED flag when the all-clear broadcast lands.
pub const TRY_PARKED: bool = true;
pub fn try_phrase(phrase: &str) -> Value {
if TRY_PARKED {
let _ = phrase;
return json!({
"staged": false,
"parked": true,
"reason": "Try it is parked ship-wide: ah's typing path is under repair and a run-prompt call can crash it. Copy the phrase instead; this returns when the fix is certified."
});
}
let tag = format!("app:{PAGE}");
let Some(census) = curl_json(&format!("{CONTROL_API}/ai-threads"), None, 2) else {
return json!({"staged":false,"reason":"hydrogen control api unreachable (copied instead)"});
};
for g in census.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default() {
for t in g.get("threads").and_then(|t| t.as_array()).cloned().unwrap_or_default() {
let tags = t.get("tags").and_then(|x| x.as_array()).cloned().unwrap_or_default();
if !tags.iter().any(|x| x.as_str() == Some(tag.as_str())) {
continue;
}
let mut body = json!({"prompt": phrase, "submit": false});
match t.get("sid").and_then(|s| s.as_str()) {
Some(sid) => body["sid"] = json!(sid),
None => body["tid"] = t.get("tid").cloned().unwrap_or(Value::Null),
}
return match curl_json(&format!("{CONTROL_API}/ai-threads/run-prompt"), Some(&body), 4) {
Some(r) if r.get("ok").and_then(|o| o.as_bool()) != Some(false) => json!({"staged":true,"where":"open thread"}),
_ => json!({"staged":false,"reason":"run-prompt failed (copied instead)"}),
};
}
}
let hist = curl_json(&format!("{CONTROL_API}/ai-threads/history?tag={tag}"), None, 2);
if let Some(item) = hist.and_then(|h| h.get("items").and_then(|i| i.as_array()).and_then(|a| a.first().cloned())) {
let ai = item.get("ai").and_then(|a| a.as_str()).unwrap_or("claude").to_string();
let id = item.get("id").cloned().unwrap_or(Value::Null);
let resumed = curl_json(&format!("{CONTROL_API}/ai-threads/resume"), Some(&json!({"ai":ai,"id":id})), 4);
if resumed.is_some() {
let body = json!({"sid": id, "prompt": phrase, "submit": false});
if curl_json(&format!("{CONTROL_API}/ai-threads/run-prompt"), Some(&body), 4).is_some() {
return json!({"staged":true,"where":"resumed thread"});
}
}
return json!({"staged":false,"reason":"could not resume this app's thread (copied instead)"});
}
json!({"staged":false,"reason":"no AI thread for this app yet: copy the phrase into any thread"})
}
// ---------------------------------------------------------------------------
// Status: per-facet LEDs + a rollup the app owns. Cached, answers in ms.
// ---------------------------------------------------------------------------
static STATUS_CACHE: Mutex<Option<(Instant, Value)>> = Mutex::new(None);
static STARTED: Mutex<Option<Instant>> = Mutex::new(None);
pub fn mark_started() {
*STARTED.lock().unwrap() = Some(Instant::now());
}
/// The SERVER emits the short pill state (status-leds 1.22.1): a page that
/// truncates prose is how "installed and pinned" became "in talled and pinned"
/// on a sibling app. `short` is 1-4 words and is exactly what the pill renders.
fn led_short(label: &str, state: &str, detail: String, short: &str, hint: Option<&str>) -> Value {
let mut v = led(label, state, detail, hint);
v["short"] = json!(short);
v
}
fn led(label: &str, state: &str, detail: String, hint: Option<&str>) -> Value {
let mut v = json!({"label": label, "state": state, "detail": detail});
if let Some(h) = hint {
v["_hint"] = json!(h);
}
v
}
pub fn assets_dir() -> Option<PathBuf> {
let mut cands = vec![];
if let Ok(p) = std::env::var("ADOM_LBR_ASSETS") {
cands.push(PathBuf::from(p));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(d) = exe.parent() {
cands.push(d.join("assets"));
cands.push(d.join("../share/adom-lbr"));
cands.push(d.join("../assets"));
}
}
cands.push(home().join(".local/share/adom-lbr"));
cands.into_iter().find(|d| d.join("adom-3d-viewer.min.js").is_file())
}
/// The facets, measured fresh. `library_count` is the Manager's cached wiki
/// list size (None = not loaded yet); `owner` its resolved identity.
fn build_status(library_count: Option<usize>, owner: &str) -> Value {
let mut leds: Vec<Value> = vec![];
// STALE PROCESS. The dock installs a new package but never restarts a dashboard that is
// already running, so a long-lived serve keeps answering from an executable that was
// replaced on disk hours ago. Measured on a real machine: two instances on 2.14.1 and
// 2.13.0 while five versions shipped past the user, each one dutifully reporting itself
// as healthy. Linux appends " (deleted)" to /proc/self/exe once the file behind a
// running process is unlinked, so the app can KNOW it is stale rather than infer it.
// Every dashboard already emitted this in its detail text and nobody read it, so it is
// an amber LED with the fix in it now.
let exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default();
let stale = exe.ends_with("(deleted)");
if stale {
let path = exe.trim_end_matches(" (deleted)").to_string();
leds.push(led_short("Binary", "warn",
format!("running adom-lbr {VERSION} from an executable that has since been replaced on disk ({path}); \
the installed version is newer and this process cannot pick it up"),
&format!("v{VERSION} stale"),
Some("restart the dashboard to run the installed version: the dock's Hard Refresh Open, or kill this serve and launch it again")));
} else {
leds.push(led_short("Binary", "ok", format!("adom-lbr {VERSION} at {exe}"),
&format!("v{VERSION}"), None));
}
let wiki_ok = Command::new("curl")
.args(["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "3", "https://wiki.adom.inc/health"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).starts_with('2'))
.unwrap_or(false);
leds.push(if wiki_ok {
led_short("Wiki", "ok", "wiki.adom.inc answers; components and boards load from it".into(), "answering", None)
} else {
led_short("Wiki", "fail", "wiki.adom.inc is not answering, so no components can load".into(), "not answering",
Some("check the network, then `adom-wiki doctor`"))
});
leds.push(if owner.is_empty() {
led_short("Identity", "warn", "not signed in to the wiki, so My projects cannot be told apart from public ones".into(), "not signed in",
Some("run `adom-wiki auth` in the container, or set owner_override in Settings"))
} else {
led_short("Identity", "ok", format!("My projects = boards by {owner}"), "signed in", None)
});
leds.push(match library_count {
Some(n) => led_short("Library", "ok", format!("{n} wiki components cached"), &format!("{n} parts"), None),
None => led_short("Library", "ok", "component list warming up from the wiki; the first open fetches it".into(), "warming up", None),
});
leds.push(match assets_dir() {
Some(d) => led_short("3D viewer", "ok", format!("viewer assets at {}", d.display()), "assets ok", None),
None => led_short("3D viewer", "warn", "viewer assets missing: the 3D pane of exported viewers stays empty".into(), "assets missing",
Some("reinstall: `adom-wiki pkg install adom/adom-lbr`")),
});
let dock = std::env::var("ADOM_DOCK_WEBVIEW").unwrap_or_default();
leds.push(if dock.is_empty() {
led_short("Dock", "ok", "started by hand (no dock webview to own)".into(), "standalone", None)
} else {
led_short("Dock", "ok", format!("owns the dock webview tab '{dock}'"), "tab owned", None)
});
let worst = |s: &str| leds.iter().any(|l| l.get("state").and_then(|x| x.as_str()) == Some(s));
let state = if worst("fail") { "fail" } else if worst("warn") { "warn" } else { "ok" };
let uptime = STARTED.lock().unwrap().map(|t| t.elapsed().as_secs()).unwrap_or(0);
json!({
"ok": true,
"app": APP,
"page": PAGE,
"version": VERSION,
"state": state,
"leds": leds,
"uptime_s": uptime,
"settings": load_settings(),
"_hint": "state is the app's own rollup; each LED carries a _hint naming the fix when it is not green"
})
}
/// Cached for 10 s so the dock's 15 s poll and the page's own refresh both
/// answer in milliseconds; `force` re-measures.
pub fn cached_status(library_count: Option<usize>, owner: &str, force: bool) -> Value {
let mut c = STATUS_CACHE.lock().unwrap();
if !force {
if let Some((t, v)) = c.as_ref() {
if t.elapsed() < Duration::from_secs(10) {
return v.clone();
}
}
}
let v = build_status(library_count, owner);
*c = Some((Instant::now(), v.clone()));
v
}
// ---------------------------------------------------------------------------
// Lifecycle jobs with a live console: install / update / uninstall
// ---------------------------------------------------------------------------
#[derive(Default)]
pub struct Job {
pub lines: Vec<String>,
pub done: bool,
pub exit: Option<i32>,
}
static JOBS: Mutex<Option<HashMap<String, Job>>> = Mutex::new(None);
fn with_jobs<T>(f: impl FnOnce(&mut HashMap<String, Job>) -> T) -> T {
let mut g = JOBS.lock().unwrap();
let m = g.get_or_insert_with(HashMap::new);
f(m)
}
/// Run a shell command as a named job, streaming its combined output into the
/// console that `/api/job/<name>` serves. One instance of a job at a time.
pub fn start_job(name: &str, shell: &str) -> Value {
let running = with_jobs(|m| m.get(name).map(|j| !j.done).unwrap_or(false));
if running {
return json!({"status":"error","error":format!("{name} is already running"),"hint":"poll /api/job/{name}"});
}
with_jobs(|m| { m.insert(name.to_string(), Job { lines: vec![format!("$ {shell}")], done: false, exit: None }); });
let name_s = name.to_string();
let shell_s = shell.to_string();
std::thread::spawn(move || {
let child = Command::new("sh")
.args(["-c", &format!("PATH=\"$HOME/.local/bin:$PATH\"; {shell_s} 2>&1")])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
let mut child = match child {
Ok(c) => c,
Err(e) => {
with_jobs(|m| if let Some(j) = m.get_mut(&name_s) { j.lines.push(format!("failed to start: {e}")); j.done = true; j.exit = Some(-1); });
return;
}
};
if let Some(out) = child.stdout.take() {
use std::io::BufRead;
for line in std::io::BufReader::new(out).lines().map_while(Result::ok) {
with_jobs(|m| if let Some(j) = m.get_mut(&name_s) { j.lines.push(line); });
}
}
let code = child.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
// The verify line: prove the result rather than trust the exit code.
let verify = Command::new("sh").args(["-c", "PATH=\"$HOME/.local/bin:$PATH\"; command -v adom-lbr >/dev/null 2>&1 && adom-lbr --version || echo 'adom-lbr: not on PATH'"]).output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
with_jobs(|m| if let Some(j) = m.get_mut(&name_s) {
j.lines.push(format!("exit {code}"));
j.lines.push(format!("verify: {verify}"));
j.done = true;
j.exit = Some(code);
});
*STATUS_CACHE.lock().unwrap() = None;
});
json!({"status":"ok","job":name,"hint":format!("poll /api/job/{name} for lines, done, exit")})
}
pub fn job_state(name: &str) -> Value {
with_jobs(|m| match m.get(name) {
Some(j) => json!({"status":"ok","job":name,"lines":j.lines,"done":j.done,"exit":j.exit}),
None => json!({"status":"ok","job":name,"lines":[],"done":true,"exit":null,"hint":"never run this session"}),
})
}
pub fn lifecycle_command(action: &str) -> Option<&'static str> {
match action {
"install" | "update" => Some("adom-wiki pkg install adom/adom-lbr"),
"uninstall" => Some("adom-wiki pkg uninstall adom/adom-lbr"),
_ => None,
}
}
// ---------------------------------------------------------------------------
// serve: idempotent, version-aware, self-backgrounding
// ---------------------------------------------------------------------------
/// Version of OUR dashboard on the port, or None when nothing answers or
/// something else owns it (another service is not a reason to claim success).
pub fn running_version(port: u16) -> Option<String> {
let v = curl_json(&format!("http://127.0.0.1:{port}/api/status"), None, 2)?;
if v.get("ok").and_then(|o| o.as_bool()) == Some(true) && v.get("app").and_then(|a| a.as_str()) == Some(APP) {
return Some(v.get("version").and_then(|s| s.as_str()).unwrap_or("?").to_string());
}
None
}
/// Our own SERVER processes, matched by EXACT argv shape: a binary named
/// adom-lbr running `dashboard serve ... --foreground` (the backgrounded
/// child) or `manage` (the pre-2.11 Manager). A launcher (`dashboard serve
/// --print-url` without --foreground) is never a victim, and neither are this
/// process or its parent: on ConfRoomROG (2026-08-23) the second launch
/// SIGTERMed itself and died with exit 143 and no output.
fn serve_pids(port: u16) -> Vec<i32> {
let me = std::process::id() as i32;
let parent = std::fs::read_to_string("/proc/self/stat").ok()
.and_then(|st| st.rsplit(')').next().map(|s| s.to_string()))
.and_then(|tail| tail.split_whitespace().nth(1).and_then(|p| p.parse::<i32>().ok()))
.unwrap_or(-1);
let mut out = vec![];
let Ok(rd) = std::fs::read_dir("/proc") else { return out };
for ent in rd.flatten() {
let Ok(pid) = ent.file_name().to_string_lossy().parse::<i32>() else { continue };
if pid == me || pid == parent { continue; }
let Ok(raw) = std::fs::read(ent.path().join("cmdline")) else { continue };
let argv: Vec<&[u8]> = raw.split(|b| *b == 0).filter(|s| !s.is_empty()).collect();
if argv.len() < 2 { continue; }
let base = argv[0].rsplit(|b| *b == b'/').next().unwrap_or(argv[0]);
if base != b"adom-lbr" { continue; }
let is_manage = argv[1] == b"manage";
let is_server = argv.len() >= 3 && argv[1] == b"dashboard" && argv[2] == b"serve"
&& argv.iter().any(|a| *a == b"--foreground");
if !(is_manage || is_server) { continue; }
// Scoped to THIS port: a foreign owner on one port must never get our
// healthy servers on other ports killed (caught in testing, 2026-08-23).
let their_port: u16 = argv.iter().position(|a| *a == b"--port")
.and_then(|i| argv.get(i + 1))
.and_then(|p| std::str::from_utf8(p).ok())
.and_then(|p| p.parse().ok())
.unwrap_or(8785);
if their_port == port { out.push(pid); }
}
out
}
fn kill(pid: i32, sig: &str) {
let _ = Command::new("kill").args([sig, &pid.to_string()]).stdout(Stdio::null()).stderr(Stdio::null()).status();
}
/// A healthy instance of a DIFFERENT version is the thing to replace, not a
/// reason to skip serving: version-blind reuse pinned users to a stale server
/// no reinstall could fix. Returns true when the port is free afterwards.
/// Retire by PORT OWNERSHIP, not by argv: argv is a claim, not a fact (a
/// process can be re-exec'd, renamed, or started by a wrapper). We ask the
/// port who it is first; the argv scan is only the fallback for a pre-2.11
/// instance that cannot identify itself over HTTP.
fn retire_stale(port: u16, theirs: &str) -> bool {
let victims = pids_owning_port(port);
let victims = if victims.is_empty() { serve_pids(port) } else { victims };
if victims.is_empty() {
// Something answers on the port but no server process of ours exists:
// it is not ours to kill, and waiting would not change that.
eprintln!("adom-lbr dashboard: port {port} answers ({theirs}) but no adom-lbr server process exists; something else owns it");
return false;
}
eprintln!("adom-lbr dashboard {theirs} is serving on {port} but this is {VERSION}; retiring the stale instance (pid {})",
victims.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", "));
for pid in &victims { kill(*pid, "-TERM"); }
for _ in 0..40 {
if !port_in_use(port) { return true; }
std::thread::sleep(Duration::from_millis(100));
}
for pid in &victims { kill(*pid, "-KILL"); }
std::thread::sleep(Duration::from_millis(500));
!port_in_use(port)
}
/// PIDs actually holding the listening socket, read from /proc: the fact,
/// where argv is only a claim. Empty when we cannot read it (other users'
/// sockets, a restricted /proc), and the caller falls back to argv.
fn pids_owning_port(port: u16) -> Vec<i32> {
let mut inodes: Vec<String> = vec![];
for f in ["/proc/net/tcp", "/proc/net/tcp6"] {
let Ok(txt) = std::fs::read_to_string(f) else { continue };
for line in txt.lines().skip(1) {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() < 10 { continue; }
let Some(hex_port) = cols[1].split(':').nth(1) else { continue };
if u16::from_str_radix(hex_port, 16).ok() != Some(port) { continue; }
if cols[3] != "0A" { continue; } // 0A = LISTEN
inodes.push(cols[9].to_string());
}
}
if inodes.is_empty() { return vec![]; }
let mut pids = vec![];
let Ok(rd) = std::fs::read_dir("/proc") else { return pids };
for ent in rd.flatten() {
let Ok(pid) = ent.file_name().to_string_lossy().parse::<i32>() else { continue };
let Ok(fds) = std::fs::read_dir(ent.path().join("fd")) else { continue };
for fd in fds.flatten() {
if let Ok(target) = std::fs::read_link(fd.path()) {
let t = target.to_string_lossy();
if let Some(ino) = t.strip_prefix("socket:[").and_then(|x| x.strip_suffix(']')) {
if inodes.iter().any(|i| i == ino) { pids.push(pid); break; }
}
}
}
}
pids
}
/// Does anything ACCEPT a TCP connection on the port? Only a completed
/// connect counts. Refused means free, and so does a timeout: under WSL2
/// mirrored networking a connect to a free loopback port can hang instead of
/// refusing (ConfRoomROG, 2026-08-23), which the old curl-exit-code check read
/// as "in use" and then spent 40 s failing to retire nothing.
fn port_in_use(port: u16) -> bool {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
}
/// Take ownership of the dock's pre-opened webview tab. The dock sets
/// ADOM_DOCK_WEBVIEW to the tab name; open-or-refresh with the same --name is
/// idempotent, which is the ownership handle. Unset env or a failed call are
/// silent no-ops (the dock's own interstitial navigation is the fallback).
pub fn claim_dock_webview(port: u16) {
let name = std::env::var("ADOM_DOCK_WEBVIEW").unwrap_or_default();
if name.trim().is_empty() {
return;
}
claim_named_webview(&name, port);
}
/// The claim itself, in a thread: adom-cli can take many seconds on a box
/// without Hydrogen, and the URL must print the moment the socket answers.
pub fn claim_named_webview(name: &str, port: u16) {
let name = name.to_string();
let url = direct_url(port);
std::thread::spawn(move || {
let _ = Command::new("adom-cli")
.args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
.status();
});
}
/// Reuse path: this process exits right away, so ask the RUNNING server to
/// claim the tab (it has all the time in the world).
fn ask_server_to_claim(port: u16) {
let name = std::env::var("ADOM_DOCK_WEBVIEW").unwrap_or_default();
if name.trim().is_empty() {
return;
}
let _ = curl_json(&format!("http://127.0.0.1:{port}/api/claim-webview"), Some(&json!({"name": name})), 2);
}
/// The dock entry point. Returns Ok(true) when a server is (now) serving and
/// the caller should exit 0; the foreground serve itself is the caller's job
/// when this returns Ok(false).
/// The port this process will actually serve on: the requested one, or the
/// next free port when a FOREIGN service holds it.
///
/// Found live on John's box (2026-08-25): another Adom app had taken 8785, so
/// the old guard refused with "pick another with --port" and the DOCK LAUNCH
/// DIED - correct diagnosis, useless outcome, because the human never types
/// that command; the dock does, and it scrapes whatever URL we print. A
/// foreign occupant is now a reason to move over, announced on stderr so the
/// launch console says why.
pub fn resolve_port(requested: u16) -> anyhow::Result<u16> {
if running_version(requested).is_some() || !port_in_use(requested) || !serve_pids(requested).is_empty() {
return Ok(requested); // ours, or free: the normal paths handle it
}
for p in (requested + 1)..=(requested + 20) {
if !port_in_use(p) {
eprintln!("adom-lbr dashboard: port {requested} is held by another service; serving on {p} instead");
return Ok(p);
}
}
anyhow::bail!("port {requested} is taken by another service and no free port was found in {}..{}", requested + 1, requested + 20)
}
pub fn serve_entry(port: u16, print_url: bool, foreground: bool) -> anyhow::Result<bool> {
let mut theirs = running_version(port);
// A pre-2.11 Manager has no /api/status, so it cannot identify itself; but
// it is still one of our server processes by argv, and it must make way.
// Only a port that actually ACCEPTS a connection can hold one; refused or
// timed out means free, and we simply bind.
if theirs.is_none() && port_in_use(port) {
if serve_pids(port).is_empty() {
// Should be unreachable: resolve_port moved us off a foreign port
// before we got here. Kept as a guard rather than a silent bind.
anyhow::bail!("port {port} is taken by something that is not adom-lbr");
}
if !retire_stale(port, "pre-2.11") {
anyhow::bail!("an older adom-lbr Manager owns port {port} and would not exit; kill it and relaunch");
}
}
if let Some(t) = theirs.clone() {
if t != VERSION {
if !retire_stale(port, &t) {
anyhow::bail!("a stale adom-lbr {t} instance owns port {port} and would not exit; kill it and relaunch");
}
theirs = None;
}
}
if theirs.is_some() {
if print_url { println!("{}", direct_url(port)); }
else { eprintln!("adom-lbr dashboard {VERSION} already serving on {port}; nothing to do"); }
ask_server_to_claim(port);
return Ok(true);
}
if foreground {
return Ok(false);
}
// Self-background: the dock runs steps synchronously and waits for exit, so
// a foreground server is killed at the timeout. Re-exec ourselves detached
// in a new session (the exec's tree kill cannot reach it), wait until the
// child answers on the port, then print the URL and exit 0.
let exe = std::env::current_exe()?;
let mut cmd = Command::new(exe);
cmd.args(["dashboard", "serve", "--port", &port.to_string(), "--foreground"])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd.spawn()?;
for _ in 0..100 {
if running_version(port).is_some() {
if print_url { println!("{}", direct_url(port)); }
else { eprintln!("adom-lbr dashboard {VERSION} serving on {}", direct_url(port)); }
return Ok(true);
}
std::thread::sleep(Duration::from_millis(100));
}
anyhow::bail!("adom-lbr dashboard did not answer on port {port} within 10 s")
}