app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
dashboard 1.9.44: house-contract completion (favicon, SSE live channel, ui verbs); dev-skill lessons
10 files changed
+463−103
CHANGELOG.md+14@@ -3,6 +3,20 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.9.44 - 2026-08-16 (dashboard only; bridge zip stays 1.9.42)++- **Dashboard house-contract completion (John's callout: no favicon = not done).** Served+ favicon + tab identity, SSE live channel at /events (state/toast/click events), POST+ /ui/toast + /ui/click, CLI driving verbs (`ui toast|click|state|console`), brand-hover+ app-info popover, viewport-clamped tooltips, centered dialogs, 24h idle reaper,+ --ai-thread identity forwarded to every bridge call. All ralph-tested live in pup:+ CLI-driven Settings open + toast render verified by screenshot, favicon verified in the+ actual browser tab via OS window capture.+- **Two streaming lessons now in the dev skill:** tiny_http buffers a streamed body until+ EOF (server is now hand-rolled HTTP/1.1 with per-event flush, binary smaller), and the+ container proxy buffers SSE until you send no-transform + X-Accel-Buffering: no + a 2KB+ comment padding at connect.+ ## 1.9.42 - 2026-08-16 - **THE DASHBOARD (John's Launch Page spec).** New `dashboard/` Rust CLI (`fusion-dashboard`) served
bin/fusion-dashboard⋯ 1 unchanged line ⋯
dashboard/Cargo.toml+1−2@@ -1,10 +1,9 @@ [package] name = "fusion-dashboard"-version = "1.9.42"+version = "1.9.44" edition = "2021" [dependencies]-tiny_http = "0.12" serde_json = "1" [profile.release]
dashboard/src/main.rs+330−86@@ -1,18 +1,37 @@ // fusion-dashboard - the fusion bridge's Launch Page (John's Dashboard spec, 2026-08-16).-// The FIRST container-side bridge binary: installed by the wiki pkg tarball, serves the-// Dashboard web app, and aggregates status by shelling the adom-bridge CLI (the bridge-// stays the single source of truth; this is a thin, honest aggregator).+// The FIRST container-side bridge binary: installed by the wiki pkg, serves the Dashboard+// web app, and aggregates status by shelling the adom-bridge CLI (the bridge stays the+// single source of truth; this is a thin, honest aggregator).+//+// House contract (adom-ui-design + app-creator), all of it:+// - served favicon + <link rel=icon> (the tab must carry the app's identity)+// - SSE live channel at /events (state pushes, toasts, click directives)+// - POST /ui/toast from day one - the AI narrates ON TOP of the running app+// - CLI driving verbs: `ui toast|click|state|console` so the AI drives the real UI+// - /state + /console observability, OK:/ERROR:/Hint: output, --ai-thread identity+// - 24h idle reaper so containers never accumulate zombie servers+//+// The HTTP server is hand-rolled on TcpListener: tiny_http buffers a streamed body until+// EOF (verified live - zero bytes reached an SSE client), and SSE NEEDS per-event flushes.+// We control every route and run behind the container proxy, so ~120 lines of HTTP/1.1+// with explicit flush() beats a framework that can't stream. // // AI HINTS ARE THE PRODUCT as much as the UI: every JSON response carries _hint fields-// teaching a calling AI what it can automate next. The dashboard is a surface for-// humans AND a discovery surface for AIs.+// teaching a calling AI what it can automate next. +use std::io::{BufRead, BufReader, Read, Write};+use std::net::{TcpListener, TcpStream}; use std::process::Command;+use std::sync::mpsc::{channel, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex};-use tiny_http::{Header, Method, Response, Server};+use std::time::{Duration, Instant}; const UI_HTML: &str = include_str!("../ui/index.html"); const VERSION: &str = env!("CARGO_PKG_VERSION");+const IDLE_REAP_HOURS: u64 = 24;++// Tab identity: teal rounded square, two green LEDs - matches the dock icon family.+const FAVICON_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="5" fill="#0d1117" stroke="#00b8b1" stroke-width="2"/><circle cx="8" cy="9" r="2.2" fill="#3fb950"/><circle cx="8" cy="15" r="2.2" fill="#3fb950"/><path d="M12.5 9h5M12.5 15h5" stroke="#00b8b1" stroke-width="2" stroke-linecap="round"/></svg>"##; fn ok(msg: &str) { println!("OK: {}", msg);@@ -22,11 +41,11 @@ fn err_hint(msg: &str, hint: &str) { eprintln!("Hint: {}", hint); } -/// Shell an adom-bridge verb and parse its JSON. The ai-thread names this surface so-/// every call is attributed in the user's Activity Log.-fn bridge(target: &str, verb: &str, args: &str) -> serde_json::Value {+/// Shell an adom-bridge verb and parse its JSON. --ai-thread attributes every call in the+/// user's Activity Log; forwarded from our own --ai-thread flag (app-creator identity rule).+fn bridge(ai_thread: &str, target: &str, verb: &str, args: &str) -> serde_json::Value { let out = Command::new("adom-bridge")- .args(["--target", target, "--ai-thread", "fusion-dashboard", verb, args])+ .args(["--target", target, "--ai-thread", ai_thread, verb, args]) .output(); match out { Ok(o) => serde_json::from_slice(&o.stdout).unwrap_or_else(|_| {@@ -59,11 +78,12 @@ fn default_target() -> String { } /// The LED payload: one call per facet, each with state + an AI hint on how to act.-fn gather_status(target: &str) -> serde_json::Value {- let rd = unwrap_data(&bridge(target, "fusion_readiness", "{}"));- let aps = unwrap_data(&bridge(target, "fusion_aps_status", "{}"));- let mcp = unwrap_data(&bridge(target, "fusion_mcp_status", "{}"));- let auto_upd = unwrap_data(&bridge(target, "fusion_set_auto_update", "{}"));+fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {+ let rd = unwrap_data(&bridge(ai_thread, target, "fusion_readiness", "{}"));+ let aps = unwrap_data(&bridge(ai_thread, target, "fusion_aps_status", "{}"));+ let mcp = unwrap_data(&bridge(ai_thread, target, "fusion_mcp_status", "{}"));+ // No-args form is a pure READ of the sticky pref (verified in server.py).+ let auto_upd = unwrap_data(&bridge(ai_thread, target, "fusion_set_auto_update", "{}")); let running = rd["fusionRunning"].as_bool().or(rd["running"].as_bool()).unwrap_or(false); let ready = rd["ready"].as_bool().unwrap_or(false);@@ -99,22 +119,22 @@ fn gather_status(target: &str) -> serde_json::Value { "settings": { "autoUpdateFusion": auto_upd["autoUpdateFusion"].as_bool().unwrap_or(true) },- "_hint": "This is the Dashboard status payload. Every LED carries its own _hint naming the verb that fixes it. POST /api/action {action, args} to act; GET /api/status to re-read."+ "_hint": "This is the Dashboard status payload. Every LED carries its own _hint naming the verb that fixes it. POST /api/action {action, args} to act; GET /api/status to re-read; subscribe to /events (SSE) for live pushes; POST /ui/toast {message,kind} to narrate on top of the app." }) } -fn action(target: &str, name: &str, args: &serde_json::Value) -> serde_json::Value {+fn action(ai_thread: &str, target: &str, name: &str, args: &serde_json::Value) -> serde_json::Value { let a = args.to_string(); match name {- "start" => bridge(target, "fusion_start", "{}"),- "stop" => bridge(target, "fusion_stop", "{\"reason\":\"dashboard: user clicked Stop\"}"),- "signin" => bridge(target, "fusion_signin", &a),- "tour" => bridge(target, "fusion_demo", "{\"mode\":\"tour\"}"),- "tour_progress" => bridge(target, "fusion_demo_progress", &a),- "set_auto_update" => bridge(target, "fusion_set_auto_update", &a),- "mcp_enable" => bridge(target, "fusion_mcp_enable", "{}"),- "aps_signin" => bridge(target, "fusion_aps_signin", "{}"),- "import_basic_part" => import_basic_part(target, args),+ "start" => bridge(ai_thread, target, "fusion_start", "{}"),+ "stop" => bridge(ai_thread, target, "fusion_stop", "{\"reason\":\"dashboard: user clicked Close Fusion\"}"),+ "signin" => bridge(ai_thread, target, "fusion_signin", &a),+ "tour" => bridge(ai_thread, target, "fusion_demo", "{\"mode\":\"tour\"}"),+ "tour_progress" => bridge(ai_thread, target, "fusion_demo_progress", &a),+ "set_auto_update" => bridge(ai_thread, target, "fusion_set_auto_update", &a),+ "mcp_enable" => bridge(ai_thread, target, "fusion_mcp_enable", "{}"),+ "aps_signin" => bridge(ai_thread, target, "fusion_aps_signin", "{}"),+ "import_basic_part" => import_basic_part(ai_thread, target, args), _ => serde_json::json!({"error": format!("unknown action {}", name), "_hint": "Actions: start, stop, signin, tour, tour_progress, set_auto_update, mcp_enable, aps_signin, import_basic_part."}), }@@ -122,7 +142,7 @@ fn action(target: &str, name: &str, args: &serde_json::Value) -> serde_json::Val /// The wiki-to-Fusion demo, end to end from the container: clone the part page, /// send its .lbr to the box, open it in Fusion's Library Manager. All background.-fn import_basic_part(target: &str, args: &serde_json::Value) -> serde_json::Value {+fn import_basic_part(ai_thread: &str, target: &str, args: &serde_json::Value) -> serde_json::Value { let page = args["page"].as_str().unwrap_or("aravk/in-s42ata"); let tmp = format!("/tmp/dash-part-{}", std::process::id()); let _ = std::fs::remove_dir_all(&tmp);@@ -141,76 +161,260 @@ fn import_basic_part(target: &str, args: &serde_json::Value) -> serde_json::Valu "_hint": "Pick an adom basic part page (it publishes <PART>.lbr + <PART>.step)."}); }; let dest = "C:/Users/john/AppData/Local/Temp/fusion-dashboard";- let send = bridge(target, "send_files",+ let send = bridge(ai_thread, target, "send_files", &serde_json::json!({"filePaths": [lbr.to_string_lossy()], "dest": dest}).to_string()); let name = lbr.file_name().unwrap().to_string_lossy().to_string();- let open = bridge(target, "fusion_open_lbr",+ let open = bridge(ai_thread, target, "fusion_open_lbr", &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string()); serde_json::json!({"page": page, "sent": unwrap_data(&send), "opened": unwrap_data(&open),- "_hint": "Wiki part -> Fusion Library Manager, all background. Next steps an AI can run: fusion_show_symbol / fusion_show_footprint {name}, fusion_bind_library_3d for the 3D, fusion_make_test_project for a solo demo board."})+ "_hint": "Wiki part -> Fusion Library Manager, all background. Next steps an AI can run: fusion_show_symbol / fusion_show_footprint {name}, fusion_bind_library_3d for the 3D, fusion_make_test_project for a solo demo board. Render any of them with fusion_state."}) } -fn serve(port: u16, target: String, print_url: bool) {- let server = match Server::http(("0.0.0.0", port)) {- Ok(s) => s,- Err(e) => {- err_hint(&format!("cannot bind port {}: {}", port, e),- &format!("Try `fusion-dashboard serve --port {}`", port + 1));- std::process::exit(1);- }- };- let console: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));- let log = |c: &Arc<Mutex<Vec<String>>>, m: String| {- let mut g = c.lock().unwrap();+// ── SSE hub: the live channel every Adom app carries ─────────────────────────────────────+struct Hub {+ clients: Mutex<Vec<Sender<String>>>,+}+impl Hub {+ fn broadcast(&self, event: &str, data: &serde_json::Value) {+ let msg = format!("event: {}\ndata: {}\n\n", event, data);+ self.clients.lock().unwrap().retain(|c| c.send(msg.clone()).is_ok());+ }+ fn client_count(&self) -> usize {+ // Prune dead senders lazily so the count reflects live viewers.+ let mut g = self.clients.lock().unwrap();+ g.retain(|c| c.send(": ping\n\n".into()).is_ok());+ g.len()+ }+}++struct App {+ ai_thread: String,+ target: String,+ hub: Hub,+ console: Mutex<Vec<String>>,+ last_activity: Mutex<Instant>,+}+impl App {+ fn log(&self, m: String) {+ let mut g = self.console.lock().unwrap(); g.push(m); let overflow = g.len().saturating_sub(500);- if overflow > 0 { g.drain(0..overflow); }+ if overflow > 0 {+ g.drain(0..overflow);+ }+ }+ fn tgt(&self) -> String {+ if self.target.is_empty() { default_target() } else { self.target.clone() }+ }+}++// ── Minimal HTTP/1.1 with explicit flushes ───────────────────────────────────────────────+fn respond(stream: &mut TcpStream, status: &str, ctype: &str, body: &[u8]) {+ let head = format!(+ "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n",+ status, ctype, body.len());+ let _ = stream.write_all(head.as_bytes());+ let _ = stream.write_all(body);+ let _ = stream.flush();+}+fn respond_json(stream: &mut TcpStream, v: &serde_json::Value) {+ respond(stream, "200 OK", "application/json", v.to_string().as_bytes());+}++fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {+ let mut reader = BufReader::new(match stream.try_clone() { Ok(s) => s, Err(_) => return });+ let mut req_line = String::new();+ if reader.read_line(&mut req_line).is_err() || req_line.trim().is_empty() {+ return;+ }+ let mut it = req_line.split_whitespace();+ let method = it.next().unwrap_or("").to_uppercase();+ let path_full = it.next().unwrap_or("/").to_string();+ let path = path_full.split('?').next().unwrap_or("/").to_string();+ let mut content_len = 0usize;+ loop {+ let mut h = String::new();+ if reader.read_line(&mut h).is_err() {+ return;+ }+ let h = h.trim();+ if h.is_empty() {+ break;+ }+ if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {+ content_len = v.trim().parse().unwrap_or(0);+ }+ }+ let mut body_raw = vec![0u8; content_len.min(1 << 20)];+ if content_len > 0 {+ let _ = reader.read_exact(&mut body_raw);+ }+ let body: serde_json::Value = serde_json::from_slice(&body_raw).unwrap_or(serde_json::json!({}));++ if path != "/events" {+ *app.last_activity.lock().unwrap() = Instant::now();+ }+ match (method.as_str(), path.as_str()) {+ ("GET", "/") | ("GET", "/index.html") => {+ respond(&mut stream, "200 OK", "text/html; charset=utf-8", UI_HTML.as_bytes());+ }+ ("GET", "/favicon.svg") | ("GET", "/favicon.ico") => {+ respond(&mut stream, "200 OK", "image/svg+xml", FAVICON_SVG.as_bytes());+ }+ ("GET", "/events") => {+ // The live channel: headers immediately, then one flush PER EVENT - the whole+ // reason this server is hand-rolled. X-Accel-Buffering + no-transform + a 2KB+ // comment padding defeat proxy/compression buffering (the container proxy held+ // ALL bytes until we did this - verified live: direct streamed, proxied stalled).+ let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache, no-transform\r\nX-Accel-Buffering: no\r\nAccess-Control-Allow-Origin: *\r\nConnection: keep-alive\r\n\r\n";+ if stream.write_all(head.as_bytes()).is_err() {+ return;+ }+ let padding = format!(": {}\n\n", "p".repeat(2048));+ if stream.write_all(padding.as_bytes()).is_err() {+ return;+ }+ let hello = format!("event: hello\ndata: {}\n\n", serde_json::json!({"version": VERSION}));+ if stream.write_all(hello.as_bytes()).is_err() || stream.flush().is_err() {+ return;+ }+ let (tx, rx) = channel::<String>();+ app.hub.clients.lock().unwrap().push(tx);+ app.log("sse client connected".into());+ loop {+ let msg = match rx.recv_timeout(Duration::from_secs(15)) {+ Ok(m) => m,+ Err(RecvTimeoutError::Timeout) => ": ping\n\n".to_string(),+ Err(_) => return,+ };+ if stream.write_all(msg.as_bytes()).is_err() || stream.flush().is_err() {+ app.log("sse client disconnected".into());+ return; // rx drops -> sender errors -> hub prunes on next broadcast+ }+ }+ }+ ("GET", "/api/status") | ("GET", "/state") => {+ let t = app.tgt();+ app.log(format!("status target={}", t));+ let st = gather_status(&app.ai_thread, &t);+ app.hub.broadcast("state", &st);+ respond_json(&mut stream, &st);+ }+ ("GET", "/console") => {+ let v = serde_json::json!({"lines": *app.console.lock().unwrap()});+ respond_json(&mut stream, &v);+ }+ ("GET", "/health") => {+ respond_json(&mut stream, &serde_json::json!({"ok": true, "version": VERSION,+ "sseClients": app.hub.client_count()}));+ }+ ("POST", "/api/action") => {+ let name = body["action"].as_str().unwrap_or("").to_string();+ let t = app.tgt();+ app.log(format!("action {} target={}", name, t));+ let out = action(&app.ai_thread, &t, &name, &body["args"]);+ respond_json(&mut stream, &out);+ }+ ("POST", "/ui/toast") => {+ // The day-one narration hook: the AI (or CLI) raises a toast OVER the app.+ let msg = body["message"].as_str().unwrap_or("").to_string();+ let kind = body["kind"].as_str().unwrap_or("info").to_string();+ app.log(format!("toast [{}] {}", kind, msg));+ app.hub.broadcast("toast", &serde_json::json!({"message": msg, "kind": kind}));+ respond_json(&mut stream, &serde_json::json!({"ok": true,+ "delivered": app.hub.client_count(),+ "_hint": "delivered = live SSE viewers that showed it. 0 means nobody has the page open - open it first."}));+ }+ ("POST", "/ui/click") => {+ // CLI-driven front-end click: the page clicks its own control (same path a user takes).+ let target = body["target"].as_str().unwrap_or("").to_string();+ app.log(format!("ui click {}", target));+ app.hub.broadcast("click", &serde_json::json!({"target": target}));+ respond_json(&mut stream, &serde_json::json!({"ok": true,+ "delivered": app.hub.client_count(),+ "_hint": "Targets: setup, start, stop, tour, import-part, settings, refresh. The PAGE performs the click, so what the AI drives is exactly what the user gets."}));+ }+ _ => {+ respond(&mut stream, "404 Not Found", "application/json",+ serde_json::json!({"error": "not found",+ "_hint": "GET / /favicon.svg /api/status /state /console /health /events(SSE); POST /api/action {action,args}, /ui/toast {message,kind}, /ui/click {target}"}).to_string().as_bytes());+ }+ }+}++fn serve(port: u16, target: String, ai_thread: String, print_url: bool) {+ let mut port = port;+ let listener = loop {+ match TcpListener::bind(("0.0.0.0", port)) {+ Ok(l) => break l,+ Err(e) => {+ // --port auto behavior: walk forward a little before giving up.+ if port < 8940 {+ port += 1;+ continue;+ }+ err_hint(&format!("cannot bind any port 8930-8940: {}", e),+ "Another instance may be serving - `fusion-dashboard ui state` to check it.");+ std::process::exit(1);+ }+ } };+ let app = Arc::new(App {+ ai_thread,+ target,+ hub: Hub { clients: Mutex::new(Vec::new()) },+ console: Mutex::new(Vec::new()),+ last_activity: Mutex::new(Instant::now()),+ }); if print_url { // Hydrogen dock await:url contract - the proxied URL a webview can reach. println!("http://127.0.0.1:{}/", port); } ok(&format!("fusion-dashboard v{} serving on port {} (target {})", VERSION, port,- if target.is_empty() { "auto" } else { &target }));- for mut req in server.incoming_requests() {- let url = req.url().to_string();- let method = req.method().clone();- let tgt = if target.is_empty() { default_target() } else { target.clone() };- let json_hdr = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();- let resp = match (method, url.split('?').next().unwrap_or("")) {- (Method::Get, "/") | (Method::Get, "/index.html") => {- let h = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();- req.respond(Response::from_string(UI_HTML).with_header(h)).ok();- continue;- }- (Method::Get, "/api/status") | (Method::Get, "/state") => {- log(&console, format!("status target={}", tgt));- Response::from_string(gather_status(&tgt).to_string()).with_header(json_hdr)- }- (Method::Get, "/console") => {- let body = serde_json::json!({"lines": *console.lock().unwrap()}).to_string();- Response::from_string(body).with_header(json_hdr)- }- (Method::Get, "/health") => {- Response::from_string(serde_json::json!({"ok": true, "version": VERSION}).to_string())- .with_header(json_hdr)+ if app.target.is_empty() { "auto" } else { &app.target }));++ // 24h idle reaper (app-creator lifecycle): a dock click respawns us, so exiting when+ // nobody has used the app for a day keeps containers free of zombie servers.+ {+ let app = app.clone();+ std::thread::spawn(move || loop {+ std::thread::sleep(Duration::from_secs(3600));+ let idle = app.last_activity.lock().unwrap().elapsed();+ if idle > Duration::from_secs(IDLE_REAP_HOURS * 3600) && app.hub.client_count() == 0 {+ println!("OK: idle {}h with no viewers - reaping (dock click restarts me)", IDLE_REAP_HOURS);+ std::process::exit(0); }- (Method::Post, "/api/action") => {- let mut body = String::new();- req.as_reader().read_to_string(&mut body).ok();- let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::json!({}));- let name = v["action"].as_str().unwrap_or("").to_string();- log(&console, format!("action {} target={}", name, tgt));- let out = action(&tgt, &name, &v["args"]);- Response::from_string(out.to_string()).with_header(json_hdr)+ });+ }++ for stream in listener.incoming() {+ let Ok(stream) = stream else { continue };+ let app = app.clone();+ // Thread-per-connection so an open SSE stream never blocks the accept loop.+ std::thread::spawn(move || handle_conn(&app, stream));+ }+}++/// CLI `ui` verbs drive the RUNNING server over HTTP (find it on 8930-8940).+fn ui_call(port_flag: Option<u16>, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value, String> {+ let ports: Vec<u16> = port_flag.map(|p| vec![p]).unwrap_or((8930..=8940).collect());+ for p in ports {+ let url = format!("http://127.0.0.1:{}{}", p, path);+ let mut cmd = Command::new("curl");+ cmd.args(["-s", "-m", "60"]);+ if let Some(b) = &body {+ cmd.args(["-X", "POST", "-H", "Content-Type: application/json", "-d", &b.to_string()]);+ }+ cmd.arg(&url);+ if let Ok(o) = cmd.output() {+ if o.status.success() && !o.stdout.is_empty() {+ if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&o.stdout) {+ return Ok(v);+ } }- _ => Response::from_string(- serde_json::json!({"error": "not found",- "_hint": "GET /api/status, GET /state, GET /console, GET /health, POST /api/action {action,args}"}).to_string())- .with_header(json_hdr),- };- req.respond(resp).ok();+ } }+ Err("no fusion-dashboard server answering on 8930-8940".into()) } fn main() {@@ -219,23 +423,63 @@ fn main() { let get_flag = |name: &str| -> Option<String> { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned() };+ let ai_thread = get_flag("--ai-thread").unwrap_or_else(|| "fusion-dashboard".into());+ let port_flag: Option<u16> = get_flag("--port").and_then(|p| p.parse().ok()); match cmd { "serve" => {- let port: u16 = get_flag("--port").and_then(|p| p.parse().ok()).unwrap_or(8930); let target = get_flag("--target").unwrap_or_default(); let print_url = args.iter().any(|a| a == "--print-url");- serve(port, target, print_url);+ serve(port_flag.unwrap_or(8930), target, ai_thread, print_url); } "status" => { let target = get_flag("--target").unwrap_or_else(default_target);- println!("{}", serde_json::to_string_pretty(&gather_status(&target)).unwrap());+ println!("{}", serde_json::to_string_pretty(&gather_status(&ai_thread, &target)).unwrap());+ }+ "ui" => {+ let sub = args.get(2).map(|s| s.as_str()).unwrap_or("");+ let res = match sub {+ "toast" => {+ let msg = args.get(3).cloned().unwrap_or_default();+ if msg.is_empty() {+ err_hint("ui toast needs a message", "fusion-dashboard ui toast \"running test 3 of 8\"");+ std::process::exit(1);+ }+ let kind = get_flag("--kind").unwrap_or_else(|| "info".into());+ ui_call(port_flag, "/ui/toast", Some(serde_json::json!({"message": msg, "kind": kind})))+ }+ "click" => {+ let target = args.get(3).cloned().unwrap_or_default();+ if target.is_empty() {+ err_hint("ui click needs a target", "fusion-dashboard ui click tour (targets: setup, start, stop, tour, import-part, settings, refresh)");+ std::process::exit(1);+ }+ ui_call(port_flag, "/ui/click", Some(serde_json::json!({"target": target})))+ }+ "state" => ui_call(port_flag, "/state", None),+ "console" => ui_call(port_flag, "/console", None),+ _ => {+ err_hint("unknown ui verb", "ui toast \"msg\" [--kind ok|err|info] | ui click <target> | ui state | ui console");+ std::process::exit(1);+ }+ };+ match res {+ Ok(v) => println!("OK: {}", v),+ Err(e) => {+ err_hint(&e, "Start it first: `fusion-dashboard serve &`");+ std::process::exit(1);+ }+ } } "health" => ok(&format!("fusion-dashboard v{}", VERSION)), _ => { println!("fusion-dashboard v{} - the fusion bridge's Launch Page", VERSION);- println!(" serve [--port 8930] [--target <box>] [--print-url] serve the Dashboard");- println!(" status [--target <box>] one-shot LED JSON");- println!(" health liveness");+ println!(" serve [--port 8930] [--target <box>] [--print-url] serve the Dashboard (SSE live channel included)");+ println!(" status [--target <box>] one-shot LED JSON");+ println!(" ui toast \"msg\" [--kind ok|err|info] [--port] narrate a toast over the running app");+ println!(" ui click <setup|start|stop|tour|import-part|settings|refresh> drive the real UI");+ println!(" ui state | ui console observe the running app");+ println!(" health liveness");+ println!(" (global: --ai-thread <name> attributes bridge calls in the Activity Log)"); println!("Hint: `fusion-dashboard serve --print-url` is the Hydrogen dock launch step."); } }
dashboard/ui/index.html+66−3@@ -4,6 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Fusion Dashboard</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg"> <style> :root{ --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;@@ -49,7 +50,7 @@ h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);ma #toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:99999} .toast{background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.4)} .toast.ok{border-left-color:var(--green)}.toast.err{border-left-color:var(--red)}-dialog{background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw}+dialog{margin:auto;background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw} dialog::backdrop{background:rgba(0,0,0,.55)} .setrow{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--border)} .toggle{width:42px;height:24px;border-radius:999px;background:var(--elevated);border:1px solid var(--border);position:relative;cursor:pointer;flex:none}@@ -58,6 +59,12 @@ dialog::backdrop{background:rgba(0,0,0,.55)} .toggle.is-on::after{left:21px;background:#05221f} @media (max-width:720px){.meta{display:none}.grid{grid-template-columns:1fr 1fr}} @media (max-width:480px){.grid{grid-template-columns:1fr}}+#tt{position:fixed;z-index:99999;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;color:var(--text-body);max-width:260px;pointer-events:none;opacity:0;transform:translateY(3px);transition:opacity .18s ease,transform .18s ease;text-transform:none}+#tt.show{opacity:1;transform:none}+#appinfo{position:fixed;z-index:99998;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4);display:none;pointer-events:auto;min-width:220px}+#appinfo.show{display:block}+#appinfo a{color:var(--accent)}+.brand{cursor:default} </style> </head> <body>@@ -70,8 +77,8 @@ dialog::backdrop{background:rgba(0,0,0,.55)} <div class="subject" id="subject">connecting...</div> <div class="meta" id="meta"></div> <div class="actions">- <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.26.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.65 0-1.25.4-1.51 1z"/></svg></button>- <button class="iconbtn" id="btnRefresh" title="Refresh status" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button>+ <button class="iconbtn" id="btnSettings" data-tip="Settings: toggle bridge defaults like silent Fusion auto-update (sticky per user)" aria-label="Settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.26.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.65 0-1.25.4-1.51 1z"/></svg></button>+ <button class="iconbtn" id="btnRefresh" data-tip="Re-read every facet's status from the bridge right now (also auto-refreshes every 15s)" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button> </div> </header> <main>@@ -128,6 +135,8 @@ dialog::backdrop{background:rgba(0,0,0,.55)} </dialog> <div id="toasts"></div>+<div id="tt" role="tooltip"></div>+<div id="appinfo"></div> <script> const $=s=>document.querySelector(s); const consoleBuf=[];@@ -223,6 +232,60 @@ const PROMPTS=[ ]; $('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join(''); +// ── Live channel (SSE): state pushes, AI toasts, CLI click directives ──+const CLICK_TARGETS={setup:'#btnSetup',start:'#btnStart',stop:'#btnStop',tour:'#btnTour','import-part':'#btnImportPart',settings:'#btnSettings',refresh:'#btnRefresh'};+let sseUp=false;+function connectSSE(){+ const es=new EventSource('events');+ es.addEventListener('hello',()=>{sseUp=true;console.log('sse connected');});+ es.addEventListener('state',e=>{try{lastStatus=JSON.parse(e.data);renderLeds(lastStatus);}catch(_){}});+ es.addEventListener('toast',e=>{try{const t=JSON.parse(e.data);toast(t.message,t.kind==='info'?'':t.kind);}catch(_){}});+ es.addEventListener('click',e=>{try{+ const {target}=JSON.parse(e.data);const sel=CLICK_TARGETS[target];+ if(sel&&$(sel)){$(sel).click();}else{toast('unknown click target: '+target,'err');}+ }catch(_){}});+ es.onerror=()=>{sseUp=false;es.close();setTimeout(connectSSE,3000);};+}+connectSSE();++// ── Tooltips: body-appended, fixed, clamped to the viewport on BOTH axes ──+const tt=$('#tt');let ttTimer=null;+document.addEventListener('mouseover',e=>{+ const el=e.target.closest('[data-tip]');+ if(!el){clearTimeout(ttTimer);tt.classList.remove('show');return;}+ clearTimeout(ttTimer);+ ttTimer=setTimeout(()=>{+ tt.textContent=el.getAttribute('data-tip');+ tt.style.left='0px';tt.style.top='0px';tt.classList.add('show');+ const r=el.getBoundingClientRect(),M=8,w=tt.offsetWidth,h=tt.offsetHeight;+ let x=r.left+r.width/2-w/2, y=r.bottom+10;+ if(y+h>innerHeight-M) y=r.top-h-10; // flip above+ x=Math.max(M,Math.min(x,innerWidth-w-M)); // clamp X+ y=Math.max(M,Math.min(y,innerHeight-h-M)); // clamp Y+ tt.style.left=x+'px';tt.style.top=y+'px';+ },600);+});+document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]')){clearTimeout(ttTimer);tt.classList.remove('show');}});++// ── Brand hover popover: app identity lives here, not in the header row ──+const ai=$('#appinfo');const brand=document.querySelector('.brand');+let aiShow=null,aiHide=null;+function fillAppInfo(){+ const v=lastStatus?lastStatus.dashboardVersion:'';const bv=lastStatus?(lastStatus.bridgeVersion||'?'):'?';+ ai.innerHTML='<b style="color:var(--text)">Fusion Dashboard</b><br>dashboard v'+(v||'?')+' · bridge v'+bv++ '<br>engine: fusion-dashboard (Rust) + adom-bridge<br>author: adom<br>'++ '<a href="https://wiki.adom.inc/adom/fusion-bridge" target="_blank" rel="noopener">wiki.adom.inc/adom/fusion-bridge</a>';+}+brand.addEventListener('mouseenter',()=>{clearTimeout(aiHide);aiShow=setTimeout(()=>{+ fillAppInfo();ai.classList.add('show');+ const r=brand.getBoundingClientRect(),M=8;+ let x=Math.max(M,Math.min(r.left,innerWidth-ai.offsetWidth-M));+ ai.style.left=x+'px';ai.style.top=Math.min(r.bottom+8,innerHeight-ai.offsetHeight-M)+'px';+},600);});+brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout(()=>ai.classList.remove('show'),220);});+ai.addEventListener('mouseenter',()=>clearTimeout(aiHide));+ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);});+ refresh();setInterval(refresh,15000); </script> </body>
dev-skills/fusion-dashboard/SKILL.md+24@@ -98,6 +98,30 @@ fusion-dashboard status # one-shot JSON (the LED 7. Verify the circle end-to-end: `adom-wiki pkg install adom/fusion-bridge` in a container -> `fusion-dashboard health` -> `serve` -> `curl /api/status` returns live LEDs. +## Hard-won build lessons (v1.9.44, 2026-08-16 - John called out the misses)++1. **The house contract is ALL mandatory, not a menu.** v1.9.42 shipped without a favicon,+ without POST /ui/toast, without SSE, without CLI ui verbs - John: "they sure as fuck+ tell you to make a favicon." Before calling a dashboard done, check off: served+ favicon + <link rel=icon>, SSE /events, /ui/toast, /ui/click, CLI ui verbs,+ /state + /console, brand-hover app-info popover, clamped tooltips, 24h idle reaper,+ --ai-thread identity. THEN ralph-test each one live.+2. **tiny_http cannot stream SSE** - it buffers the entire response until the reader EOFs+ (verified: zero bytes reached a connected client, not even headers). The dashboard+ hand-rolls HTTP/1.1 on TcpListener with an explicit flush() per event. ~120 lines,+ smaller binary, full control.+3. **The container proxy buffers event-streams too.** Direct 127.0.0.1 streamed, the+ proxied URL delivered NOTHING. Fix (all three): `Cache-Control: no-cache,+ no-transform`, `X-Accel-Buffering: no`, and a ~2KB SSE comment padding written at+ connect. Without the padding the compression buffer sits on your first events.+4. **`*{margin:0}` resets kill `<dialog>` centering** - native centering is margin:auto;+ re-add `dialog{margin:auto}` or every showModal() pins top-left.+5. **`curl -I` sends HEAD** - a GET-only route 404s under it and you chase a phantom bug.+ Test GETs with `-o /dev/null -w "%{http_code}"`.+6. **Favicon-in-tab proof needs an OS window capture** (desktop_screenshot_window on the+ pup hwnd - pup window titles end "- Google Chrome for Testing"); a page screenshot+ never shows the tab bar.+ ## SDK evangelism ab's Bridge SDK gets this pattern as "the container binary facet" - filed on the ab/SDK
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.42"+DASH_VERSION="1.9.44" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.43",+ "version": "1.9.44", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+11−8@@ -1,16 +1,20 @@ { "slug": "fusion-bridge",- "version": "1.9.43",+ "version": "1.9.44", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {- "adom/adom-desktop": "*"+ "adom/adom-desktop": "^1.9.0" }, "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -70,7 +74,10 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge"+ "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page" ], "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -99,9 +106,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "author": {- "name": "John Lauer",- "email": "[email protected]"- }+ ] }
skills/fusion-dashboard/SKILL.md+15−2@@ -16,14 +16,27 @@ A Rust CLI in THIS container serving the Dashboard web app. Status comes from sh ``` fusion-dashboard serve [--port 8930] [--target <box>] [--print-url] # serve the app fusion-dashboard status [--target <box>] # one-shot LED JSON+fusion-dashboard ui toast "msg" [--kind ok|err|info] # narrate OVER the running app+fusion-dashboard ui click <setup|start|stop|tour|import-part|settings|refresh>+fusion-dashboard ui state | ui console # observe the running app fusion-dashboard health # liveness ``` - `serve --print-url` prints the local URL first - the Hydrogen dock's `await:"url"` step.-- No `--target` = first box from `adom-bridge targets`.+- No `--target` = first box from `adom-bridge targets`. Global `--ai-thread <name>`+ attributes every bridge call in the user's Activity Log. - HTTP: `GET /api/status` (the LED payload), `POST /api/action {action, args}` (start, stop, signin, tour, tour_progress, set_auto_update, mcp_enable, aps_signin,- import_basic_part), `GET /state`, `GET /console`, `GET /health`.+ import_basic_part), `GET /events` (SSE live channel: state/toast/click events),+ `POST /ui/toast {message,kind}`, `POST /ui/click {target}`, `GET /state`,+ `GET /console`, `GET /health` (includes sseClients - live viewer count).++## Driving it as an AI (the ralph loop)++The page holds an SSE connection; `ui click` makes THE PAGE click its own control (same+path a user takes) and `ui toast` narrates on top of the app while you work. Toast/click+responses return `delivered` = live viewers; 0 means nobody has the page open. Screenshot+via pup after each drive step and fix what you see. ## Displaying this app (REQUIRED - read before showing anything)