← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.10.40\ No newline at end of file+1.10.41\ 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.10.40",+  "version": "1.10.41",   "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.10.40"+version = "1.10.41" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.10.40"+version = "1.10.41" 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.10.40"+DASH_VERSION="1.10.41" 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.10.40",+  "version": "1.10.41",   "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+10−26
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.10.40",+  "version": "1.10.41",   "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",@@ -76,6 +80,9 @@     "add a 3d model to a component",     "make a fusion 360 library with 3d",     "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page",     "aps not configured",     "share aps client id across my org",     "set up fusion cloud search for my company",@@ -108,28 +115,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.10.40"+version = "1.10.41" edition = "2021" license = "MIT" 
rust/bridge/src/lifecycle.rs+2−3
@@ -212,9 +212,8 @@ pub fn dialog_inventory(ctx: &Ctx) -> Vec<Value> {     for d in fusion_core::dialogs::qt_dialog_rows(&rows) {         let h = d["hwnd"].as_i64().unwrap_or(0);         let (mut text, mut buttons): (Vec<String>, Vec<String>) = (vec![], vec![]);-        if let Ok(r) = ab.call("desktop_find_controls", json!({"hwnd": h}), &ctx.caller, 10) {-            let dd = if r["data"].is_object() { r["data"].clone() } else { r };-            for c in dd["controls"].as_array().or(dd["matches"].as_array()).cloned().unwrap_or_default() {+        {+            for c in crate::small_verbs::controls_cached(&ab, &ctx.caller, h) {                 let name = c["name"].as_str().unwrap_or("").trim().to_string();                 if name.is_empty() { continue; }                 let ty = c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase();
rust/bridge/src/main.rs+1−1
@@ -478,7 +478,7 @@ fn main() {                         let mut r = match cmd { "stop" => lifecycle::stop(&ctx, &parsed["args"]), "kill" => lifecycle::kill(&ctx, &parsed["args"]), _ => lifecycle::restart_for_addin(&ctx, &parsed["args"], &|verb, a| addin::pass_through(verb, &a, &ctx)) };                         timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false));                         r["shell"] = json!("rust"); r },-                    "fusion_get_preferences" | "get_preferences" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = fusion_prefs::get_preferences(&|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },+                    "fusion_get_preferences" | "get_preferences" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let _ = &ctx; let mut r = fusion_prefs::get_preferences(&|v, a, t| addin::addin_http(v, &a, t.max(20))); r["shell"] = json!("rust"); r },                     "fusion_set_preference" | "set_preference" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = fusion_prefs::set_preference(&parsed["args"], &|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },                     "fusion_reset_workspace" | "reset_workspace" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = fusion_prefs::reset_workspace(&sd, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx), &|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },                     "fusion_describe" | "describe" => {
rust/bridge/src/orchestrations.rs+3−1
@@ -618,7 +618,9 @@ if hit is not None:     hit.activate() result = {{"wanted": want, "tabType": {want_pt:?}, "switchedTab": hit is not None, "activeNow": app.activeDocument.name if app.activeDocument else None}} "#);-        let sr = pass("run_modeling_script", json!({"script": script}));+        // straight to the add-in: this is the bridge's own bookkeeping, not a user script, so it skips+        // the window-etiquette envelope (its dialog scans before and after cost more than the script)+        let sr = crate::addin::addin_http("run_modeling_script", &json!({"script": script}), 20);         let res = if sr["data"]["result"].is_object() { sr["data"]["result"].clone() } else { sr["result"].clone() };         let switched = res["switchedTab"].as_bool() == Some(true);         // no typed tab matched (a standalone local .sch/.brd, or an older Fusion): fall back to name
rust/bridge/src/small_verbs.rs+18−10
@@ -333,6 +333,20 @@ pub fn screenshot_fusion(args: &Value, caller: &fusion_core::ab::Caller) -> Valu     } } +/// UI Automation reads of a window's controls cost ~1-5 s on a busy Fusion, and the classifier,+/// the closable check and the inventory each wanted one for the same window on the same verb.+/// One read per window per 20 s is plenty: a dialog's buttons do not change that fast.+pub fn controls_cached(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> Vec<Value> {+    use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant};+    static CACHE: Mutex<Option<HashMap<i64, (Instant, Vec<Value>)>>> = Mutex::new(None);+    if let Some((t, v)) = CACHE.lock().unwrap().get_or_insert_with(HashMap::new).get(&hwnd) { if t.elapsed() < Duration::from_secs(20) { return v.clone(); } }+    let v: Vec<Value> = ab.call("desktop_find_controls", json!({"hwnd": hwnd}), caller, 10).ok().map(|r| { let d = if r["data"].is_object() { r["data"].clone() } else { r }; d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default() }).unwrap_or_default();+    let mut g = CACHE.lock().unwrap(); let m = g.get_or_insert_with(HashMap::new);+    m.retain(|_, (t, _)| t.elapsed() < Duration::from_secs(60));+    m.insert(hwnd, (Instant::now(), v.clone()));+    v+}+ /// ab's image-delivery contract: a reply that carries `fullPath` (and `safePath`), or /// `screenshots[]` entries with `fullPath`, is auto-pulled to the CALLER's machine, so an AI in a /// container can read the picture with no second call. Older replies named the Windows path@@ -386,12 +400,9 @@ pub fn unknown_window_is_closable(ab: &dyn fusion_core::ab::AbClient, caller: &f         if mw > 0 && mh > 0 && w * 10 >= mw * 8 && h * 10 >= mh * 8 { return (false, format!("window-sized ({w}x{h} against the main window's {mw}x{mh}): a main or document window, not a dialog")); }     }     let Some(hwnd) = dlg["hwnd"].as_i64() else { return (false, "no window handle".into()) };-    let buttons: Vec<String> = ab.call("desktop_find_controls", json!({"hwnd": hwnd}), caller, 10).ok().map(|r| {-        let d = if r["data"].is_object() { r["data"].clone() } else { r };-        d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default().iter()-            .filter(|c| c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase().contains("button"))-            .filter_map(|c| c["name"].as_str().map(|n| n.trim().to_lowercase())).collect()-    }).unwrap_or_default();+    let buttons: Vec<String> = controls_cached(ab, caller, hwnd).iter()+        .filter(|c| c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase().contains("button"))+        .filter_map(|c| c["name"].as_str().map(|n| n.trim().to_lowercase())).collect();     const DISMISS: &[&str] = &["ok", "cancel", "close", "no", "dismiss", "not now", "later", "skip", "got it", "continue"];     if buttons.iter().any(|b| DISMISS.iter().any(|d| b == d || b.starts_with(&format!("{d} ")))) { (true, String::new()) }     else { (false, format!("no Cancel/Close/OK-style button (buttons: {})", if buttons.is_empty() { "none readable".to_string() } else { buttons.join(", ") })) }@@ -410,10 +421,7 @@ pub fn classify_blocking_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fu         let mut matched_on: Option<&str> = None;         if classified.as_ref().map(|(c, _)| c == "generic_modal_read_screenshot" || c == "unknown").unwrap_or(true) {             if let Some(h) = dlg["hwnd"].as_i64() {-                let body: String = ab.call("desktop_find_controls", json!({"hwnd": h}), caller, 10).ok()-                    .map(|r| { let d = if r["data"].is_object() { r["data"].clone() } else { r }; let ctrls = d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default();-                        ctrls.iter().filter_map(|c| c["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Vec<_>>().join(" ").chars().take(400).collect() })-                    .unwrap_or_default();+                let body: String = controls_cached(ab, caller, h).iter().filter_map(|c| c["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Vec<_>>().join(" ").chars().take(400).collect();                 if body.contains("ToolbarFramework") || body.contains("QTView") { continue; } // a Fusion panel (a floating BROWSER titled "Fusion360"), not a dialog                 if !body.is_empty() { if let Some(bt) = fusion_core::dialogs::category_for_title(&body) { classified = Some(bt); matched_on = Some("bodyText"); } }             }
rust/core/src/dialogs.rs+4−1
@@ -54,7 +54,10 @@ pub fn qt_dialog_rows(rows: &[Value]) -> Vec<Value> {         if title.contains("QTView") { continue; }         let rect = rect_of(&w["rect"]);         let (ww, hh) = (rect["width"].as_i64().unwrap_or(0), rect["height"].as_i64().unwrap_or(0));-        if class.contains("Tool") && (ww < 400 || hh < 300 || hh > ww * 3 / 2) { continue; }+        // tool windows shaped like a PANEL (taller than wide: a floating BROWSER is 600x800 and titled+        // just "Fusion360") are docked-panel frames, never modal dialogs. Skipping them here also saves+        // the seconds a UI Automation read of their contents costs on every verb (2026-09-22, #97).+        if class.contains("Tool") && (ww < 400 || hh < 300 || hh > ww) { continue; }         out.push(serde_json::json!({"hwnd": w["hwnd"], "title": title, "className": class, "rect": rect}));     }     out
tools/check-naming.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh