← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.11.7\ No newline at end of file+1.11.8\ 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.11.7",+  "version": "1.11.8",   "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.11.7"+version = "1.11.8" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.11.7"+version = "1.11.8" 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.11.7"+DASH_VERSION="1.11.8" 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.11.7",+  "version": "1.11.8",   "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.11.7",+  "version": "1.11.8",   "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.11.7"+version = "1.11.8" edition = "2021" license = "MIT" 
rust/core/src/aps.rs+46−2
@@ -63,9 +63,14 @@ impl ApsStore {     pub fn read(&self, name: &str) -> Value {         std::fs::read_to_string(self.dir.join(name)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}))     }+    /// Atomic: a crash or a concurrent reader never sees half a tokens.json (a torn write of a+    /// single-use refresh token loses the sign-in for good).     pub fn write(&self, name: &str, v: &Value) {         let _ = std::fs::create_dir_all(&self.dir);-        let _ = std::fs::write(self.dir.join(name), serde_json::to_string_pretty(v).unwrap_or_default());+        let tmp = self.dir.join(format!("{name}.tmp{}", std::process::id()));+        if std::fs::write(&tmp, serde_json::to_string_pretty(v).unwrap_or_default()).is_ok() {+            if std::fs::rename(&tmp, self.dir.join(name)).is_err() { let _ = std::fs::remove_file(&tmp); }+        }     }     pub fn client_id(&self) -> String { self.read("config.json")["client_id"].as_str().unwrap_or("").to_string() }     pub fn tokens(&self) -> Value { self.read("tokens.json") }@@ -97,13 +102,52 @@ impl ApsStore {         }         t["access_token"].as_str().map(str::to_string)     }+    /// Refresh ONCE, whoever asks. Autodesk refresh tokens are single use and rotate; cloud search+    /// used to refresh from up to 12 parallel workers (and the janitor, and status calls, from+    /// other processes) with the SAME token, and replaying a rotated token can revoke the whole+    /// family. ConfRoomROG then stayed signed out and the daily run was red for five days+    /// (2026-09-24 to 09-28, page issues #100-#104). So: one refresher at a time across threads+    /// and processes, re-read the tokens once the lock is held (someone else may already have+    /// refreshed), and keep Autodesk's answer when it refuses.     fn refresh(&self) -> bool {+        static IN_PROCESS: std::sync::Mutex<()> = std::sync::Mutex::new(());+        let _held = IN_PROCESS.lock().unwrap_or_else(|p| p.into_inner());+        let _ = std::fs::create_dir_all(&self.dir);+        let lock = self.dir.join("refresh.lock");+        let t0 = std::time::Instant::now();+        loop {+            match std::fs::OpenOptions::new().write(true).create_new(true).open(&lock) {+                Ok(_) => break,+                Err(_) => {+                    // a lock older than 90 s belongs to a process that died mid-refresh+                    let stale = std::fs::metadata(&lock).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).map(|e| e.as_secs() > 90).unwrap_or(true);+                    if stale { let _ = std::fs::remove_file(&lock); continue; }+                    if t0.elapsed() > Duration::from_secs(45) { return self.tokens()["expires_at"].as_u64().unwrap_or(0) > now_secs(); }+                    std::thread::sleep(Duration::from_millis(250));+                }+            }+        }+        let ok = self.refresh_locked();+        let _ = std::fs::remove_file(&lock);+        ok+    }+    fn refresh_locked(&self) -> bool {         let t = self.tokens();+        // another thread or process refreshed while we waited for the lock: use its token+        if t["expires_at"].as_u64().unwrap_or(0) > now_secs() && t["access_token"].as_str().map(|s| !s.is_empty()).unwrap_or(false) { return true; }         let Some(rt) = t["refresh_token"].as_str().filter(|s| !s.is_empty()).map(str::to_string) else { return false };         let form = format!("grant_type=refresh_token&refresh_token={}&client_id={}", urlencode(&rt), urlencode(&self.client_id()));         let resp = ureq::post(TOKEN_URL).timeout(Duration::from_secs(30))             .set("Content-Type", "application/x-www-form-urlencoded").send_string(&form);-        let Ok(resp) = resp else { return false };+        let resp = match resp {+            Ok(r) => r,+            Err(ureq::Error::Status(code, r)) => {+                let body: Value = r.into_json().unwrap_or(json!({}));+                self.write("last_refresh_error.json", &json!({"at": now_secs(), "status": code, "error": body["error"], "description": body["error_description"].as_str().or(body["developerMessage"].as_str())}));+                return false;+            }+            Err(e) => { self.write("last_refresh_error.json", &json!({"at": now_secs(), "status": null, "error": format!("{e}")})); return false; }+        };         let Ok(mut body) = resp.into_json::<Value>() else { return false };         if body["access_token"].as_str().map(|s| !s.is_empty()).unwrap_or(false) {             let now = now_secs();
tools/check-naming.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh