← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.355\ No newline at end of file+1.9.356\ 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.355",+  "version": "1.9.356",   "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.355"+version = "1.9.356" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.355"+version = "1.9.356" 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.355"+DASH_VERSION="1.9.356" 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.355",+  "version": "1.9.356",   "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.355",+  "version": "1.9.356",   "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.355"+version = "1.9.356" edition = "2021" license = "MIT" 
rust/PORTING.md+21
@@ -172,3 +172,24 @@ in the roadmap's ab list); `desktop_wait_for_window` replaces the four polling l   Python bridge learned on 2026-08-29. Now each request runs on its own thread (tiny_http   hands them out concurrently); a blocking `fusion_start` through the exe produced no   respawn and readiness agreed afterwards.+- **The registry lints every `.lbr` in the repo tree on `pkg publish`**, not only the files in+  `page.json.files`. Golden and fixture libraries therefore carry the `.lbrx` extension+  (they are deliberately broken or bound to a test account's cloud), and shipped samples+  carry no `<package3d>` urn (a urn is a pointer into the minting account, nothing for+  anyone else). `adom-lbr validate <file>` shows the same findings locally.+- **Readiness ownership switch (2026-09-03).** `prefs.json` `readinessOwner: "exe"` makes the+  exe serve `fusion_readiness` itself: it gathers, acts (seat dialog and update click through+  the sidecar's `resolve_seat_dialog` / `apply_fusion_update`, the crash janitor through+  `dismiss_blocking_dialogs {categories:["crash_report"]}`, launch self-heal through+  `stop`/`kill` + `start`, launch timing samples and pref writes natively), computes, and+  answers with `readinessOwner: "exe"`; the sidecar is not asked. Any other value (or no+  key) keeps the sidecar as owner with the exe shadowing beside it. Roll back by deleting+  the key. One owner at a time: the sidecar's own sweeps (seat takeover, update sweep) keep+  running in both modes, as before.+- **Owned readiness proven (ConfRoomROG, 2026-09-03):** with `readinessOwner: "exe"` the exe+  served every readiness poll through a real stop/start: the launch progress block ("Fusion is+  up - waiting for the Adom add-in to serve"), ready afterwards, the signed-in user fetched+  once and cached, `launchStartedAt` cleared, `lastReadyAt` written, and a new cold-start+  sample (38.5 s) in `timings.json`. Set the pref WITHOUT a byte-order mark: PowerShell's+  `Set-Content -Encoding UTF8` writes one, the Python bridge's pref loader then fails and+  rewrites the file without the key (the first attempt silently never engaged).
rust/bridge/Cargo.toml+1
@@ -10,6 +10,7 @@ name = "adom-fusion-bridge" path = "src/main.rs"  [dependencies]+regex = "1" chrono = { version = "0.4", default-features = false, features = ["clock"] } fusion-core = { path = "../core" } fusion-lbr = { path = "../lbr" }
rust/bridge/src/main.rs+126−8
@@ -318,6 +318,7 @@ fn main() {                 let reply = match verb.as_str() {                     // Phase 3 shadow: the sidecar still answers; the exe computes its own readiness                     // from real probes and rides beside it with a diff, until parity holds.+                    "fusion_readiness" | "readiness" if readiness_owner_is_exe(&**h) => owned_readiness(&**h, &sidecar, &caller),                     "fusion_readiness" | "readiness" => {                         let mut r = proxy(&sidecar, &raw, &caller);                         let native = shadow_readiness(&**h, &caller);@@ -848,10 +849,131 @@ fn native_aps_status(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) ->  /// Gather every input the readiness computation needs from real probes (no side effects), /// and compute the native answer. Shadow mode: nothing is written, nothing is acted on.+/// Who serves `fusion_readiness`: the sidecar (default, the exe shadows) or the exe+/// (`prefs.json` `readinessOwner: "exe"`). One owner at a time, never both acting.+fn readiness_owner_is_exe(h: &dyn HostBoth) -> bool {+    std::fs::read_to_string(h.bridge_state_dir().join("prefs.json")).ok().and_then(|t| serde_json::from_str::<Value>(t.trim_start_matches('\u{feff}')).ok())+        .map(|p| p["readinessOwner"].as_str() == Some("exe")).unwrap_or(false)+}++/// The signed-in Fusion user, fetched once through the add-in in the background and cached+/// until Fusion stops (the dashboard shows who is signed in; the auth matrix runs accounts).+static FUSION_USER: Mutex<Option<Value>> = Mutex::new(None);+static FUSION_USER_FETCHING: Mutex<bool> = Mutex::new(false);++fn ensure_fusion_user_async(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) {+    if FUSION_USER.lock().unwrap().is_some() || *FUSION_USER_FETCHING.lock().unwrap() { return; }+    *FUSION_USER_FETCHING.lock().unwrap() = true;+    let (sidecar, caller) = (sidecar.clone(), caller.clone());+    std::thread::spawn(move || {+        let body = json!({"command": "fusion_run_modeling_script", "args": {"script": "import adsk.core\na=adsk.core.Application.get()\nresult={'user': a.userName, 'id': a.userId}"}}).to_string();+        let r = proxy(&sidecar, &body, &caller);+        let d = &r["data"]["result"];+        if let Some(u) = d["user"].as_str() { *FUSION_USER.lock().unwrap() = Some(json!({"user": u, "userId": d["id"]})); }+        *FUSION_USER_FETCHING.lock().unwrap() = false;+    });+}++/// The user's Default Modeling Orientation from Fusion's local options file (disk only).+fn modeling_orientation(user_id: Option<&str>) -> Option<String> {+    let uid = user_id?;+    let appdata = std::env::var("APPDATA").ok()?;+    let p = std::path::Path::new(&appdata).join("Autodesk").join("Neutron Platform").join("Options").join(uid).join("NGlobalOptions.xml");+    let t = std::fs::read_to_string(p).ok()?;+    let re = regex::Regex::new(r#"sceneUpOptionId[^>]*Value="(\d)""#).ok()?;+    match re.captures(&t)?.get(1)?.as_str() { "0" => Some("yup".into()), "1" => Some("zup".into()), _ => None }+}++/// `timings.record("launch", secs, "cold")`: a rolling ten samples per phase and start kind.+fn record_launch(h: &dyn HostBoth, secs: f64) {+    let p = h.bridge_state_dir().join("timings.json");+    let mut d: Value = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+    let key = "launch|cold";+    let mut samples: Vec<Value> = d[key]["samples"].as_array().cloned().unwrap_or_default().into_iter().filter(|v| v.is_number()).collect();+    samples.push(json!((secs * 10.0).round() / 10.0));+    let keep: Vec<Value> = samples.iter().rev().take(10).rev().cloned().collect();+    d[key] = json!({"samples": keep, "lastAt": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0)});+    let tmp = p.with_extension("json.tmp");+    if std::fs::write(&tmp, serde_json::to_string_pretty(&d).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, &p); }+}++/// Merge the computation's pref writes into prefs.json (other keys untouched).+fn write_prefs(h: &dyn HostBoth, after: &serde_json::Map<String, Value>) {+    let p = h.bridge_state_dir().join("prefs.json");+    let mut d: Value = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+    if !d.is_object() { d = json!({}); }+    for (k, v) in after { d[k] = v.clone(); }+    let tmp = p.with_extension("json.tmp");+    if std::fs::write(&tmp, serde_json::to_string_pretty(&d).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, &p); }+}++/// The exe OWNS readiness: gather, act where the Python handler would have acted (through+/// the sidecar's executor verbs and its own verbs), compute, persist, and answer.+fn owned_readiness(h: &dyn HostBoth, sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) -> Value {+    use fusion_core::readiness::compute;+    let mut inputs = gather_readiness_inputs(h, caller);+    let call = |verb: &str, args: Value| -> Value { proxy(sidecar, &json!({"command": verb, "args": args}).to_string(), caller) };+    let mut seat_resolved = false;+    // the seat / licensing modal: resolve now, then look again (like the Python handler)+    if inputs.running && inputs.licensing_dialog {+        let r = call("resolve_seat_dialog", json!({}));+        seat_resolved = r["clicks"].as_u64().unwrap_or(0) > 0;+        inputs = gather_readiness_inputs(h, caller);+    }+    // the crash-report janitor: clear crash dialogs while Fusion is up but not serving+    let provisional_ready = inputs.installed_raw && inputs.running && inputs.addin_version.is_some() && !inputs.needs_signin && !inputs.licensing_dialog;+    if inputs.running && !provisional_ready {+        let r = call("dismiss_blocking_dialogs", json!({"categories": ["crash_report"]}));+        inputs.crash_dialogs_cleared = r["count"].as_u64().unwrap_or(0) as usize;+    }+    // the silent update click happens BEFORE the hint is composed, like Python+    if provisional_ready {+        let r = call("apply_fusion_update", json!({}));+        inputs.update_offered = r["offered"].as_bool().unwrap_or(false);+        if r["applied"].as_bool() == Some(true) { inputs.update_applied_button = r["button"].as_str().map(str::to_string); }+    }+    // who is signed in, and their modeling orientation+    if provisional_ready { ensure_fusion_user_async(sidecar, caller); } else if !inputs.running { *FUSION_USER.lock().unwrap() = None; }+    inputs.fusion_user = FUSION_USER.lock().unwrap().clone();+    inputs.modeling_orientation = modeling_orientation(inputs.fusion_user.as_ref().and_then(|u| u["userId"].as_str()));+    let out = compute(&inputs);+    write_prefs(h, &out.prefs_after);+    if let Some(secs) = out.actions.record_launch_seconds { record_launch(h, secs); }+    if let Some(attempt) = out.actions.heal_launch {+        let (sidecar, caller) = (sidecar.clone(), caller.clone());+        std::thread::spawn(move || {+            let c = |verb: &str| proxy(&sidecar, &json!({"command": verb, "args": {}}).to_string(), &caller);+            if attempt >= 2 { c("kill"); } else { c("stop"); }+            std::thread::sleep(Duration::from_secs(8));+            c("start");+        });+        eprintln!("[fusion-bridge] add-in never came up; restarting Fusion (attempt {attempt})");+    }+    let mut r = out.result;+    if seat_resolved { r["seatDialogAutoResolved"] = json!(true); }+    r["readinessOwner"] = json!("exe");+    r+}+ fn shadow_readiness(h: &dyn HostBoth, caller: &Caller) -> Value {-    use fusion_core::readiness::{compute, timings_stats, timings_summary, Inputs};+    use fusion_core::readiness::compute;+    let inputs = gather_readiness_inputs(h, caller);+    let facts_degraded = inputs.fusion_user.is_none() && false; // kept for the field below+    let out = compute(&inputs);+    let mut r = out.result;+    r["shadow"] = json!(true);+    r["actions"] = serde_json::to_value(&out.actions).unwrap_or(Value::Null);+    r["windowFactsDegraded"] = json!(WINDOW_FACTS_DEGRADED.lock().unwrap().0 || facts_degraded);+    r+}++/// The last window-facts fetch: (degraded, error) so both readiness paths can report it.+static WINDOW_FACTS_DEGRADED: Mutex<(bool, Option<String>)> = Mutex::new((false, None));++fn gather_readiness_inputs(h: &dyn HostBoth, caller: &Caller) -> fusion_core::readiness::Inputs {+    use fusion_core::readiness::{timings_stats, timings_summary, Inputs};     let state = h.bridge_state_dir();-    let read_json = |p: std::path::PathBuf| -> Value { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({})) };+    let read_json = |p: std::path::PathBuf| -> Value { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(t.trim_start_matches('\u{feff}')).ok()).unwrap_or(json!({})) };     let prefs = read_json(state.join("prefs.json")).as_object().cloned().unwrap_or_default();     let timings = read_json(state.join("timings.json"));     let installed_raw = h.fusion_executable().is_some();@@ -890,10 +1012,6 @@ fn shadow_readiness(h: &dyn HostBoth, caller: &Caller) -> Value {         timings_launch: timings_summary(&timings)["launch"].clone(), launch_stats: timings_stats(&timings, "launch", "cold"),         progress_override: None, addin_start_failed, crash_dialogs_cleared: 0, update_applied_button: None, update_offered: false,     };-    let out = compute(&inputs);-    let mut r = out.result;-    r["shadow"] = json!(true);-    r["actions"] = serde_json::to_value(&out.actions).unwrap_or(Value::Null);-    r["windowFactsDegraded"] = json!(facts.degraded);-    r+    *WINDOW_FACTS_DEGRADED.lock().unwrap() = (facts.degraded, facts.error.clone());+    inputs }