← 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)

John Lauer ·9e7d002090 ·1mo ago ·parent 85f448f
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}