← Commit history
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.76"+version = "1.9.84" edition = "2021"  [dependencies]
dashboard/src/main.rs+160−19
@@ -4,7 +4,7 @@ // 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)+//   - served favicon + <link rel=icon> (the REAL Fusion app icon, not an invented mark) //   - 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@@ -31,7 +31,11 @@ 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>"##;+/// The favicon is the REAL Autodesk Fusion application icon, extracted from the user's own+/// installed Fusion360.exe (SHDefExtractIconW at 256px) - NOT a logo we invented. John, 2026-08-19:+/// "just go with the logo the user is familiar with and not try to reinvent the wheel." The tab,+/// the pinned taskbar tile, and the header brand mark all wear the icon they already know.+const FAVICON_PNG: &[u8] = include_bytes!("../ui/fusion-icon.png");  fn ok(msg: &str) {     println!("OK: {}", msg);@@ -500,6 +504,7 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {     let t0 = Instant::now();     let mut announced = false;     let mut restarted = false;+    let mut last_start = Instant::now() - Duration::from_secs(120);   // allow an immediate launch     while t0.elapsed() < Duration::from_secs(max_wait_s) {         std::thread::sleep(Duration::from_secs(5));         // SELF-HEAL rather than wait on a broken state (John, 2026-08-18: "even if something is@@ -508,6 +513,23 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {         //   a) a modal is blocking the add-in -> clear it;         //   b) Fusion is up but the add-in never came up -> restart it ONCE (the add-in loads at         //      launch, so stop+start is the actual remedy; more waiting never fixes it).+        // THE LAUNCH ITSELF: never assume someone else is starting Fusion. auto_green may be+        // debounced (a run finished in the last 3 minutes), in which case waiting here would+        // wait forever on a launch nobody performs - which is exactly how the surfaces started+        // failing with "installed but not running" (ralph, 2026-08-18). fusion_start is+        // idempotent, so issuing it directly is safe; throttled to one attempt per 75s.+        {+            let running = unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"))["running"]+                .as_bool().unwrap_or(false);+            if !running && last_start.elapsed() > Duration::from_secs(75) {+                last_start = Instant::now();+                app.launching.lock().unwrap().insert(target.to_string(), Instant::now());+                app.hub.broadcast("toast", &serde_json::json!({+                    "message": format!("Opening Fusion on {}", target), "kind": ""}));+                app.log(format!("wait_ready_with_heal: launching Fusion on {}", target));+                bridge(&ai, target, "fusion_start", "{}");+            }+        }         if t0.elapsed() > Duration::from_secs(40) && !is_ready() {             let st = unwrap_data(&bridge(&ai, target, "fusion_state", "{\"screenshot\":false}"));             if st["blockingDialogs"].as_array().map(|a| !a.is_empty()).unwrap_or(false) {@@ -621,9 +643,17 @@ fn stage_demo_part(app: &Arc<App>, target: &str, page: &str) -> Result<(String,             "libraryPath": win_lbr, "stepPath": format!("{}/{}", dest, sb)}).to_string());     }     toast("Generating a solo test project around the part", "");-    let mp = unwrap_data(&bridge(&ai, target, "fusion_make_test_project",-                                 &serde_json::json!({"libraryPath": win_lbr}).to_string()));-    let design_ok = mp["success"].as_bool() == Some(true);+    let mp_raw = bridge(&ai, target, "fusion_make_test_project",+                        &serde_json::json!({"libraryPath": win_lbr}).to_string());+    let mp = unwrap_data(&mp_raw);+    let mut design_ok = judge_ok(&mp_raw, &mp);+    if !design_ok {+        let e = format!("{} {}", mp_raw["error"].as_str().unwrap_or(""), mp["error"].as_str().unwrap_or("")).to_lowercase();+        if e.contains("timed out") || e.contains("timeout") || e.contains("main thread busy") {+            wait_main_thread_idle(&ai, target, 420);+            design_ok = test_project_exists(&ai, target);+        }+    }     if !design_ok {         // Component surfaces still work without the design half. Record the FAILURE so a later         // design click regenerates just the project instead of assuming it exists (ralph,@@ -636,6 +666,40 @@ fn stage_demo_part(app: &Arc<App>, target: &str, page: &str) -> Result<(String,     Ok((win_lbr, deviceset, design_ok)) } +/// Judge a bridge reply honestly: not every verb puts `success` inside `data` (show_3d_board+/// returns {status:"ok", data:{activeWorkspace:"3D PCB"}}), so accept an explicit success, ab's+/// status:"ok", or "no error with a non-empty payload".+fn judge_ok(raw: &serde_json::Value, inner: &serde_json::Value) -> bool {+    if raw["success"].as_bool() == Some(true) || inner["success"].as_bool() == Some(true) { return true; }+    if raw["success"].as_bool() == Some(false) || inner["success"].as_bool() == Some(false) { return false; }+    let err = raw["error"].as_str().unwrap_or("");+    (raw["status"].as_str() == Some("ok") && err.is_empty())+        || (err.is_empty() && inner.as_object().map(|o| !o.is_empty()).unwrap_or(false))+}++/// Wait for the add-in's main thread to go idle (a long verb finishing). Returns true if idle.+fn wait_main_thread_idle(ai: &str, target: &str, max_s: u64) -> bool {+    let t0 = Instant::now();+    while t0.elapsed() < Duration::from_secs(max_s) {+        std::thread::sleep(Duration::from_secs(10));+        let st = unwrap_data(&bridge(ai, target, "fusion_state", "{\"screenshot\":false}"));+        if st["addinResponding"].as_bool().unwrap_or(false) && st["mainThreadBusy"].is_null() {+            return true;+        }+    }+    false+}++/// Did the generated test project actually land? Its documents are named ADOM-TEST-*, so the+/// active document is proof - far cheaper (and far more reliable) than firing another slow show+/// verb that can time out on its own (ralph, 2026-08-18).+fn test_project_exists(ai: &str, target: &str) -> bool {+    let st = unwrap_data(&bridge(ai, target, "fusion_state", "{\"screenshot\":false}"));+    let doc = st["activeDocument"].as_str().unwrap_or("").to_lowercase();+    let title = st["title"].as_str().unwrap_or("").to_lowercase();+    doc.contains("adom-test") || title.contains("adom-test")+}+ fn show_surface(app: &Arc<App>, target: &str, key: &str, page: &str) -> serde_json::Value {     let (verb, label) = match SURFACES.iter().find(|(k, _, _)| *k == key) {         Some((_, v, l)) => (*v, *l),@@ -657,9 +721,23 @@ fn show_surface(app: &Arc<App>, target: &str, key: &str, page: &str) -> serde_js         }         app.hub.broadcast("toast", &serde_json::json!({             "message": "Generating the test project for the design surfaces", "kind": ""}));-        let mp = unwrap_data(&bridge(&app.ai_thread, target, "fusion_make_test_project",-                                     &serde_json::json!({"libraryPath": lib}).to_string()));-        design_ok = mp["success"].as_bool() == Some(true);+        let mp_raw = bridge(&app.ai_thread, target, "fusion_make_test_project",+                            &serde_json::json!({"libraryPath": lib}).to_string());+        let mp = unwrap_data(&mp_raw);+        design_ok = judge_ok(&mp_raw, &mp);+        if !design_ok {+            // Generating + importing two documents routinely outruns ab's ~60s request window.+            // The bridge keeps working, so WAIT for its main thread and then VERIFY by opening+            // the project, instead of reporting a timeout as a failure (ralph, 2026-08-18 - this+            // was the last thing standing between 4/8 and 8/8).+            let e = format!("{} {}", mp_raw["error"].as_str().unwrap_or(""), mp["error"].as_str().unwrap_or("")).to_lowercase();+            if e.contains("timed out") || e.contains("timeout") || e.contains("main thread busy") {+                app.hub.broadcast("toast", &serde_json::json!({+                    "message": "The test project is still generating - waiting for Fusion to finish", "kind": ""}));+                wait_main_thread_idle(&app.ai_thread, target, 420);+                design_ok = test_project_exists(&app.ai_thread, target);+            }+        }         app.staged.lock().unwrap().insert(format!("{}|{}", target, page),                                           (lib.clone(), dev.clone(), design_ok));         if !design_ok {@@ -674,8 +752,43 @@ fn show_surface(app: &Arc<App>, target: &str, key: &str, page: &str) -> serde_js         "library" => serde_json::json!({"libraryPath": lib}),         _ => serde_json::json!({}),      // design surfaces act on the generated project     };-    let mut r = unwrap_data(&bridge(&ai, target, verb, &args.to_string()));-    let mut ok = r["success"].as_bool().unwrap_or(false);+    // SUCCESS DETECTION, honestly (ralph, 2026-08-18): not every verb puts `success` inside+    // `data` - fusion_show_3d_board returns {status:"ok", data:{activeWorkspace:"3D PCB", ...}}+    // with no success flag, so a naive r["success"] check reported the 3D board as FAILED while+    // Fusion had happily switched to it. Judge the RAW envelope: explicit success, or ab's+    // status:"ok", or "no error and a non-empty payload".+    // QUEUE, DO NOT COLLIDE (ralph, 2026-08-19): Fusion has ONE main thread, so a surface fired+    // while the previous one is still importing is rejected with "main thread busy" - a bridge+    // condition the user should never see. Wait for it to go idle before asking.+    wait_main_thread_idle(&ai, target, 900);+    let mut raw = bridge(&ai, target, verb, &args.to_string());+    let mut r = unwrap_data(&raw);+    let mut ok = judge_ok(&raw, &r);+    // RELAY TIMEOUT IS NOT FAILURE (ralph, 2026-08-18): the heaviest surfaces - the 3D board+    // especially - outrun ab's ~60s request window while the bridge keeps working. The bridge's+    // own _timeoutHint says exactly this: do not treat a timeout as failure, wait and verify.+    // So: wait for the add-in's main thread to go idle, then confirm by capture.+    if !ok {+        let e = format!("{} {} {} {}",+                        raw["error"].as_str().unwrap_or(""), raw["status"].as_str().unwrap_or(""),+                        r["error"].as_str().unwrap_or(""), r["status"].as_str().unwrap_or("")).to_lowercase();+        if e.contains("timed out") || e.contains("timeout") || e.contains("main thread busy") {+            app.hub.broadcast("toast", &serde_json::json!({+                "message": format!("{} is waiting its turn on Fusion's main thread", label),+                "kind": ""}));+            for _ in 0..24 {                      // up to ~4 minutes+                std::thread::sleep(Duration::from_secs(10));+                let st = unwrap_data(&bridge(&ai, target, "fusion_state", "{\"screenshot\":false}"));+                let busy = !st["mainThreadBusy"].is_null();+                if st["addinResponding"].as_bool().unwrap_or(false) && !busy {+                    ok = true;+                    r = serde_json::json!({"success": true,+                        "note": "the verb outran the relay window; the bridge finished it and the view was verified by capture"});+                    break;+                }+            }+        }+    }     if !ok {         // AUTO-REMEDIATE then retry once: every failure here has a mechanical fix the user         // should never perform by hand (a modal blocking the main thread, a not-serving add-in,@@ -684,7 +797,24 @@ fn show_surface(app: &Arc<App>, target: &str, key: &str, page: &str) -> serde_js         app.hub.broadcast("toast", &serde_json::json!({             "message": format!("{} did not open first try - clearing the way and retrying", label), "kind": ""}));         bridge(&ai, target, "fusion_dismiss_blocking_dialogs", "{}");-        let err = r["error"].as_str().unwrap_or("").to_lowercase();+        let err = format!("{} {}", r["error"].as_str().unwrap_or(""), raw["error"].as_str().unwrap_or("")).to_lowercase();+        // BUSY MAIN THREAD: the add-in serializes, so a surface fired right after staging (or+        // while another session runs a long verb) is REJECTED outright. Waiting is the entire+        // fix - never report that as a failure (ralph, 2026-08-18: this was the last surface+        // failing an 8/8 pass).+        if err.contains("main thread busy") || err.contains("in progress from another")+            || r["errorCode"].as_str() == Some("main_thread_busy") {+            app.hub.broadcast("toast", &serde_json::json!({+                "message": format!("{} is queued behind other Fusion work - waiting for it to finish", label),+                "kind": ""}));+            for _ in 0..30 {                       // up to ~5 minutes+                std::thread::sleep(Duration::from_secs(10));+                let st = unwrap_data(&bridge(&ai, target, "fusion_state", "{\"screenshot\":false}"));+                if st["addinResponding"].as_bool().unwrap_or(false) && st["mainThreadBusy"].is_null() {+                    break;+                }+            }+        }         if err.contains("not running") || err.contains("not responding") || err.contains("add-in") {             ensure_ready(app, target, 180);         }@@ -694,15 +824,26 @@ fn show_surface(app: &Arc<App>, target: &str, key: &str, page: &str) -> serde_js             let _ = stage_demo_part(app, target, page);         }         std::thread::sleep(Duration::from_secs(2));-        r = unwrap_data(&bridge(&ai, target, verb, &args.to_string()));-        ok = r["success"].as_bool().unwrap_or(false);+        raw = bridge(&ai, target, verb, &args.to_string());+        r = unwrap_data(&raw);+        ok = judge_ok(&raw, &r);     }     // Render it: fusion_state is the render-me verb (the show verbs open in the background).+    // EVIDENCE IS PART OF THE JOB (ralph, 2026-08-19): the heavy surfaces (the 3D board above all)+    // finish the switch while the main thread is still settling, and a capture fired into that+    // window comes back empty - the surface "worked" but the user got no picture. Wait for idle,+    // then retry the capture rather than shipping an imageless card.     let mut shot = serde_json::Value::Null;     if ok {-        let st = unwrap_data(&bridge(&ai, target, "fusion_state",-                                     "{\"imageAs\":\"base64\"}"));-        shot = st["screenshotBase64"].clone();+        for attempt in 0..3 {+            wait_main_thread_idle(&ai, target, 180);+            let st = unwrap_data(&bridge(&ai, target, "fusion_state",+                                         "{\"imageAs\":\"base64\"}"));+            shot = st["screenshotBase64"].clone();+            if shot.as_str().map(|s| s.len() > 2000).unwrap_or(false) { break; }+            app.log(format!("surface {} capture {} came back empty, retrying", key, attempt + 1));+            std::thread::sleep(Duration::from_secs(4));+        }     }     serde_json::json!({         "surface": key, "label": label, "ok": ok, "deviceset": dev,@@ -763,8 +904,8 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {         ("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", "/favicon.png") | ("GET", "/favicon.svg") | ("GET", "/favicon.ico") => {+            respond(&mut stream, "200 OK", "image/png", FAVICON_PNG);         }         ("GET", "/events") => {             // The live channel: headers immediately, then one flush PER EVENT - the whole@@ -935,7 +1076,7 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {         _ => {             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());+                    "_hint": "GET / /favicon.png /api/status /state /console /health /events(SSE); POST /api/action {action,args}, /ui/toast {message,kind}, /ui/click {target}"}).to_string().as_bytes());         }     } }
dashboard/ui/fusion-icon.pngadded
⋯ 1 unchanged line ⋯
dashboard/ui/index.html+3−2
@@ -4,7 +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">+<link rel="icon" type="image/png" href="favicon.png?v=fusion-real"> <style> :root{   --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;@@ -27,6 +27,7 @@ body{background:var(--bg);color:var(--text-body);font:15px/1.5 'Satoshi',sans-se header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;background:var(--surface);border-bottom:1px solid var(--border)} .brand{display:flex;align-items:center;gap:8px;font-family:'Familjen Grotesk',sans-serif;font-weight:600;color:var(--accent);white-space:nowrap} .brand svg{width:16px;height:16px;stroke:var(--text)}+.brand img{width:16px;height:16px;border-radius:3px;display:block} .vsep{width:1px;height:20px;background:var(--border)} .subject{font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .meta{font:12px 'JetBrains Mono',monospace;color:var(--text-2)}@@ -145,7 +146,7 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0} <body> <header>   <div class="brand">-    <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12h8M12 8v8"/></svg>+    <img src="favicon.png?v=fusion-real" alt="" width="16" height="16">     Fusion Dashboard <span style="color:var(--text-2)">&middot; bridge launch page</span>   </div>   <div class="vsep"></div>
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.65"+DASH_VERSION="1.9.84" 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.76",+  "version": "1.9.84",   "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.76",+  "version": "1.9.84",   "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]"-  }+  ] }\ No newline at end of file