← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.68\ No newline at end of file+1.13.69\ No newline at end of file
CHANGELOG.md+8−1
@@ -3,7 +3,14 @@ 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` -## Next release+## 1.13.69 (2026-11-04)++Fusion is no longer relaunched into a loop of popups. A call that finds Fusion closed used to start it every time, so a poller relaunched a broken install once a minute, a round of Windows error popups each time (Colby's PC, 2026-11-04). Now:+- A stripped install is seen from the disk and never launched: `fusion_install_broken`, naming the folder. `fusion_install_fusion` repairs it, where it used to say "already installed" because another copy was on disk.+- While Fusion's installer runs, calls answer `fusion_updating` rather than launching it.+- After two starts within 5 minutes that didn't stay up, calls stop starting it for 15 minutes (`fusion_autostart_paused`). `fusion_start` always tries.+- The ledger's `fusion_auto_started` says `ok` only if Fusion is really running afterwards.+  Pours have a stable `id` (#138, Andy Engevold): `fusion_pour_state` and the board model give each one, made from its net, layer and outline, as Fusion stores none. It stays the same across saves and when other pours come and go. `fusion_remove_pours` and `fusion_edit_pours` take it, and an unknown one is refused before anything runs. 
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.68",+  "version": "1.13.69",   "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.",
contracts/describe.json+4−4
@@ -1,6 +1,6 @@ {   "contract": "fusion-bridge verb catalog",-  "frozen": "2026-11-03",+  "frozen": "2026-11-04",   "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.",   "source": "describe.py",   "verbs": [@@ -1989,7 +1989,7 @@         "verified": "bool"       },       "statusVerb": "fusion_routing_state",-      "summary": "The EDA Bridge Standard's name for fusion_change_pours (marked aliasOf): change existing pours in place, minimum width, clearance, thermals, rank and orphans, each read back after the refill. A pour's net cannot be moved in place in Fusion (a polygon belongs to its signal): that is refused with what to do instead.",+      "summary": "The EDA Bridge Standard's name for fusion_change_pours (marked aliasOf): change existing pours in place, named by id from fusion_pour_state or by net and layer, minimum width, clearance, thermals, rank and orphans, each read back after the refill. A pour's net cannot be moved in place in Fusion (a polygon belongs to its signal): that is refused with what to do instead.",       "timeoutSeconds": 120     },     {@@ -20593,7 +20593,7 @@         "zones": "[{net,layer,outline,filledMm2,filled,fills:[{outline,holes}]}] (the standard's pours model)"       },       "statusVerb": "fusion_routing_state",-      "summary": "Read every pour on the live board with Fusion's own computed fill: filled area per pour, per layer and per net, and which pours were never filled (no RATSNEST since they were drawn). The measure step of the AI flow's Fusion lane.",+      "summary": "Read every pour on the live board with Fusion's own computed fill, each with a stable id made from its net, layer and outline (Fusion stores none): filled area per pour, per layer and per net, and which pours were never filled (no RATSNEST since they were drawn). The measure step of the AI flow's Fusion lane.",       "timeoutSeconds": 60     },     {@@ -21103,7 +21103,7 @@         "success": "bool"       },       "statusVerb": "fusion_routing_state",-      "summary": "Delete pours (all of them, or the ones named by net, layer and outline) so a board can be poured again after a refine. Each DELETE picks the outline point farthest from every pad (refused if none is 1 mm clear), and the readback undoes everything if a part or a track changed.",+      "summary": "Delete pours (all of them, or the ones named by id from fusion_pour_state, or by net, layer and outline) so a board can be poured again after a refine. Each DELETE picks the outline point farthest from every pad (refused if none is 1 mm clear), and the readback undoes everything if a part or a track changed.",       "timeoutSeconds": 120     },     {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.68"+version = "1.13.69" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.68"+version = "1.13.69" 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.13.68"+DASH_VERSION="1.13.69" 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.13.68",+  "version": "1.13.69",   "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+2−2
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.68",+  "version": "1.13.69",   "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": {@@ -152,4 +152,4 @@   "confirmed_user_skills": [     "fusion-design-efficiency"   ]-}\ No newline at end of file+}
rust/Cargo.lock+5−5
@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.68"+version = "1.13.69" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.68"+version = "1.13.69" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.68"+version = "1.13.69" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.68"+version = "1.13.69" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.68"+version = "1.13.69" dependencies = [  "fusion-core",  "windows-sys 0.59.0",
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.13.68"+version = "1.13.69" edition = "2021" license = "MIT" 
rust/bridge/src/launch.rs+79−3
@@ -140,6 +140,10 @@ fn not_installed() -> Value {  /// `fusion_start`. pub fn start(ctx: &Ctx, _args: &Value) -> Value {+    if _args["_fromGate"].is_null() { let _ = std::fs::remove_file(autostart_path(&ctx.state_dir)); }+    if !running(ctx) {+        if let Some(problem) = ctx.fusion_exe.as_deref().and_then(fusion_core::install_check::launch_problem) { return broken_install_refusal(&problem); }+    }     let sd = ctx.state_dir.clone();     let Some(exe) = ctx.fusion_exe.clone() else { return not_installed() };     let exe_path = exe.to_string_lossy().replace('\\', "/");@@ -291,6 +295,40 @@ pub static SIDECAR_URL: std::sync::Mutex<Option<String>> = std::sync::Mutex::new /// This exe's own /command URL (set once it listens), for work that must outlive a request. pub static SELF_URL: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None); +/// A call that finds Fusion closed starts it. If it keeps dying straight after, that must not repeat: a poller+/// once relaunched a broken Fusion every minute, a fresh round of popups each time (2026-11-04). Two starts within+/// RESTART_WINDOW_SECS means it isn't staying up, so calls stop starting it for AUTOSTART_PAUSE_SECS and say why.+/// fusion_start, a person or an AI asking outright, always tries and clears this.+const RESTART_WINDOW_SECS: f64 = 300.0;+const AUTOSTART_PAUSE_SECS: f64 = 900.0;++fn autostart_path(state_dir: &std::path::Path) -> std::path::PathBuf { state_dir.join("autostart.json") }++fn autostart_record(state_dir: &std::path::Path) -> Value {+    std::fs::read_to_string(autostart_path(state_dir)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}))+}++/// Seconds left on the pause, or None to go ahead.+fn autostart_paused(state_dir: &std::path::Path, now: f64) -> Option<f64> {+    let r = autostart_record(state_dir);+    let last = r["lastStart"].as_f64()?;+    (r["strikes"].as_u64().unwrap_or(0) >= 2 && now - last < AUTOSTART_PAUSE_SECS).then(|| AUTOSTART_PAUSE_SECS - (now - last))+}++fn note_autostart(state_dir: &std::path::Path, now: f64) {+    let r = autostart_record(state_dir);+    let soon = r["lastStart"].as_f64().map(|l| now - l < RESTART_WINDOW_SECS).unwrap_or(false);+    let strikes = if soon { r["strikes"].as_u64().unwrap_or(0) + 1 } else { 1 };+    let _ = std::fs::create_dir_all(state_dir);+    let _ = std::fs::write(autostart_path(state_dir), json!({"lastStart": now, "strikes": strikes}).to_string());+}++fn broken_install_refusal(problem: &str) -> Value {+    json!({"success": false, "errorCode": "fusion_install_broken", "launchAttempted": false,+        "error": format!("Fusion can't start on this machine, so it wasn't launched: {problem}."),+        "_hint": "Launching it would only raise one Windows error popup per missing DLL. fusion_install_fusion repairs it with Autodesk's own installer (about 5 minutes, designs are in the cloud and aren't touched). Tell the user before starting it."})+}+ /// `_addin_preamble`: the gate every add-in-dependent verb passes. None means send it. pub fn gate(ctx: &Ctx, cmd: &str, args: &Value) -> Option<Value> {     let installed = ctx.fusion_exe.is_some();@@ -307,12 +345,28 @@ pub fn gate(ctx: &Ctx, cmd: &str, args: &Value) -> Option<Value> {         }     }     if !fusion_running && args["noAutoStart"].as_bool() != Some(true) {+        if let Some(problem) = ctx.fusion_exe.as_deref().and_then(fusion_core::install_check::launch_problem) {+            crate::fusion_prefs::action_ledger(&ctx.state_dir, "autostart_refused_broken_install", json!({"forCommand": cmd, "problem": problem}));+            return Some(broken_install_refusal(&problem));+        }+        if crate::maintenance::installer_running(ctx) {+            return Some(json!({"success": false, "errorCode": "fusion_updating", "launchAttempted": false, "error": "Fusion's installer is running, so Fusion wasn't launched: starting it mid-update can fail or disturb the update.",+                "_hint": "Wait for it to finish, then call again. fusion_readiness says when it's done."}));+        }+        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);+        if let Some(left) = autostart_paused(&ctx.state_dir, now) {+            return Some(json!({"success": false, "errorCode": "fusion_autostart_paused", "launchAttempted": false, "retryAfterSec": left.round(),+                "error": "Fusion was started twice in the last few minutes and closed again each time, so it wasn't started again.",+                "_hint": "It may have been closed by the user, or it may be failing to stay up: look before retrying. fusion_start tries again now."}));+        }+        note_autostart(&ctx.state_dir, now);         let t0 = Instant::now();         eprintln!("[fusion-bridge] {cmd} needs Fusion; it is closed - launching it (page issue #64)");-        let auto = start(ctx, &json!({"waitForAddin": true}));+        let auto = start(ctx, &json!({"waitForAddin": true, "_fromGate": true}));         let took = (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0;-        crate::fusion_prefs::action_ledger(&ctx.state_dir, "fusion_auto_started", json!({"forCommand": cmd, "ok": auto["success"].as_bool().unwrap_or(false), "tookSec": took}));-        if auto["success"].as_bool() == Some(true) && running(ctx) { fusion_running = true; }+        let up = auto["success"].as_bool() == Some(true) && running(ctx);+        crate::fusion_prefs::action_ledger(&ctx.state_dir, "fusion_auto_started", json!({"forCommand": cmd, "ok": up, "tookSec": took}));+        if up { fusion_running = true; }         else {             return Some(json!({"success": false, "errorCode": "fusion_autostart_failed",                 "error": format!("Fusion was closed so the bridge tried to open it for you, and the launch did not come up: {}", auto["error"].as_str().unwrap_or("no error reported").chars().take(300).collect::<String>()),@@ -387,3 +441,25 @@ pub fn gate(ctx: &Ctx, cmd: &str, args: &Value) -> Option<Value> {         "documentsKnown": ri["documentsKnown"], "remedyVerb": "fusion_restart_for_addin", "toast": ri["toast"],         "_hint": "ASK THE USER, do not silently restart their Fusion: show userMessage, and if documentsKnown say how many documents are open. On yes, call fusion_restart_for_addin {save:true} - it saves open documents, restarts Fusion, waits for the add-in and reports back. An AI driving with no human present may call it directly. (Legacy callers: this was errorCode fusion_addin_not_responding.)"})) }++#[cfg(test)] mod autostart_tests {+    use super::*;+    fn dir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("adom-autostart-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d }+    #[test] fn a_first_start_goes_ahead_and_so_does_a_second() {+        let d = dir("two");+        assert!(autostart_paused(&d, 1000.0).is_none());+        note_autostart(&d, 1000.0);+        assert!(autostart_paused(&d, 1060.0).is_none());+    }+    #[test] fn a_third_start_within_minutes_is_paused_then_the_pause_ends() {+        let d = dir("three");+        note_autostart(&d, 1000.0); note_autostart(&d, 1060.0);+        assert!(autostart_paused(&d, 1120.0).is_some());+        assert!(autostart_paused(&d, 1060.0 + AUTOSTART_PAUSE_SECS + 1.0).is_none());+    }+    #[test] fn starts_far_apart_never_add_up() {+        let d = dir("apart");+        note_autostart(&d, 1000.0); note_autostart(&d, 1000.0 + RESTART_WINDOW_SECS + 1.0); note_autostart(&d, 1000.0 + 2.0 * RESTART_WINDOW_SECS + 2.0);+        assert!(autostart_paused(&d, 1000.0 + 2.0 * RESTART_WINDOW_SECS + 3.0).is_none());+    }+}
rust/bridge/src/maintenance.rs+4−3
@@ -85,9 +85,9 @@ pub fn apply_fusion_update(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value) -> Va     out } -fn installer_running(ctx: &Ctx) -> bool {+pub fn installer_running(ctx: &Ctx) -> bool {     let Some(ab) = Loopback::from_env() else { return false };-    for name in ["streamer", "FusionDL", "Fusion Client Downloader"] {+    for name in ["streamer", "FusionDL", "Fusion Client Downloader", "FusionClientDownloader"] {         if let Ok(r) = ab.call("process_list", json!({"name": name}), &ctx.caller, 10) { if r["processes"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { return true; } }     }     false@@ -95,7 +95,8 @@ fn installer_running(ctx: &Ctx) -> bool {  /// `fusion_install_fusion`: the official streamer, silently; per-user when not elevated. pub fn install_fusion(ctx: &Ctx) -> Value {-    if ctx.fusion_exe.is_some() { return json!({"success": true, "alreadyInstalled": true, "_hint": "Fusion 360 is already installed - call fusion_start to launch it."}); }+    // a copy on disk isn't enough: a launcher pointing at a stripped folder needs the same installer, which repairs it+    if ctx.fusion_exe.as_deref().is_some_and(|e| fusion_core::install_check::launch_problem(e).is_none()) { return json!({"success": true, "alreadyInstalled": true, "_hint": "Fusion 360 is already installed - call fusion_start to launch it."}); }     if installer_running(ctx) { return json!({"success": true, "installing": true, "statusVerb": "fusion_readiness", "_hint": "An install is ALREADY streaming (10-30 min). Poll fusion_readiness until installed:true - do not start a second installer."}); }     let stub = std::env::temp_dir().join("FusionClientDownloader.exe");     let (code, bytes) = fusion_core::aps::http_bytes("https://dl.appstreaming.autodesk.com/production/installers/Fusion%20Client%20Downloader.exe");
rust/core/src/install_check.rsadded+83
@@ -0,0 +1,83 @@+//! Can this Fusion install start at all? Windows answers a launch of a stripped install with one "System+//! Error" popup per missing DLL, and the launcher points wherever its .ini says, whatever else is on disk.+//! An interrupted update left exactly that: a launcher pointing at a folder with Fusion360.exe and almost+//! nothing else (2026-11-04). So it's checked from the disk first, and the popups are never raised.+use std::path::{Path, PathBuf};++/// A real Fusion app folder has hundreds of DLLs; the stripped one had 2.+const MIN_APP_DLLS: usize = 20;+const APP_EXE: &str = "Fusion360.exe";++/// The folder a FusionLauncher.exe.ini starts, from its `cmd = ""C:\...\Fusion360.exe" ...` line. The file is UTF-16.+pub fn ini_target(bytes: &[u8]) -> Option<PathBuf> {+    let text = if bytes.starts_with(&[0xff, 0xfe]) {+        String::from_utf16_lossy(&bytes[2..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::<Vec<_>>())+    } else {+        String::from_utf8_lossy(bytes).into_owned()+    };+    let line = text.lines().find(|l| l.trim_start().to_lowercase().starts_with("cmd"))?;+    let at = line.to_lowercase().find(&APP_EXE.to_lowercase())?;+    let start = line[..at].rfind('"')? + 1;+    Some(PathBuf::from(line[start..at].replace('\\', "/")))+}++fn dll_count(dir: &Path) -> usize {+    std::fs::read_dir(dir).map(|d| d.flatten().filter(|e| e.path().extension().map(|x| x.eq_ignore_ascii_case("dll")).unwrap_or(false)).count()).unwrap_or(0)+}++/// Why this launcher can't start Fusion, or None. Says None when it can't tell: this must never stop a good install.+pub fn launch_problem(launcher: &Path) -> Option<String> {+    let name = launcher.file_name()?.to_string_lossy().to_lowercase();+    let dir = if name == APP_EXE.to_lowercase() {+        launcher.parent()?.to_path_buf()+    } else {+        ini_target(&std::fs::read(launcher.with_file_name("FusionLauncher.exe.ini")).ok()?)?+    };+    if !dir.is_dir() { return Some(format!("its launcher points at {}, which isn't there", dir.display())); }+    let dlls = dll_count(&dir);+    (dlls < MIN_APP_DLLS).then(|| format!("{} has only {dlls} DLLs, so the install is incomplete (an update that didn't finish)", dir.display()))+}++#[cfg(test)]+mod tests {+    use super::*;+    fn temp(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("adom-install-check-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d }+    fn utf16(s: &str) -> Vec<u8> { let mut v = vec![0xff, 0xfe]; for u in s.encode_utf16() { v.extend(u.to_le_bytes()); } v }+    fn app(dir: &Path, dlls: usize) { std::fs::create_dir_all(dir).unwrap(); std::fs::write(dir.join(APP_EXE), b"x").unwrap(); for i in 0..dlls { std::fs::write(dir.join(format!("a{i}.dll")), b"x").unwrap(); } }+    fn launcher(root: &Path, target: &Path) -> PathBuf {+        let l = root.join("launcher"); std::fs::create_dir_all(&l).unwrap();+        std::fs::write(l.join("FusionLauncher.exe"), b"x").unwrap();+        std::fs::write(l.join("FusionLauncher.exe.ini"), utf16(&format!("[Launcher]\r\nstream = production\r\ncmd = \"\"{}/Fusion360.exe\" /arg\"\r\nglobal = False\r\n", target.display()))).unwrap();+        l.join("FusionLauncher.exe")+    }+    #[test] fn reads_the_folder_from_a_real_shaped_ini() {+        let b = utf16("[Launcher]\r\nstream = production\r\nauid = Autodesk\r\ncmd = \"\"C:\\Users\\me\\webdeploy\\production\\39e8\\Fusion360.exe\" /x\"\r\n");+        assert_eq!(ini_target(&b).unwrap(), PathBuf::from("C:/Users/me/webdeploy/production/39e8/"));+    }+    #[test] fn a_stripped_install_is_refused_and_a_full_one_is_not() {+        let r = temp("full");+        let (bad, good) = (r.join("bad"), r.join("good"));+        app(&bad, 2); app(&good, 300);+        assert!(launch_problem(&launcher(&r, &bad)).unwrap().contains("only 2 DLLs"));+        assert!(launch_problem(&launcher(&r, &good)).is_none());+        assert!(launch_problem(&bad.join(APP_EXE)).is_some());+        assert!(launch_problem(&good.join(APP_EXE)).is_none());+    }+    /// A real install, read-only: ADOM_REAL_INI=<FusionLauncher.exe.ini> ADOM_REAL_ROOT=<a path prefix that maps C:/ here>+    #[test] #[ignore] fn a_real_install_passes() {+        let ini = std::env::var("ADOM_REAL_INI").unwrap();+        let t = ini_target(&std::fs::read(&ini).unwrap()).expect("the ini names a folder");+        println!("target {}", t.display());+        let here = PathBuf::from(t.to_string_lossy().replace("C:/", &std::env::var("ADOM_REAL_ROOT").unwrap()));+        println!("dlls {}", dll_count(&here));+        assert!(dll_count(&here) >= MIN_APP_DLLS);+    }+    #[test] fn a_missing_target_is_a_problem_and_doubt_is_not() {+        let r = temp("doubt");+        assert!(launch_problem(&launcher(&r, &r.join("gone"))).unwrap().contains("isn't there"));+        let l = r.join("noini"); std::fs::create_dir_all(&l).unwrap();+        assert!(launch_problem(&l.join("FusionLauncher.exe")).is_none());+        std::fs::write(l.join("FusionLauncher.exe.ini"), b"[Launcher]\nstream = x\n").unwrap();+        assert!(launch_problem(&l.join("FusionLauncher.exe")).is_none());+    }+}
rust/core/src/lib.rs+1
@@ -7,6 +7,7 @@ //! portable for free. Never add a `windows` / `cocoa` dependency here; the CI check //! `tools/check-portable.sh` fails the build if one appears. +pub mod install_check; pub mod contract; pub mod host; pub mod alerts;