← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.364\ No newline at end of file+1.9.365\ No newline at end of file
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.364",+  "version": "1.9.365",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.364"+version = "1.9.365" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.364"+version = "1.9.365" edition = "2021"  [dependencies]
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.364"+DASH_VERSION="1.9.365" 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.364",+  "version": "1.9.365",   "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−27
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.364",+  "version": "1.9.365",   "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": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,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.",@@ -104,28 +111,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "agent_permissions": {-    "allow_bash": [],-    "allow": [-      "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",-      "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."-    ],-    "soft_deny": [-      "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",-      "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",-      "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",-      "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."-    ],-    "environment": [-      "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",-      "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."-    ]-  },-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.9.364"+version = "1.9.365" edition = "2021" license = "MIT" 
rust/PORTING.md+14
@@ -545,3 +545,17 @@ port learned:   dock's readiness probe: "serving on port 8932" in the log, then "never answered within 120s".   `fusion-dashboard serve --print-url` now launches the server through `setsid -f` (its own   session) and falls back to the plain spawn where setsid is absent.++## 2026-09-05: the palette is the verbs' voice; busy gates ignore it++- John: the ab captions describe the demo, the in-Fusion palette describes the verb running+  right now (opening a library, minting 3D part 2 of 5, switching to the 2D board). `palette.rs`+  sends those through the add-in's activity_banner on a retrying thread; the pass-through+  chokepoint speaks for the plain verbs, the orchestrations for their own, the bind per part with+  an ETA from the per-part timing store (`mint.part`, so a 50-part library estimates itself).+- Both busy gates (our long-command gate and the add-in /status gate) now ignore an+  activity_banner in flight: two recorded runs lost four beats to "activity_banner running for+  0.1s" refusals before that.+- The tour's front gate counts Fusion's own popups (the palette host) and PopupHost as front.+- The taskbar overlay is the same 32 px PNG the Python bridge sent, not ab's larger built-in badge.+- Fusion is placed at 95% of the work area; captions are ab's medium on one line.
rust/bridge/src/addin.rs+7−2
@@ -93,7 +93,9 @@ pub fn addin_status_probe(timeout: Duration) -> Option<Value> { fn busy_gate(cmd: &str) -> Option<Value> {     if LONG_RUNNING.contains(&cmd) || BUSY_EXEMPT.contains(&cmd) { return None; }     let busy = LONG_COMMAND.lock().unwrap().clone();-    if let Some((bc, at)) = busy {+    // the palette (activity_banner) is advisory and never blocks a real verb: a beat's+    // show_2d_board was refused with "activity_banner running for 0.1s" (ConfRoomROG, 2026-09-05)+    if let Some((bc, at)) = busy.filter(|(bc, _)| bc != "activity_banner") {         let st = addin_status_probe(Duration::from_secs(3));         let progress = st.as_ref().and_then(|s| s.get("walkProgress").cloned()).filter(|p| !p.is_null());         let elapsed = (at.elapsed().as_secs_f64() * 10.0).round() / 10.0;@@ -104,7 +106,9 @@ fn busy_gate(cmd: &str) -> Option<Value> {             "_hint": format!("A cloud search ({bc}) is in progress{}, running for {elapsed}s. Do NOT retry add-in commands (get_app_state, document_info, etc.) — they will all be rejected until the search finishes. Commands that still work right now: fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, fusion_send_key, fusion_close_window. Wait for the search to complete, then retry.", pct.map(|p| format!(" (~{p}% done)")).unwrap_or_default())}));     }     if let Some(st) = addin_status_probe(Duration::from_secs(3)) {-        if st["busy"].as_bool() == Some(true) {+        // the palette (activity_banner) repaints in a fraction of a second and is advisory: a verb+        // that lands while it paints waits its turn in the add-in instead of being refused+        if st["busy"].as_bool() == Some(true) && st["busyCommand"].as_str() != Some("activity_banner") {             let elapsed = st.get("elapsedSeconds").cloned().unwrap_or(json!(0));             let bc = st["busyCommand"].as_str().unwrap_or("unknown").to_string();             let mut resp = json!({"success": false,@@ -204,6 +208,7 @@ impl<'a> Ctx<'a> { pub fn host_paths_short(&self, p: &str) -> String { (self.sho  /// The whole pass-through for one verb. pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value {+    if let Some((title, detail)) = crate::palette::for_verb(cmd, args) { crate::palette::say(&title, &detail, None, crate::palette::eta_for_verb(&ctx.state_dir, cmd)); }     let sidecar = ctx.sidecar;     let dialogs = ctx.dialogs;     let t0 = Instant::now();
rust/bridge/src/etiquette.rs+4−1
@@ -18,7 +18,10 @@ static BADGED: AtomicBool = AtomicBool::new(false); static BADGED_HWND: std::sync::Mutex<i64> = std::sync::Mutex::new(0); /// `set_driving_badge`: the Adom mark on Fusion's taskbar button while Adom drives it. pub fn set_badge(caller: &Caller, hwnd: i64) -> bool {-    let ok = Loopback::from_env().map(|ab| ab.call("desktop_taskbar", json!({"hwnd": hwnd, "overlay": {"badge": "adom", "tooltip": "Adom is driving Fusion"}}), caller, 10).is_ok()).unwrap_or(false);+    // The SAME badge the Python bridge painted (handlers/taskbar_overlay.py): our own 32 px PNG as+    // base64, not ab's built-in "adom" badge, which draws larger (John, 2026-09-05).+    static OVERLAY_PNG: &[u8] = include_bytes!("../../../resources/adom-overlay-32.png");+    let ok = Loopback::from_env().map(|ab| ab.call("desktop_taskbar", json!({"hwnd": hwnd, "overlay": {"icon": b64_pub(OVERLAY_PNG), "tooltip": "Adom is driving Fusion"}}), caller, 10).is_ok()).unwrap_or(false);     if ok { BADGED.store(true, Ordering::Relaxed); *BADGED_HWND.lock().unwrap() = hwnd; }     ok }
rust/bridge/src/main.rs+2
@@ -39,6 +39,7 @@ mod addin_install; mod janitor; mod electronics; mod tour;+mod palette; use fusion_core::ab::Caller; use fusion_core::{HostPaths, HostProcess}; use serde_json::{json, Value};@@ -757,6 +758,7 @@ fn native_aps_browse(args: &Value) -> Value {  /// `fusion_aps_search`: the server-indexed name search across the hub, newest first. pub(crate) fn native_aps_search(args: &Value) -> Value {+    crate::palette::say("Searching your hub", &format!("cloud search for {}", args["query"].as_str().unwrap_or("")), None, None);     use fusion_core::aps::*;     let query = args["query"].as_str().unwrap_or("").trim().to_string();     if query.is_empty() { return json!({"success": false, "error": "Missing query.", "shell": "rust"}); }
rust/bridge/src/mint.rs+11−2
@@ -307,13 +307,18 @@ pub fn build_library_3d(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> V             let ctx2 = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids, sidecar: &side, dialogs: &dlg };             let pass2 = |verb: &str, a: Value| crate::addin::pass_through(verb, &a, &ctx2);             let st = std::cell::RefCell::new(bstate.clone());+            let part_clock = std::cell::Cell::new(Instant::now());             let on_part = |entry: &Value, i: usize, total: usize| {+                if i > 1 { crate::timings::record(&sd, "mint.part", part_clock.get().elapsed().as_secs_f64(), None); }+                part_clock.set(Instant::now());+                let per_part = crate::chains::estimate(&sd, "mint.part", 90.0).0;+                let eta = Some(per_part * (total.saturating_sub(i) + 1) as f64);                 let mut s = st.borrow_mut();                 s["stage"] = json!(format!("minting {} ({i}/{total})", entry["package"].as_str().unwrap_or("part")));                 s["partsDone"] = json!(i);                 let mut ps = s["parts"].as_array().cloned().unwrap_or_default(); ps.push(entry.clone()); s["parts"] = json!(ps);                 crate::chains::save_state_pub(&rid, &s);-                let _ = crate::addin::addin_http("activity_banner", &json!({"action": "update", "title": format!("{lib_label}: binding 3D, part {i} of {total}"), "detail": format!("{}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), "percent": 100.0 * i as f64 / total.max(1) as f64 * 0.4, "etaSec": Value::Null, "tone": "working"}), 20);+                crate::palette::say(&format!("{lib_label}: binding 3D, part {i} of {total}"), &format!("{}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), Some(100.0 * i as f64 / total.max(1) as f64 * 0.4), eta);             };             let res = build_library_3d_core(&ctx2, &args2, &pass2, &on_part);             { let mut s = st.borrow_mut(); s["stage"] = json!("done"); s["success"] = json!(res["success"].as_bool().unwrap_or(false)); s["result"] = res; let n = s["parts"].as_array().map(|a| a.len()).unwrap_or(0); s["partsDone"] = json!(n); bstate = s.clone(); }@@ -325,9 +330,13 @@ pub fn build_library_3d(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> V             "_hint": "⏳ 3D bind runs DETACHED (survives ab's 60s relay cap, which used to kill it + lose every wip_urn). POLL the boundLbr via read_file every ~30s until it has one <package3d name=... wip_urn=urn:...> PER PART (count == partsTotal). Do NOT re-fire while running (check fusion_addin_status.busy). When done, embed those <packages3d> + a per-<element> package3d_urn in your .brd, then fusion_open_board + fusion_show_3d_board for real 3D bodies (all background). If a urn stays unresolved in the 3D view, re-run for just that part - its f3d upload failed."});     }     let lib_label = crate::chains::lib_label_pub(&combined);+    let part_clock = std::cell::Cell::new(Instant::now());     let on_part = |entry: &Value, i: usize, total: usize| {+        if i > 1 { crate::timings::record(&ctx.state_dir, "mint.part", part_clock.get().elapsed().as_secs_f64(), None); }+        part_clock.set(Instant::now());+        let per_part = crate::chains::estimate(&ctx.state_dir, "mint.part", 90.0).0;         let story = step_story_from_file(entry["modelPath"].as_str().unwrap_or(""));-        let _ = crate::addin::addin_http("activity_banner", &json!({"action": "update", "title": format!("{lib_label}: binding 3D, part {i} of {total}"), "detail": format!("{}. {story}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), "percent": 100.0 * i as f64 / total.max(1) as f64 * 0.4, "etaSec": Value::Null, "tone": "working"}), 20);+        crate::palette::say(&format!("{lib_label}: binding 3D, part {i} of {total}"), &format!("{}. {story}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), Some(100.0 * i as f64 / total.max(1) as f64 * 0.4), Some(per_part * (total.saturating_sub(i) + 1) as f64));     };     build_library_3d_core(ctx, args, pass, &on_part) }
rust/bridge/src/orchestrations.rs+2
@@ -91,6 +91,7 @@ pub fn close_bridge_doc_verified(ctx: &Ctx, name: &str, timeout_s: u64, pass: &d  /// `fusion_open_lbr`. pub fn open_lbr(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    { let stem = args["filePath"].as_str().unwrap_or("").replace('\\', "/").rsplit('/').next().unwrap_or("").trim_end_matches(".lbr").to_string(); crate::palette::say(&format!("Opening library {stem}"), "Fusion's Electronics Library editor, in the background", None, crate::palette::eta_for_verb(&ctx.state_dir, "open_lbr")); }     let file_path = ["filePath", "lbrPath", "libraryPath", "path"].iter().find_map(|k| args[k].as_str()).unwrap_or("").to_string();     if file_path.trim().is_empty() {         let mut received: Vec<String> = args.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); received.sort();@@ -333,6 +334,7 @@ pub fn open_doc_names(pass: &dyn Fn(&str, Value) -> Value) -> Vec<String> {  /// `_show_library_view`: EDIT to a library view, fit, background screenshot. Library must be open. pub fn show_library_view(ctx: &Ctx, name: &str, suffix: &str, lbr_path: Option<&str>, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    { let what = match suffix { "sym" => "symbol", "pac" => "footprint", "dev" => "component view", _ => suffix }; let lib = lbr_path.unwrap_or("").replace('\\', "/").rsplit('/').next().unwrap_or("").trim_end_matches(".lbr").to_string(); crate::palette::say(&format!("{}: {what} of {name}", if lib.is_empty() { "Library".to_string() } else { lib }), &format!("EDIT {name}.{suffix}, then WINDOW FIT"), None, crate::palette::eta_for_verb(&ctx.state_dir, "show_library_view")); }     let mut name = name.to_string();     if let Some(lp) = lbr_path {         match resolve_artifact_name(&name, suffix, lp) { Ok(r) => { if r != name { eprintln!("[fusion-bridge] resolved {name:?} -> {r:?} from the library (avoids a Create-new prompt)"); name = r; } }, Err(e) => return e }
rust/bridge/src/palette.rsadded+73
@@ -0,0 +1,73 @@+//! The in-Fusion palette ("Adom is driving Fusion"): the VERBS' voice. John, 2026-09-05: the ab+//! captions describe the demo; the palette describes the verb running against Fusion right now+//! (opening a library, minting 3D part 2 of 5, switching to the 2D board). Every message goes+//! straight to the add-in's activity_banner over its own HTTP hop (busy-exempt), on a thread that+//! retries while Fusion's main thread is tied up, so the palette lands even mid-bind. A message+//! that nobody follows within 25 s takes the palette down; a newer message cancels older retries.+use serde_json::{json, Value};+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Mutex;+use std::time::{Duration, Instant};++static GEN: AtomicU64 = AtomicU64::new(0);+static LAST: Mutex<Option<(String, String)>> = Mutex::new(None);+const TITLE_PREFIX: &str = "";++fn send(action: &str, title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) -> Value {+    crate::addin::addin_http("activity_banner", &json!({"action": action, "title": format!("{TITLE_PREFIX}{title}"), "detail": detail, "percent": percent, "etaSec": eta.map(|e| e.max(0.0).round()), "tone": "working"}), 8)+}++/// Say what is being done to Fusion right now, with the time it usually takes (from the timing+/// store, so the estimate sharpens with every run). Fire-and-forget.+pub fn say(title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) {+    let gen = GEN.fetch_add(1, Ordering::Relaxed) + 1;+    *LAST.lock().unwrap() = Some((title.to_string(), detail.to_string()));+    let (t, d) = (title.to_string(), detail.to_string());+    std::thread::spawn(move || {+        let t0 = Instant::now();+        // one call per message: the add-in's show creates the palette or repaints it in place+        while t0.elapsed() < Duration::from_secs(90) {+            if GEN.load(Ordering::Relaxed) != gen { return; }      // a newer message took over+            let r = send("show", &t, &d, percent, eta);+            if r["success"].as_bool() == Some(true) { break; }+            std::thread::sleep(Duration::from_secs(3));+        }+        // idle hide: nothing newer within 25 s and no import run in flight+        std::thread::sleep(Duration::from_secs(25));+        if GEN.load(Ordering::Relaxed) == gen && !run_in_flight() { let _ = send("hide", "", "", None, None); }+    });+}++/// Take the palette down now (the tour's teardown, a cancelled run).+pub fn hide() {+    GEN.fetch_add(1, Ordering::Relaxed);+    *LAST.lock().unwrap() = None;+    std::thread::spawn(|| { for _ in 0..5 { let r = send("hide", "", "", None, None); if r["success"].as_bool() == Some(true) { break; } std::thread::sleep(Duration::from_secs(3)); } });+}++fn run_in_flight() -> bool {+    crate::chains::read_runs().values().any(|r| !matches!(r["stage"].as_str(), Some("done") | Some("failed") | Some("cancelled") | None))+}++/// The plain add-in verbs worth a line on the palette (the orchestrations say their own).+pub fn for_verb(cmd: &str, args: &Value) -> Option<(String, String)> {+    let name = |k: &str| args[k].as_str().map(|s| s.replace('\\', "/").rsplit('/').next().unwrap_or("").to_string()).filter(|s| !s.is_empty());+    Some(match cmd {+        "open_by_urn" => ("Opening a cloud design".into(), format!("from your hub: {}", args["name"].as_str().or(args["urn"].as_str()).unwrap_or("").chars().rev().take(40).collect::<String>().chars().rev().collect::<String>())),+        "open_cloud_file" => ("Opening a cloud design".into(), format!("from your hub: {}", args["fileName"].as_str().or(args["name"].as_str()).unwrap_or(""))),+        "show_schematic" => ("Switching to the schematic".into(), "Fusion's electronics design: the schematic sheet".into()),+        "show_2d_board" => ("Switching to the 2D board".into(), "the board editor: copper, pours and parts".into()),+        "show_3d_board" => ("Switching to the 3D board".into(), "the 3D PCB: every component body in place".into()),+        "import_step" | "import_file" => ("Importing a model".into(), name("filePath").unwrap_or_default()),+        "import_electronics" | "new_electronics_from_eagle" => ("Importing an electronics design".into(), name("filePath").or(name("schPath")).unwrap_or_default()),+        "export_step" | "export_stl" | "export_f3d" | "export_glb" | "export_gerbers" | "export_bom" => (format!("Exporting {}", cmd.trim_start_matches("export_").to_uppercase()), name("outputPath").unwrap_or_default()),+        "save_to_cloud" => ("Saving to your hub".into(), args["name"].as_str().unwrap_or("").into()),+        _ => return None,+    })+}++/// The measured time a verb usually takes on this machine (p50 of past runs), for the palette.+pub fn eta_for_verb(state_dir: &std::path::Path, verb: &str) -> Option<f64> {+    let (secs, kind) = crate::timings::verb_estimate(state_dir, verb);+    if kind == "unknown" || secs <= 0.0 { None } else { Some(secs) }+}
rust/bridge/src/testproject.rs+1
@@ -132,6 +132,7 @@ pub fn make_test_project(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) ->     if lbr_path.is_empty() { return json!({"success": false, "error": "libraryPath (.lbr, Windows path) required.", "_hint": "Stage the component page's .lbr onto the box first (send_files)."}); }     let lbr_text = match std::fs::read(&lbr_path) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "error": format!("could not read {lbr_path}: {e}")}) };     let deviceset = match args["deviceset"].as_str().filter(|s| !s.is_empty()) { Some(d) => d.to_string(), None => match regex::Regex::new(r#"<deviceset name="([^"]+)""#).unwrap().captures(&lbr_text) { Some(c) => c[1].to_string(), None => return json!({"success": false, "error": "no deviceset found in the library"}) } };+    crate::palette::say(&format!("Generating a test project around {deviceset}"), "a schematic and a board from EAGLE source, then Fusion imports both", None, crate::palette::eta_for_verb(&ctx.state_dir, "make_test_project"));     let ex = match mtp_extract(&lbr_text, &deviceset, args["device"].as_str()) { Ok(e) => e, Err(e) => return json!({"success": false, "error": e}) };     let lib_name: String = regex::Regex::new(r"[^A-Za-z0-9_-]").unwrap().replace_all(lbr_path.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s).unwrap_or(""), "_").to_string();     let mut urn = String::new();
rust/bridge/src/tour.rs+60−12
@@ -15,8 +15,8 @@ use std::time::{Duration, Instant}; pub const PANEL_ID: &str = "fusion-tour"; const CAPTION_ID: &str = "fusion-tour"; const ACK_ID: &str = "fusion-tour-ack";-/// Caption glyph size: 32px, the size ab called medium before v2.0.84 grew it to 48px.-const CAPTION_PX: u32 = 32;+/// The share of the primary work area Fusion gets for the show (John, 2026-09-05: 95%).+const WINDOW_SHARE: f64 = 0.95; /// The board the schematic, 2D and 3D beats show: a real design from the user's hub (John, /// 2026-09-04: the generated one-chip board looked lame; show Adonis's BQ25792 charger). The /// generated solo board stays as the fallback for a hub without it. Override with args.board.@@ -56,6 +56,8 @@ static NARR: Mutex<Option<(String, usize)>> = Mutex::new(None); static BEAT_BUSY: AtomicBool = AtomicBool::new(false); static PAUSED: AtomicBool = AtomicBool::new(false); static GENERIC_USED: Mutex<Vec<String>> = Mutex::new(Vec::new());+/// The tour's state dir, for detached helpers (the in-Fusion palette) that need their own Ctx.+static STATE_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);  /// One timestamped event in the record (post-production cuts on these). fn event(name: &str, detail: Value) { let at = since_start(); rec(|s| push(s, "events", json!({"at": at, "event": name, "beat": s["step"].clone(), "detail": detail}))); }@@ -85,7 +87,7 @@ fn caption(text: &str, ms: u64) {     // remote; 32px (the old medium) and one short line keep clear of it. ab's own warnings     // (too_wide_for_screen, too_tall_for_screen) go into the record so a wrap never passes silently.     let at0 = since_start();-    let r = ab("desktop_caption", json!({"id": CAPTION_ID, "text": text, "duration": ms, "size": "medium", "fontSize": CAPTION_PX, "position": "bottom", "reason": "Narrate the Fusion tour the user started, one line per beat"}), 6);+    let r = ab("desktop_caption", json!({"id": CAPTION_ID, "text": text, "duration": ms, "size": "medium", "position": "bottom", "reason": "Narrate the Fusion tour the user started, one line per beat"}), 6);     let ok = r["success"].as_bool() == Some(true) && r["error"].as_str().map(|e| e.is_empty()).unwrap_or(true);     let at = at0;     let warns: Vec<String> = r["warnings"].as_array().map(|a| a.iter().filter_map(|w| w["check"].as_str().map(str::to_string)).collect()).unwrap_or_default();@@ -93,7 +95,7 @@ fn caption(text: &str, ms: u64) { } fn caption_clear() { let _ = ab("desktop_caption", json!({"action": "hide", "id": CAPTION_ID, "reason": "The user stopped the Fusion tour; clear its caption"}), 6); } fn ack(text: &str) {-    let r = ab("desktop_caption", json!({"id": ACK_ID, "text": text, "duration": 3500, "size": "medium", "fontSize": CAPTION_PX, "position": "top", "reason": "Acknowledge a press on the Fusion tour remote the instant it arrives"}), 6);+    let r = ab("desktop_caption", json!({"id": ACK_ID, "text": text, "duration": 3500, "size": "medium", "position": "top", "reason": "Acknowledge a press on the Fusion tour remote the instant it arrives"}), 6);     rec(|s| push(s, "captions", json!({"text": text, "ack": true, "ok": r["success"].as_bool() == Some(true), "error": r["error"].as_str().filter(|e| !e.is_empty())}))); } fn audio_dir() -> std::path::PathBuf { std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.join("resources").join("demo-audio"))).unwrap_or_default() }@@ -118,6 +120,23 @@ fn play_file(name: &str, kind: &str) {     #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); }     match cmd.spawn() { Ok(c) => { *AUDIO.lock().unwrap() = Some((c, name.to_string())); rec(|s| push(s, "audio", json!({"beat": name, "kind": kind, "started": true, "at": at, "file": f.to_string_lossy().replace('\\', "/")}))); } Err(e) => rec(|s| push(s, "audio", json!({"beat": name, "kind": kind, "error": e.to_string()}))) } }+/// The in-Fusion palette ("Adom is driving Fusion"): show / update / hide through the add-in's+/// activity_banner, fire-and-forget on a thread with its own Ctx. The palette carries the+/// caption's line, the beat and the percent, so Fusion itself shows the work getting done.+fn banner(action: &str, title: &str, detail: &str, percent: Option<u64>) {+    let Some(sd) = STATE_DIR.lock().unwrap().clone() else { return };+    let caller = caller();+    let args = json!({"action": action, "title": title, "detail": detail, "percent": percent, "tone": "working"});+    std::thread::spawn(move || {+        let host = crate::host_for_diagnosis();+        let side = |_v: &str, _a: Value, _t: u64| -> Value { json!({"success": false, "error": "no sidecar"}) };+        let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+        let dlg = |r: Value| r;+        let ctx = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd, caller, pids, sidecar: &side, dialogs: &dlg };+        let r = crate::addin::pass_through("activity_banner", &args, &ctx);+        rec(|s| { s["palette"] = json!({"action": args["action"], "ok": r["success"].as_bool() == Some(true), "error": r["error"].as_str().filter(|e| !e.is_empty()), "at": 0}); });+    });+} /// The narrator thread: whenever the current beat is still busy on screen and nothing is playing, /// play the beat's next filler (<beat>-f<n>.mp3), then the generic pool, each once per tour. It /// never starts a clip while paused or muted, and never once the beat only waits for audio, so a@@ -257,10 +276,10 @@ fn place_fusion(sd: &std::path::Path, hwnd: i64, title: &str) -> Value {     let Some(wa) = primary_work_area() else { return json!({"placed": false, "why": "no monitor bounds"}) };     let (mx, my, mw, mh) = (wa["x"].as_i64().unwrap_or(0), wa["y"].as_i64().unwrap_or(0), wa["width"].as_i64().unwrap_or(0), wa["height"].as_i64().unwrap_or(0));     if mw == 0 || mh == 0 { return json!({"placed": false, "why": "empty work area"}); }-    let (w, h) = (mw * 8 / 10, mh * 8 / 10);+    let (w, h) = ((mw as f64 * WINDOW_SHARE) as i64, (mh as f64 * WINDOW_SHARE) as i64);     let (x, y) = (mx + (mw - w) / 2, my + (mh - h) / 2);     save_bounds(sd, hwnd, title);-    let r = ab("desktop_set_window_bounds", json!({"hwnd": hwnd, "x": x, "y": y, "w": w, "h": h, "reason": "Fusion tour: place Fusion at 80% of the screen, centred"}), 8);+    let r = ab("desktop_set_window_bounds", json!({"hwnd": hwnd, "x": x, "y": y, "w": w, "h": h, "reason": "Fusion tour: place Fusion at 95% of the screen, centred"}), 8);     json!({"placed": r["success"].as_bool() == Some(true) && r["error"].as_str().map(|e| e.is_empty()).unwrap_or(true), "x": x, "y": y, "w": w, "h": h, "error": r["error"].as_str().filter(|e| !e.is_empty()).or(r["_adCallError"].as_str())}) } fn restore_bounds(sd: &std::path::Path, source: &str) -> usize {@@ -324,13 +343,23 @@ fn verify_beat(hwnd: i64, expected_doc: &str) -> Value {     }     v["settleMs"] = json!(t0.elapsed().as_millis() as u64);     let remote = v["frontTitle"].as_str().map(is_remote_viewer).unwrap_or(false);+    let own_popup = front_ok(hwnd) && v["front"].as_bool() != Some(true) && !remote;     if v["front"].as_bool() != Some(true) && remote { v["frontNote"] = json!("a remote-desktop viewer is on top; front cannot be proven from here, layout and motion can"); }-    v["ok"] = json!((v["front"].as_bool() == Some(true) || remote) && v["title"].as_bool() != Some(false) && v["painted"].as_bool() != Some(false));+    if own_popup { v["frontNote"] = json!("one of Fusion's own popups (the palette host) is on top; counted as front"); }+    v["ok"] = json!((v["front"].as_bool() == Some(true) || remote || own_popup) && v["title"].as_bool() != Some(false) && v["painted"].as_bool() != Some(false));     v } /// RustDesk and friends sit on top of everything on a box driven over remote desktop. fn is_remote_viewer(title: &str) -> bool { let t = title.to_lowercase(); ["rustdesk", "remote desktop", "anydesk", "teamviewer", "vnc", "parsec"].iter().any(|k| t.contains(k)) }-fn front_ok(hwnd: i64) -> bool { match top_window() { Some(t) => t["hwnd"].as_i64() == Some(hwnd) || t["title"].as_str().map(is_remote_viewer).unwrap_or(false), None => false } }+/// Windows that may sit above Fusion without meaning the user switched away: a remote-desktop+/// viewer, Fusion's own popups (the in-Fusion palette renders in a WebView popup host), and the+/// shell's popup host. Everything else in front is the user's business and stops motion.+fn front_ok(hwnd: i64) -> bool {+    match top_window() {+        Some(t) => t["hwnd"].as_i64() == Some(hwnd) || t["title"].as_str().map(is_remote_viewer).unwrap_or(false) || t["image"].as_str().map(|i| i.eq_ignore_ascii_case("Fusion360.exe")).unwrap_or(false) || matches!(t["title"].as_str(), Some("PopupHost")),+        None => false,+    }+} fn is_front(hwnd: i64) -> bool { top_window().and_then(|t| t["hwnd"].as_i64()) == Some(hwnd) } fn foreground_once(hwnd: i64, why: &str) -> bool {     let _ = ab("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": format!("Fusion tour: {why}, once")}), 8);@@ -445,7 +474,21 @@ fn wait_active(pass: &dyn Fn(&str, Value) -> Value, pred: &dyn Fn(&str) -> bool,     false } -fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &Env) {+/// Retry a verb while the add-in reports its main thread busy with someone else's command (the+/// dashboard polls the bridge during a tour); bounded to ~12 s. ConfRoomROG 2026-09-05: the 2D board+/// beat failed on a 0.1 s get_app_state from the dashboard.+fn pass_patient(pass: &dyn Fn(&str, Value) -> Value, verb: &str, args: Value) -> Value {+    let t0 = Instant::now();+    loop {+        let r = pass(verb, args.clone());+        let busy = r["success"].as_bool() != Some(true) && r["error"].as_str().map(|e| e.contains("main thread busy") || e.contains("Fusion is busy") || e.contains("busy_")).unwrap_or(false);+        if !busy || t0.elapsed() > Duration::from_secs(12) || EXIT.load(Ordering::Relaxed) { return r; }+        sleep_s(0.4);+    }+}+fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env: &Env) {+    let pass_owned = |verb: &str, a: Value| pass_patient(pass_raw, verb, a);+    let pass: &dyn Fn(&str, Value) -> Value = &pass_owned;     rec(|s| s["bridgeVersion"] = json!(env.version));     let sd = ctx.state_dir.clone();     let t0 = Instant::now();@@ -460,6 +503,7 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E     let _ = crate::etiquette::clear_badge(&caller());     if let Some((h, _)) = fusion_hwnd() { let _ = ab("desktop_taskbar", json!({"hwnd": h, "progress": {"state": "none"}}), 6); }     let tp_base = crate::testproject::test_project(&sd).and_then(|t| t["docBase"].as_str().map(str::to_string)).unwrap_or_default();+    *STATE_DIR.lock().unwrap() = Some(sd.clone());     let stale = close_named_docs(pass, &["adomdemosample".into(), tp_base.clone()]);     let docs_before = crate::orchestrations::open_doc_names(pass);     rec(|s| { s["staleDocsClosed"] = json!(stale); s["docsBefore"] = json!(docs_before.len()); });@@ -589,7 +633,9 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E             "board2d" => {                 caption(&format!("Fusion tour 6/{n}: the {} in 2D - real copper, real routing", if hub_board { board_name.clone() } else { "board".to_string() }), 30000);                 audio_play(if hub_board { "board2d" } else { "board2d-solo" });-                let act = if hub_board { pass("activate_document", json!({"name": board_name}))["success"].as_bool() == Some(true) } else { crate::testproject::test_project_activate(ctx, "brd", pass) };+                // the hub design carries schematic, board and 3D as one document set with one name:+                // never activate by substring (four documents share it); switch views inside it+                let act = if hub_board { true } else { crate::testproject::test_project_activate(ctx, "brd", pass) };                 let o = pass("show_2d_board", json!({}));                 r["activatedBoard"] = json!(act); r["ok"] = json!(o["success"].as_bool() == Some(true)); r["error"] = o["error"].clone();                 expected = project_base.clone(); kind = "2d";@@ -597,7 +643,8 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E             "board3d" => {                 caption(&format!("Fusion tour 7/{n}: the {} in 3D{}", if hub_board { board_name.clone() } else { "board".to_string() }, if !hub_board && bound { ", carrying the chip minted a minute ago" } else { "" }), 30000);                 audio_play(if hub_board { "board3d" } else { "board3d-solo" });-                let act = if hub_board { true } else { crate::testproject::test_project_activate(ctx, "brd", pass) };+                let act = if hub_board { pass("show_2d_board", json!({}))["success"].as_bool() == Some(true) } else { crate::testproject::test_project_activate(ctx, "brd", pass) };+                if act { sleep_s(1.5); }                 let was = active_doc(pass);                 let o = pass("show_3d_board", json!({}));                 r["activatedBoard"] = json!(act); r["ok"] = json!(o["success"].as_bool() == Some(true)); r["error"] = o["error"].clone();@@ -683,6 +730,7 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E     let my_id = s["demoId"].as_str().unwrap_or("").to_string();     let close_t0 = Instant::now();     let closed = crate::mint::close_docs_opened_since(ctx, &docs_before, &[], pass);+    crate::palette::hide();     rec_for(&sd, &my_id, |s| { s["tourDocsClosed"] = json!(closed); s["closeSec"] = json!(close_t0.elapsed().as_secs()); });     if snap()["demoId"].as_str() == Some(my_id.as_str()) { let s = snap(); crate::demo::persist_pub(&sd, &s); } }@@ -751,7 +799,7 @@ pub fn progress(args: &Value) -> Value {     let s = snap();     if s.is_null() { return json!({"success": false, "error": "no tour has been started", "_hint": "fusion_demo {mode:'tour'} starts one."}); }     let mut out = json!({"success": true});-    for (k, v) in s.as_object().unwrap() { if k != "shots" && k != "started" { out[k] = v.clone(); } }+    for (k, v) in s.as_object().unwrap() { if k != "shots" { out[k] = v.clone(); } }     let all = s["shots"].as_array().cloned().unwrap_or_default();     out["shotCount"] = json!(all.len());     let since = args["sinceShot"].as_u64().unwrap_or(0) as usize;