app
Adom Project Manager
Public Made by Adomby adom
Adom Project Manager — 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 Manag
← Commit history
v0.11.0: dock-ready launch layer — dockbar.json (dock_manifest 1.2.0), serve verb (idempotent, version-aware retirement by port ownership, direct-URL print, ADOM_DOCK_WEBVIEW claim), CORS + OPTIONS on /api/*, instant /api/status, /favicon.svg (issue #11)
9 files changed
+312−16
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-project-manager"-version = "0.10.2"+version = "0.11.0" dependencies = [ "altium-codec", "anyhow",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-project-manager"-version = "0.10.2"+version = "0.11.0" edition = "2021" description = "Adom Project Manager — the central hub for navigating wiki boards, molecules, and libraries into Hydrogen: browse your wiki boards/projects, view symbol + footprint + 3D and the footprint layer stackup, add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr."
README.md+8−1@@ -36,9 +36,16 @@ to render and convert symbols/footprints on the fly. ## Run ```bash-adom-project-manager --port 8785+adom-project-manager serve # dock-style: idempotent, version-aware, backgrounds itself, prints the URL+adom-project-manager --port 8785 # or: run the server in the foreground ``` +`serve` is the launcher the Hydrogen Dock Bar uses (see `dockbar.json`): it reuses+a running current instance, retires an older one (found by port ownership, never+argv), steps aside if the port belongs to another app, prints a direct+`http://127.0.0.1:<port>/` URL, and claims the dock webview when+`ADOM_DOCK_WEBVIEW` is set.+ Then open `http://localhost:<port>` in a browser or Hydrogen webview. Delivery to your machine (Add to Library / Add Project) goes through the **adom-desktop** bridge, and identity/stars come from the signed-in `adom-wiki` session.
bin/adom-project-manager⋯ 1 unchanged line ⋯
dockbar.json+23−4@@ -1,10 +1,29 @@ {- "dock_manifest": "1.1.0",+ "dock_manifest": "1.2.0", "webview": { "title": "Adom Project Manager",- "url": "https://wiki.adom.inc/adom/adom-project-manager"+ "favicon": "docs/img/dock-project-manager.svg" },+ "health": { "url": "/api/status", "poll_seconds": 15, "timeout_ms": 2000 }, "launch": {- "mode": "webview"- }+ "mode": "container_command",+ "console": "show",+ "steps": [+ { "label": "Installing or updating Adom Project Manager",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg update adom/adom-project-manager || adom-wiki pkg install adom/adom-project-manager'",+ "timeout_seconds": 300 },+ { "label": "Starting the Project Manager dashboard",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec adom-project-manager serve --print-url'",+ "await": "url",+ "timeout_seconds": 120 }+ ],+ "on_ready": "navigate_placeholder"+ },+ "say": [+ "open my boards in project manager",+ "add this board's parts to my kicad library with project manager",+ "send this whole project to my computer from project manager",+ "show me the layer stackup in project manager",+ "highlight this net in project manager's board view"+ ] }
docs/img/dock-project-manager.svgadded+1@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2.1" fill="#e6edf3" stroke="none"/><ellipse cx="12" cy="12" rx="10" ry="4.3"/><ellipse cx="12" cy="12" rx="10" ry="4.3" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4.3" transform="rotate(120 12 12)"/></svg>
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-project-manager",- "version": "0.10.2",+ "version": "0.11.0", "type": "app", "description": "Adom Project Manager — 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 / adom-project-manager.)", "dependencies": {
src/main.rs+276−8@@ -20,28 +20,263 @@ 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;+ // `serve`: the dock-facing launcher — idempotent, version-aware, self-backgrounding+ // (see serve_cmd). `--render-board <slug>`: regenerate + upload the page's render/+ // assets, then exit (used by the publish flow). Otherwise serve in the foreground+ // on `--port <n>` (default 8785).+ let mut port: Option<u16> = None;+ let mut serve = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() {- if a == "--port" || a == "-p" {+ if a == "serve" {+ serve = true;+ } else if a == "--print-url" {+ // accepted for the dock manifest's sake; serve always prints the URL+ } else if a == "--port" || a == "-p" { if let Some(v) = args.next() {- port = v.parse().unwrap_or(port);+ port = v.parse().ok().or(port); } } else if let Some(v) = a.strip_prefix("--port=") {- port = v.parse().unwrap_or(port);+ port = v.parse().ok().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) {+ if serve {+ std::process::exit(serve_cmd(port));+ }+ if let Err(e) = run(port.unwrap_or(8785)) { eprintln!("ERROR: {e}"); std::process::exit(1); } } +// ---------------------------------------------------------------------------+// `serve` — the dock launch contract+// ---------------------------------------------------------------------------++/// The state file remembers which port the last `serve` settled on, so a re-run+/// finds the running instance even when the preferred port had been taken.+fn serve_state_path() -> String {+ home_container_path(".adom/project-manager-serve.json")+}++fn home_container_path(rel: &str) -> String {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());+ format!("{home}/{rel}")+}++/// GET /pm/version on a local port; Some(version) if an Adom Project Manager answers.+fn probe_version(port: u16) -> Option<String> {+ use std::io::{Read as _, Write as _};+ let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));+ let mut s = std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(700)).ok()?;+ let _ = s.set_read_timeout(Some(std::time::Duration::from_millis(700)));+ s.write_all(b"GET /pm/version HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n").ok()?;+ let mut buf = String::new();+ let _ = s.read_to_string(&mut buf);+ let body = buf.split("\r\n\r\n").nth(1)?;+ serde_json::from_str::<Value>(body).ok()?+ .get("version").and_then(|v| v.as_str()).map(String::from)+}++/// PIDs of processes LISTENing on 127.0.0.1/::1 `port`, found by socket ownership:+/// the LISTEN inode from /proc/net/tcp{,6} matched against every /proc/*/fd. Argv is+/// a claim, not a fact (zombies keep stale --port args), so ownership is the truth.+#[cfg(target_os = "linux")]+fn port_owners(port: u16) -> Vec<u32> {+ let mut inodes: Vec<String> = vec![];+ for f in ["/proc/net/tcp", "/proc/net/tcp6"] {+ let Ok(t) = std::fs::read_to_string(f) else { continue };+ for line in t.lines().skip(1) {+ let cols: Vec<&str> = line.split_whitespace().collect();+ // local_address st ... inode — LISTEN is state 0A+ if cols.len() > 9 && cols[3] == "0A" {+ if let Some((_, p)) = cols[1].rsplit_once(':') {+ if u16::from_str_radix(p, 16) == Ok(port) {+ inodes.push(cols[9].to_string());+ }+ }+ }+ }+ }+ let mut pids = vec![];+ if inodes.is_empty() {+ return pids;+ }+ let me = std::process::id();+ let parent = std::os::unix::process::parent_id();+ let Ok(procs) = std::fs::read_dir("/proc") else { return pids };+ for e in procs.flatten() {+ let Some(pid) = e.file_name().to_str().and_then(|n| n.parse::<u32>().ok()) else { continue };+ if pid == me || pid == parent {+ continue; // never retire yourself or your parent+ }+ let Ok(fds) = std::fs::read_dir(e.path().join("fd")) else { continue };+ for fd in fds.flatten() {+ if let Ok(t) = std::fs::read_link(fd.path()) {+ let t = t.to_string_lossy();+ if inodes.iter().any(|i| t == format!("socket:[{i}]")) {+ pids.push(pid);+ break;+ }+ }+ }+ }+ pids+}++#[cfg(not(target_os = "linux"))]+fn port_owners(_port: u16) -> Vec<u32> {+ vec![]+}++/// Is this pid one of ours? `pkg update` unlinks the old binary, so a running old+/// instance resolves /proc/<pid>/exe to "<path> (deleted)" — strip the suffix.+fn is_our_exe(pid: u32) -> bool {+ std::fs::read_link(format!("/proc/{pid}/exe"))+ .map(|p| {+ p.to_string_lossy()+ .trim_end_matches(" (deleted)")+ .rsplit('/')+ .next()+ .unwrap_or("")+ .contains("adom-project-manager")+ })+ .unwrap_or(false)+}++/// The dock-facing launcher. Idempotent: an already-running CURRENT instance is+/// reused. Version-aware: an older instance of ours (found by port ownership,+/// never argv) is retired first. Self-backgrounding: the server is spawned+/// detached and this process exits once it answers. Prints a direct+/// `http://127.0.0.1:<port>/` URL (ah's WSL2 world is mirrored localhost; the+/// proxy form is banned in dock manifests), and claims the dock webview when+/// ADOM_DOCK_WEBVIEW is set.+fn serve_cmd(port_flag: Option<u16>) -> i32 {+ let my_version = env!("APP_VERSION");+ let state_port = std::fs::read_to_string(serve_state_path())+ .ok()+ .and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v.get("port").and_then(|p| p.as_u64()).map(|p| p as u16));+ let preferred = port_flag.or(state_port).unwrap_or(8785);++ // Enumerate EVERY instance of ours on the candidate ports, not just the one that+ // answers: stale instances on other ports must be retired too or the user's open+ // dashboard may keep talking to an old server.+ let mut port = preferred;+ for candidate in [preferred, 8785] {+ if let Some(v) = probe_version(candidate) {+ if v == my_version {+ // current already running here — reuse it+ port = candidate;+ finish_serve(port, my_version, false);+ return 0;+ }+ // an older (or newer) us — retire it by port ownership+ let owners: Vec<u32> = port_owners(candidate).into_iter().filter(|p| is_our_exe(*p)).collect();+ for pid in &owners {+ eprintln!("retiring instance v{v} (pid {pid}) on port {candidate}");+ unsafe { libc_kill(*pid as i32, 15) };+ }+ if !owners.is_empty() {+ for _ in 0..30 {+ if probe_version(candidate).is_none() {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(100));+ }+ for pid in &owners {+ unsafe { libc_kill(*pid as i32, 9) };+ }+ port = candidate;+ }+ }+ }+ // If the port belongs to something that is NOT us, step aside instead of killing it.+ let taken_by_other = port_owners(port).iter().any(|p| !is_our_exe(*p));+ if taken_by_other {+ // u32 math: near the top of the range `port + 50` overflows u16 and wraps+ // into the privileged ports (a real failure: 65502 + 50 → 16).+ port = (u32::from(port) + 1..=65535)+ .take(50)+ .map(|p| p as u16)+ .find(|p| port_owners(*p).is_empty() && probe_version(*p).is_none())+ .unwrap_or(8785);+ }++ // Spawn the actual server detached, logging to ~/.adom.+ let log = std::fs::OpenOptions::new()+ .create(true)+ .append(true)+ .open(home_container_path(".adom/project-manager-serve.log"))+ .ok();+ let exe = std::env::current_exe().unwrap_or_else(|_| "adom-project-manager".into());+ let mut cmd = Command::new(exe);+ cmd.args(["--port", &port.to_string()]).stdin(std::process::Stdio::null());+ match log {+ Some(f) => {+ let f2 = f.try_clone().ok();+ cmd.stdout(f);+ if let Some(f2) = f2 {+ cmd.stderr(f2);+ }+ }+ None => {+ cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+ }+ }+ let child = match cmd.spawn() {+ Ok(c) => c,+ Err(e) => {+ eprintln!("ERROR: could not start server: {e}");+ return 1;+ }+ };+ for _ in 0..120 {+ if probe_version(port).as_deref() == Some(my_version) {+ let _ = std::fs::write(+ serve_state_path(),+ json!({"port": port, "pid": child.id(), "version": my_version}).to_string(),+ );+ finish_serve(port, my_version, true);+ return 0;+ }+ std::thread::sleep(std::time::Duration::from_millis(250));+ }+ eprintln!("ERROR: server did not become ready on port {port} within 30s");+ eprintln!("Hint: tail {}", home_container_path(".adom/project-manager-serve.log"));+ 1+}++extern "C" {+ #[link_name = "kill"]+ fn libc_kill(pid: i32, sig: i32) -> i32;+}++/// Print the readiness URL (the dock's `await: url` gate) and claim the dock+/// webview when the dock told us its tab name.+fn finish_serve(port: u16, version: &str, started: bool) {+ let url = format!("http://127.0.0.1:{port}/");+ println!(+ "OK: Adom Project Manager v{version} {} on {url}",+ if started { "started" } else { "already running" }+ );+ if let Ok(name) = std::env::var("ADOM_DOCK_WEBVIEW") {+ if !name.is_empty() {+ let ok = Command::new("adom-cli")+ .args(["hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url])+ .status()+ .map(|s| s.success())+ .unwrap_or(false);+ if !ok {+ eprintln!("WARN: could not claim dock webview {name:?} (adom-cli hydrogen webview open-or-refresh failed)");+ }+ }+ }+}+ /// 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.@@ -224,7 +459,24 @@ pub fn run(port: u16) -> Result<()> { // /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);+ // CORS on every /api/* route: the dock's health poll (and any embedding+ // dashboard) fetches cross-origin, and without the header it reads red+ // while curl says 200. OPTIONS preflights answer 204.+ let is_api = url.split('?').next().unwrap_or("").starts_with("/api/");+ let allow_origin = || Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap();+ let resp = if is_api && matches!(method, Method::Options) {+ Response::from_string("")+ .with_status_code(204)+ .with_header(allow_origin())+ .with_header(Header::from_bytes(&b"Access-Control-Allow-Methods"[..], &b"GET, POST, OPTIONS"[..]).unwrap())+ .with_header(Header::from_bytes(&b"Access-Control-Allow-Headers"[..], &b"Content-Type"[..]).unwrap())+ } else {+ let mut r = route(&method, &url, &body);+ if is_api {+ r = r.with_header(allow_origin());+ }+ r+ }; let _ = req.respond(resp); }); }@@ -311,6 +563,19 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { json_resp(&list_dirs(&pp).to_string()) } (Method::Get, "/") => html(INDEX_HTML),+ (Method::Get, "/favicon.svg") => Response::from_string(FAVICON_SVG)+ .with_header(hdr("image/svg+xml"))+ .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=86400"[..]).unwrap()),+ // Dock health: answered instantly from in-process state, no wiki calls. The+ // dock polls this every few seconds, so it must never block.+ (Method::Get, "/api/status") => json_resp(+ &json!({+ "state": "ok",+ "version": env!("APP_VERSION"),+ "boards_cached": LIST_CACHE.lock().unwrap().is_some(),+ })+ .to_string(),+ ), (Method::Get, "/pm/version") => Response::from_string( format!("{{\"version\":\"{}\"}}", env!("APP_VERSION"))).with_header(hdr("application/json")), // Update check: compares the running binary's version against the latest@@ -3550,3 +3815,6 @@ fn proxy_tool(slug: &str, rest: &str, query: &str, method: &Method, body: &str) // v0.9.5: board 3D routes wiki adom-3d-viewer (public) vs server-proxied fallback (private). const INDEX_HTML: &str = include_str!("manage.html"); const LOADER_GIF: &[u8] = include_bytes!("loader.gif");+// Single source for the app mark: the repo file the dock card's webview.favicon+// points at is the same file the server hands the page as /favicon.svg.+const FAVICON_SVG: &str = include_str!("../docs/img/dock-project-manager.svg");
src/manage.html+1@@ -4,6 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Adom Project Manager</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg"> <script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script> <style> :root{--bg:#0d1117;--panel:#161b22;--elevated:#1c2128;--overlay:#21262d;--edge:#30363d;--ink:#e6edf3;--mut:#8b949e;--acc:#00b8b0;--acc2:#00e6dc;--green:#3fb950;--red:#f85149;--yellow:#d29922}