app
Parts Search
Public Made by Adomby adom
One search box across Mouser, DigiKey and JLCPCB: parallel queries, side-by-side product photos, and a Mouser-preferred recommendation that reasons around stock and lead time.
master
John Lauer
v2.0.16: Module 2.4.0: the panel gutter check now measures against the containing block
92284b3
29d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
//! `adom-parts-search serve [--print-url]`: the Dock Bar contract.
//!
//! 1. SELF-BACKGROUNDS: spawns the server detached, waits for it to
//! answer, prints the DIRECT url (http://127.0.0.1:<port>/), exits 0.
//! 2. IDEMPOTENT + VERSION-AWARE: an instance already up with OUR version
//! is reused (print its url, exit 0). An instance of another version is
//! stopped (matched by the pid it published AND its exact argv) and
//! replaced, so a reinstall never leaves users pinned to a stale server.
//! 3. WEBVIEW OWNERSHIP: when the dock set ADOM_DOCK_WEBVIEW, the tab it
//! pre-opened is navigated to our url via `adom-cli hydrogen webview
//! open-or-refresh`; skipped when unset (a hand-run serve).
use serde_json::{json, Value};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn state_path() -> std::path::PathBuf { crate::settings::adom_dir().join("parts-search-server.json") }
pub fn direct_url(port: u16) -> String { format!("http://127.0.0.1:{}/", port) }
/// Written by the running server so serve / show can find and version it.
pub fn publish_state(port: u16) {
let _ = std::fs::create_dir_all(crate::settings::adom_dir());
let v = json!({
"port": port, "pid": std::process::id(), "version": VERSION, "url": direct_url(port),
"argv": expected_argv(port),
"dockWebview": std::env::var("ADOM_DOCK_WEBVIEW").ok(),
"dockApp": std::env::var("ADOM_DOCK_APP").ok(),
});
let _ = std::fs::write(state_path(), serde_json::to_string_pretty(&v).unwrap_or_default());
}
fn expected_argv(port: u16) -> Vec<String> {
let exe = std::env::current_exe().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| "adom-parts-search".into());
vec![exe, "app".into(), "--port".into(), port.to_string(), "--no-tab".into()]
}
/// GET /api/status on the port; Some(version) when something of ours answers.
pub fn instance_version(port: u16) -> Option<String> {
let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(1200)).build();
let r = agent.get(&format!("http://127.0.0.1:{}/api/status", port)).call().ok()?;
let v: Value = r.into_json().ok()?;
if v.get("app").and_then(|a| a.as_str()) != Some("adom-parts-search") { return None; }
Some(v.get("version").and_then(|s| s.as_str()).unwrap_or("").to_string())
}
pub fn alive(port: u16) -> bool { instance_version(port).is_some() }
/// Retire the instance that actually OWNS the port. Argv is a claim, not a
/// fact: the authority is that the process answers our /api/status on this
/// port with our app name, so we check that first and only then match the
/// published pid, and never on a substring.
fn stop_published_instance(port: u16) {
// Only ever retire a process that is genuinely serving THIS app on THIS
// port; if something else holds it, we must not kill it.
if instance_version(port).is_none() { return; }
let Ok(raw) = std::fs::read_to_string(state_path()) else { return };
let Ok(st) = serde_json::from_str::<Value>(&raw) else { return };
let Some(pid) = st.get("pid").and_then(|p| p.as_u64()) else { return };
let Ok(cmd) = std::fs::read(format!("/proc/{}/cmdline", pid)) else { return };
let argv: Vec<String> = cmd.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect();
let want = expected_argv(port);
// argv[0] may be the bare name or a path; compare the tail exactly.
let same = argv.len() == want.len()
&& argv[1..] == want[1..]
&& (argv[0] == want[0] || argv[0].rsplit('/').next() == want[0].rsplit('/').next());
if !same { return; }
let _ = Command::new("kill").arg(pid.to_string()).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status();
let _ = std::fs::remove_file(state_path());
let t0 = Instant::now();
while alive(port) && t0.elapsed() < Duration::from_secs(3) { std::thread::sleep(Duration::from_millis(100)); }
}
fn shutdown_via_api(port: u16) {
let agent = ureq::AgentBuilder::new().timeout(Duration::from_millis(3000)).build();
let _ = agent.post(&format!("http://127.0.0.1:{}/shutdown", port)).call();
let t0 = Instant::now();
while alive(port) && t0.elapsed() < Duration::from_secs(5) { std::thread::sleep(Duration::from_millis(100)); }
}
pub fn take_over_dock_webview(url: &str) {
let Ok(name) = std::env::var("ADOM_DOCK_WEBVIEW") else { return };
if name.trim().is_empty() { return; }
let _ = Command::new("adom-cli")
.args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", url])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
.spawn();
}
pub fn spawn_detached(port: u16) -> Result<(), String> {
use std::os::unix::process::CommandExt;
let exe = std::env::current_exe().map_err(|e| format!("current_exe: {}", e))?;
// setsid when available: a new session survives the parent exiting, which
// is what the dock's exec does the moment serve prints its URL.
let exe_str = exe.to_string_lossy().to_string();
let spawned = Command::new("setsid")
.args(["--fork", &exe_str, "app", "--port", &port.to_string(), "--no-tab"])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
.spawn();
match spawned {
Ok(_) => Ok(()),
Err(_) => Command::new(exe) // no setsid on this box: own process group
.args(["app", "--port", &port.to_string(), "--no-tab"])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
.process_group(0)
.spawn()
.map(|_| ())
.map_err(|e| format!("spawn: {}", e)),
}
}
/// Ensure a server of OUR version is up on `port`. Returns its direct url.
pub fn ensure(port: u16) -> Result<(String, bool), String> {
if let Some(v) = instance_version(port) {
if v == VERSION { return Ok((direct_url(port), false)); }
crate::warn(format!("running instance is {}, this is {}; restarting", if v.is_empty() { "unversioned" } else { &v }, VERSION));
stop_published_instance(port);
// THE PID FILE IS A HINT, THE PORT IS THE FACT. The state file is one
// per box, not per port, so a second copy on another port (a test, a
// dev build) overwrites it and the pid route finds nothing to kill.
// Every version since 1.1.0 answers POST /shutdown, and instance_version
// above already proved this port is serving Parts Search, so asking it
// to exit is safe. Found 2026-09-22: 2.0.15 could not replace 2.0.14.
if alive(port) { shutdown_via_api(port); }
if alive(port) {
return Err(format!("port {} is held by a parts-search server I could not stop (pid not published by this version). Hint: POST /shutdown to http://127.0.0.1:{}/ or free the port, then retry.", port, port));
}
}
spawn_detached(port)?;
let t0 = Instant::now();
while t0.elapsed() < Duration::from_secs(15) {
if alive(port) { return Ok((direct_url(port), true)); }
std::thread::sleep(Duration::from_millis(200));
}
Err(format!("the server did not answer on port {} within 15s. Hint: run `adom-parts-search app --no-tab --port {}` in the foreground to see why.", port, port))
}
pub fn run(port: u16, print_url: bool) -> Result<(), String> {
let (url, started) = ensure(port)?;
take_over_dock_webview(&url);
if print_url {
println!("{}", url);
} else {
crate::ok(format!("{} on {} (v{})", if started { "started" } else { "already running" }, url, VERSION));
if std::env::var("ADOM_DOCK_WEBVIEW").is_ok() { crate::note!(" took over dock webview \"{}\"", std::env::var("ADOM_DOCK_WEBVIEW").unwrap_or_default()); }
}
Ok(())
}