app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
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.
← Commit history
Update 129 file(s)
23 files changed
+571−55
BRIDGE_VERSION+1−1bridge.json+1−1contracts/describe.json+36−3dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+4−2install.sh+1−1package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/PORTING.md+46rust/bridge/src/main.rs+115−13rust/core/src/ab.rs+11−1rust/core/src/host.rs+11rust/core/src/lib.rs+1rust/core/src/readiness.rs+243rust/core/src/windows_facts.rs+3−1rust/os-win/src/lib.rs+43server.py+40−1tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.shBRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.354+1.9.355
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.354",+ "version": "1.9.355", "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+36−3@@ -66,6 +66,20 @@ "summary": "Fusion's OWN alert toasts, read from its log. Fusion draws error/warning toasts in CEF: they are invisible to Win32 enumeration AND to UIA, so a verb can look successful while Fusion is visibly complaining. This reads them, newest last.", "timeoutSeconds": 30 },+ {+ "example": {},+ "input": {},+ "longRunning": false,+ "name": "fusion_apply_fusion_update",+ "output": {+ "applied": "bool",+ "button": "str|null",+ "why": "str"+ },+ "statusVerb": null,+ "summary": "Readiness EXECUTOR (Rust port, Phase 3): click Fusion's update offer IN THE BACKGROUND when auto-update is on (sticky pref; fusion_set_auto_update). Never the exit-class buttons while a host op is active. Answers {applied, button, why}.",+ "timeoutSeconds": 60+ }, { "example": {}, "input": {@@ -708,14 +722,18 @@ }, { "example": {},- "input": {},+ "input": {+ "categories": "optional [str] dialog categories to act on (default: all blocking dialogs)"+ }, "longRunning": false, "name": "fusion_dismiss_blocking_dialogs", "output": {- "dialogs": "[...]"+ "count": "int",+ "dialogs": "[...]",+ "dismissed": "[{title, category, answeredWith, closed}]" }, "statusVerb": null,- "summary": "Detect + classify (and optionally dismiss) blocking Fusion dialogs.",+ "summary": "Detect + classify (and optionally dismiss) blocking Fusion dialogs. Answers every one with the safe button (No/Cancel via WM_CLOSE, never Yes, never focus-stolen). {categories:[...]} narrows the sweep to those categories (e.g. [\"crash_report\"], the launch janitor's scope).", "timeoutSeconds": 30 }, {@@ -1910,6 +1928,21 @@ "summary": "Bring Fusion back to a clean slate between runs: closes named documents AND the Untitled scratch the library/bind flow leaves behind (page issue #59). Fusion always keeps ONE blank document alive, so a single remaining Untitled is correct rather than a failure. MODIFIED Untitled docs are left untouched unless force:true, because those can hold real unsaved work.", "timeoutSeconds": 90 },+ {+ "example": {},+ "input": {+ "maxClicks": "optional int (default 3)"+ },+ "longRunning": false,+ "name": "fusion_resolve_seat_dialog",+ "output": {+ "clicks": "int",+ "resolved": "bool"+ },+ "statusVerb": null,+ "summary": "Readiness EXECUTOR (Rust port, Phase 3): detect + resolve the Autodesk seat/licensing modal ('Active Sessions Exceeded' / 'Suspend Remote Session') in the background, at most 3 UIA clicks per call, screenshot-verified. Idempotent: nothing up answers clicks:0. fusion_readiness calls this itself when it sees the dialog; call it directly only when you are driving the launch by hand.",+ "timeoutSeconds": 60+ }, { "example": { "save": true
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.354"+version = "1.9.355" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.354"+version = "1.9.355" edition = "2021" [dependencies]
describe.py+4−2@@ -235,6 +235,8 @@ _T = [ ("fusion_show_project", "Eight-surfaces contract: bring the PROJECT/design to its top level. No args = activate this session's generated test project (fusion_make_test_project). query = cloud design name (already-open match wins, else fire-and-poll open). urn = open_by_urn passthrough. Background; returns a screenshot path.", {"capture": "optional bool - ONE call = ONE consent: the bridge settles the surface (waits for the add-in main thread + a paint beat) and returns proof inline as shot:{title,image,thumbnailDataUrl,maxWidth} (base64 frame + a ready-to-use data: URL for BROWSER callers who cannot pull a file; set captureMaxWidth to shrink it - 320 keeps a /command body small, default 640. The bridge encodes PNG; the relay may transcode to WebP, so sniff rather than assume). Use this instead of polling fusion_state after a show - page-side settle polling costs one human consent per poll (issue #30).", "query": "optional str (cloud design name)", "urn": "optional str", "fileName": "alias of query"}, {"success": "bool", "view": "str", "document": "str", "screenshot": "str"}, 240, "fusion_get_app_state", True, {"query": "BQ25792"}), ("fusion_set_window_sizing", "Read/toggle auto-sizing Fusion's WINDOW after launch (STICKY, default ON). Fusion opens at whatever geometry it last had, which after an automated launch is often a small default; the bridge grows it to ~92% of the monitor work area, centered, using SetWindowPos with NOACTIVATE (never ShowWindow/maximize) so focus is never stolen. A window the user maximized, or already sized to >=60% of the work area, is left alone. No args = read.", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "sizeFusionWindow": "bool"}, 15, "fusion_state", False, {}), ("fusion_set_driving_badge", "Read/toggle the 'Adom is driving Fusion' TASKBAR OVERLAY BADGE (STICKY per user, default ON). While the bridge drives Fusion, its Windows taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds automatically after 5 minutes of driving-verb idle. No args = read. Turning it off clears any badge immediately.", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "showDrivingBadge": "bool"}, 15, "fusion_state", False, {}),+ ("fusion_resolve_seat_dialog", "Readiness EXECUTOR (Rust port, Phase 3): detect + resolve the Autodesk seat/licensing modal ('Active Sessions Exceeded' / 'Suspend Remote Session') in the background, at most 3 UIA clicks per call, screenshot-verified. Idempotent: nothing up answers clicks:0. fusion_readiness calls this itself when it sees the dialog; call it directly only when you are driving the launch by hand.", {"maxClicks": "optional int (default 3)"}, {"clicks": "int", "resolved": "bool"}, 60, None, False, {}),+ ("fusion_apply_fusion_update", "Readiness EXECUTOR (Rust port, Phase 3): click Fusion's update offer IN THE BACKGROUND when auto-update is on (sticky pref; fusion_set_auto_update). Never the exit-class buttons while a host op is active. Answers {applied, button, why}.", {}, {"applied": "bool", "button": "str|null", "why": "str"}, 60, None, False, {}), ("fusion_set_seat_takeover", "Read/toggle the mid-session Autodesk seat-takeover sweep (STICKY per user, default ON). When Autodesk pops 'Active Sessions Exceeded' mid-session, the bridge clicks Continue through BOTH dialogs in the background: this machine takes the seat, the other machine's Fusion suspends RESUMABLY, the user gets one toast. No args = read current value. Gates only the passive sweep - a verb-driven fusion_start still grabs the seat (the user asked for Fusion, so the seat is implied).", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "autoTakeOverSeat": "bool"}, 15, "fusion_readiness", False, {}), ("fusion_state", "The RENDER-ME verb (kicad_state parity, issues #28/#30): running + window + dialog + busy state PLUS a fresh background screenshot of whatever Fusion is showing - the one verb that makes every background fusion_show_* result visible. SERVER-SIDE ONLY by design: it answers even while the add-in main thread is busy or wedged (activeDocument is best-effort with a short budget and skipped when busy). blockingDialogs carries the CLASSIFIED modal list (cause + resolution). imageAs:base64 inlines the PNG (auto-refused over 4MB - pull the screenshot path via pull_file instead).", {"screenshot": "optional bool (default true)", "imageAs": "optional str path|base64 (default path)"}, {"success": "bool", "running": "bool", "activeDocument": "str", "mainThreadBusy": "dict|null", "blockingDialogs": "list", "focusEvents": "list - the QUIETNESS AUDIT LOG (SDK canonical): every time this bridge took the user's foreground, or refused a bare foreground:true, as {at, verb, outcome: foreground|refused, reason}. Audit how quiet the bridge actually was instead of trusting that it was.", "timings": "dict - measured launch history on THIS machine (p50/p90 per phase, cold vs warm)", "screenshot": "str path", "screenshotBase64": "str (imageAs:base64 only)"}, 60, None, False, {}), ("fusion_export_iges", "Export to IGES.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/board.iges"}),@@ -259,8 +261,8 @@ _T = [ {"fab": "optional str vendorSlug (jlcpcb|pcbway|osh-park|seeed-fusion|eurocircuits|aisler|elecrow|advanced-circuits-4pcb|sierra-circuits|macrofab|nextpcb|allpcb|adom-instapcb)", "process": "optional str (the vendor's tier: standard|advanced|proto|enig|hasl|...; default standard)", "layers": "optional str (auto|2|4|6)", "druPath": "optional str local .edru/.dru to load as-is", "action": "optional str (apply|list|export|show)"}, {"activeWorkspace": "str", "description": "str", "pack": "dict {fab, vendor, process, layers, file, record}"}, 90, None, False, {"fab": "jlcpcb", "layers": "2"}), ("fusion_detect_layers", "Detect 2-layer vs 4-layer board (ULP + CAM comparison).", {}, {"layerCount": "int", "copperLayers": "[int]"}, 90, None, False, {}),- ("fusion_dismiss_blocking_dialogs", "Detect + classify (and optionally dismiss) blocking Fusion dialogs.",- {}, {"dialogs": "[...]"}, 30, None, False, {}),+ ("fusion_dismiss_blocking_dialogs", "Detect + classify (and optionally dismiss) blocking Fusion dialogs. Answers every one with the safe button (No/Cancel via WM_CLOSE, never Yes, never focus-stolen). {categories:[...]} narrows the sweep to those categories (e.g. [\"crash_report\"], the launch janitor's scope).",+ {"categories": "optional [str] dialog categories to act on (default: all blocking dialogs)"}, {"dialogs": "[...]", "dismissed": "[{title, category, answeredWith, closed}]", "count": "int"}, 30, None, False, {}), ("fusion_electron_run", "Run an EAGLE/Electron command in the open board (the generic extension point, e.g. RUN <ulp>).", {"command": "required str"}, {"success": "bool", "output": "str"}, 90, None, False, {"command": "set confirm yes;RUN 'C:/tmp/jlcpcb_smta_exporter.ulp' 'C:/tmp/jlc'"}), ("fusion_electron_zoom", "Smoothly ZOOM the schematic/2D board for a recording (the whole motion is one call). factor>1 zooms in, <1 out; or fit:true. The loop runs in the add-in with a repaint per frame.",
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.354"+DASH_VERSION="1.9.355" 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.354",+ "version": "1.9.355", "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.354",+ "version": "1.9.355", "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]"- }-}+ ]+}
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.354"+version = "1.9.355" edition = "2021" license = "MIT"
rust/PORTING.md+46@@ -126,3 +126,49 @@ in the roadmap's ab list); `desktop_wait_for_window` replaces the four polling l Python bridge (`installed`, `exe_path`, `addins_dir`, `addin_installed`, `running`, the add-in probe, led / summary / tooltip), cached 5 s; with a sidecar present it rides as `nativeSnapshot` beside the sidecar's answer. Proven identical on ConfRoomROG.+- **Phase 3 layer 2 landed (2026-09-03):** window facts from ab's `desktop_list_windows`+ (owner, pid, image, kind, rect: no ab change was needed): the sign-in title and the seat+ dialog shape rule, with the field cases as unit tests. Porting it found a live false+ positive shared with Python: Fusion's 640x196 message tray is an OWNED, LANDSCAPE tool+ window titled "Fusion360", so both rules called it the seat dialog and readiness sat at+ ready:false on a healthy Fusion. Fixed on both sides in 1.9.354 (a tool window is never a+ modal); ConfRoomROG went ready:true the moment it installed.+- **Layer 2 proven under ab (2026-09-03, ConfRoomROG):** with the exe spawned by ab (so it+ holds the bridge token that exempts a bridge's own calls from ab's caller-identity gate),+ the native window facts came back live: no sign-in, no seat dialog, the real main title;+ the Python readiness agreed. A hand-launched exe cannot pass that gate and reports the+ facts as `windowFactsDegraded` with the refusal text, by design.+- **Flipping a test box to the exe by hand:** copy the exe into the bridge cache dir, edit+ ONLY the two `spawn` strings in `bridge.json` as text (a JSON round-trip through PowerShell+ changed the file's shape and ab fell back to its bundled 1.6.89 seed), then+ `refresh_bridges` (ab's registry re-reads bridge.json only on a rescan or an install) and+ `bridge_kill {confirm:true}`. The next `bridge_install` restores the manifest's shape.+- **Readiness in shadow (2026-09-03):** `fusion_readiness` is ported as a pure computation+ (`fusion-core::readiness`, sixteen Python-generated golden scenarios: result, hint text,+ pref writes), and the exe computes it from real probes beside the sidecar's answer as+ `nativeReadiness {result, shadowDiff}`. On a live ready Fusion the diff was only the+ second of the `lastReadyAt` / `lastSignedInAt` stamps (two writers). Side effects come+ back as `actions` and are not run until the exe owns readiness.+- **The sidecar's port must be outside ab's allocator range.** ab persists the port it+ assigned and, on a respawn, probes it first and routes to whatever answers there. The+ sidecar's ephemeral pick landed on that exact port once (Windows returns the last freed+ port) and ab bypassed the exe for a whole session. The sidecar now binds in 20000-29999;+ `/status` carries `exePort` and `sidecarPort` so the two are never confused again.+- **ab's bundled-bridge collapse can adopt the sidecar.** `BUNDLED_SPECS` calls any+ `python.exe` whose `/health` mentions Fusion "the fusion bridge" and re-pins routing to it,+ which bypassed the exe for a session. The sidecar therefore runs as+ `adom-fusion-sidecar.exe` (a copy of ab's python launcher beside its DLLs) and binds in+ 20000-29999. Proven stable on ConfRoomROG; ab ask filed (adom-bridge, collapse should key+ on `spawn.kind`). Diagnose a bypass in one call: `fusion_shell_info` answers+ `shell: rust` only when the exe is in the path.+- **Shadow readiness across a real stop/start (ConfRoomROG, 2026-09-03):** identical at+ every stable state (ready, stopped, ready again). The one divergence was the poll at the+ instant the add-in came up: Python's 1.0 s add-in probe timed out under launch load and+ said "add-in not responding", while the exe's probe (1.5 s) got the add-in's answer and the+ round-trip passed. A boundary race, not a logic difference; the exe's answer was the true one.+- **Every request on its own thread (2026-09-03).** The shell's request loop was sequential,+ so a long verb (`fusion_start`, a bind) blocked ab's health poll and ab reaped and+ respawned the exe twice during one Fusion launch, orphaning the run: the same lesson the+ 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.
rust/bridge/src/main.rs+115−13@@ -111,8 +111,22 @@ impl Sidecar { found.pop().map(|f| f.1) } + /// The sidecar runs under its OWN image name. ab's bundled-bridge collapse scans every+ /// loopback listener and adopts any `python.exe` whose /health body mentions Fusion as+ /// THE fusion bridge, re-pinning the route to it; that sent every verb straight to the+ /// sidecar and bypassed this exe for a whole session. python.exe is a tiny launcher that+ /// loads python3xx.dll from its own directory, so a copy named adom-fusion-sidecar.exe+ /// beside it runs identically and is invisible to that scan.+ fn sidecar_image(py: &std::path::Path) -> std::path::PathBuf {+ let Some(dir) = py.parent() else { return py.to_path_buf() };+ if !py.file_name().map(|n| n.to_string_lossy().eq_ignore_ascii_case("python.exe")).unwrap_or(false) { return py.to_path_buf(); }+ let alias = dir.join("adom-fusion-sidecar.exe");+ let fresh = match (std::fs::metadata(&alias), std::fs::metadata(py)) { (Ok(a), Ok(b)) => a.len() == b.len(), _ => false };+ if fresh || std::fs::copy(py, &alias).is_ok() { alias } else { py.to_path_buf() }+ }+ fn spawn(bind: &str, log_dir: &std::path::Path) -> Option<Sidecar> {- let py = Self::python_exe()?;+ let py = Self::sidecar_image(&Self::python_exe()?); let here = std::env::current_exe().ok()?.parent()?.to_path_buf(); // The sidecar's server.py sits beside this exe in the bridge dir during the port. let server_py = here.join("server.py");@@ -169,7 +183,7 @@ fn native_status(h: &dyn HostBoth) -> Value { let addin_ok = addin.is_object(); // layer 2: the window facts, from ab (never local Win32) let facts = if running {- match fusion_core::ab::Loopback::from_env() { Some(ab) => fusion_core::windows_facts::facts_from_ab(&ab, &Caller::default()), None => fusion_core::windows_facts::WindowFacts { degraded: true, ..Default::default() } }+ match fusion_core::ab::Loopback::from_env() { Some(ab) => fusion_core::windows_facts::facts_from_ab(&ab, &Caller::default()), None => fusion_core::windows_facts::WindowFacts { degraded: true, error: Some("ADOM_DIRECT_API_URL not set".into()), ..Default::default() } } } else { fusion_core::windows_facts::WindowFacts::default() }; let (led, summary, tooltip) = if !cfg!(windows) { ("yellow", "Unsupported OS", "Bridge running, but this host is not Windows. Fusion 360 verbs need a Windows host with Fusion 360 installed.")@@ -190,13 +204,24 @@ fn native_status(h: &dyn HostBoth) -> Value { "addin_installed": addin_installed, "running": running}, "addin": addin, "shell": "rust", "needsSignin": facts.needs_signin, "licensingDialog": facts.licensing_dialog.is_some(),- "windowTitle": facts.main_title, "windowFactsDegraded": facts.degraded,+ "windowTitle": facts.main_title, "windowFactsDegraded": facts.degraded, "windowFactsError": facts.error, }); *SNAPSHOT.lock().unwrap() = Some(Snapshot { at: Instant::now(), body: body.clone() }); body } +/// A port for the SIDECAR that ab can never mistake for the bridge's. ab allocates bridge+/// ports from the OS ephemeral range (49152+), persists the last one, and on a respawn probes+/// that port FIRST and routes to whatever answers /health there. An ephemeral pick for the+/// sidecar landed on exactly that persisted port once (Windows hands back the last freed+/// port), and ab bypassed the exe for the rest of the session. So the sidecar lives in+/// 20000..29999, which ab's allocator never touches. fn pick_free_port() -> u16 {+ let seed = std::process::id() as u16 % 5000;+ for i in 0..5000u16 {+ let port = 20000 + (seed + i * 7) % 10000;+ if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { return port; }+ } std::net::TcpListener::bind("127.0.0.1:0").and_then(|l| l.local_addr()).map(|a| a.port()).unwrap_or(8773) } @@ -227,8 +252,8 @@ fn main() { let port: u16 = args.iter().position(|a| a == "--port") .and_then(|i| args.get(i + 1)).and_then(|p| p.parse().ok()).unwrap_or(8773); let bind = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".into());- let catalog = fusion_core::contract::load().expect("frozen verb catalog");- let h = host();+ let catalog = Arc::new(fusion_core::contract::load().expect("frozen verb catalog"));+ let h: Arc<Box<dyn HostBoth>> = Arc::new(host()); let sidecar = Arc::new(Mutex::new(Sidecar::spawn(&bind, &h.bridge_state_dir()))); // ab stops this bridge with a hard kill (bridge_install, supervisor respawn): the sidecar // must go with it, or every respawn leaves one more Python on a random port.@@ -242,8 +267,18 @@ fn main() { catalog.frozen, sidecar.lock().unwrap().as_ref().map(|s| format!("python on :{}", s.port)).unwrap_or("absent".into())); - for mut req in server.incoming_requests() {- let path = req.url().split('?').next().unwrap_or("/").to_string();+ // EVERY REQUEST ON ITS OWN THREAD. ab polls /status every few seconds and reaps a bridge+ // whose health poll stalls; a sequential loop let one long verb (fusion_start, a seven-+ // minute bind) block the poll, and ab respawned this exe twice during a single Fusion+ // launch, orphaning the run (ConfRoomROG, 2026-09-03). The Python bridge learned the+ // same lesson on 2026-08-29. tiny_http hands out requests concurrently; we take them.+ for req in server.incoming_requests() {+ let (h, catalog, sidecar) = (h.clone(), catalog.clone(), sidecar.clone());+ let port_now = port;+ std::thread::spawn(move || {+ let mut req = req;+ let port = port_now;+ let path = req.url().split('?').next().unwrap_or("/").to_string(); // A panic in a handler must cost one request (tiny_http answers 500 for the dropped // request), never the process: ab paints the chip red the moment the port goes dark. let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {@@ -253,8 +288,9 @@ fn main() { // this process is up regardless, so the answer is ALWAYS 2xx. // Without a sidecar this IS the answer; with one, the sidecar's rich status // wins and the native snapshot rides beside it for parity checks.- let native = native_status(h.as_ref());+ let native = native_status(&**h); let mut body = native.clone();+ body["exePort"] = json!(port); if let Some(sc) = sidecar.lock().unwrap().as_mut() { if let Some(code) = sc.exited() { body["led"] = json!("red"); body["status"] = json!("error");@@ -263,10 +299,10 @@ fn main() { body["sidecarExit"] = json!(code); } else if let Ok(r) = ureq::get(&sc.url("/status")).timeout(Duration::from_secs(4)).call() { if let Ok(mut v) = r.into_json::<Value>() {- v["shell"] = json!("rust"); v["sidecarPort"] = json!(sc.port);+ v["shell"] = json!("rust"); v["sidecarPort"] = json!(sc.port); v["exePort"] = json!(port); v["sidecarUptimeSec"] = json!(sc.started.elapsed().as_secs()); v["nativeSnapshot"] = json!({"led": native["led"], "summary": native["summary"], "fusion": native["fusion"], "addinOk": native["addin"].is_object(),- "needsSignin": native["needsSignin"], "licensingDialog": native["licensingDialog"], "windowTitle": native["windowTitle"], "windowFactsDegraded": native["windowFactsDegraded"]});+ "needsSignin": native["needsSignin"], "licensingDialog": native["licensingDialog"], "windowTitle": native["windowTitle"], "windowFactsDegraded": native["windowFactsDegraded"], "windowFactsError": native["windowFactsError"]}); body = v; } }@@ -280,16 +316,28 @@ fn main() { let parsed: Value = serde_json::from_str(&raw).unwrap_or(json!({})); let verb = parsed.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string(); 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" => {+ let mut r = proxy(&sidecar, &raw, &caller);+ let native = shadow_readiness(&**h, &caller);+ if let Some(obj) = r.as_object_mut() {+ let keys = ["installed", "installing", "running", "ready", "needsSignin", "licensingDialog", "updating", "addinStale", "addinVersion", "lastReadyAt", "lastSignedInAt", "_hint", "statusVerb", "kicadIngest"];+ let diff: Vec<String> = keys.iter().filter(|k| obj.get(**k) != native.get(**k)).map(|k| k.to_string()).collect();+ obj.insert("nativeReadiness".into(), json!({"result": native, "shadowDiff": diff}));+ }+ r+ } // Native since Phase 2: Fusion's own alert toasts from its AppLogFile, the // same answer the Python bridge gives (parser golden-tested against it). "fusion_alerts" | "alerts" => { let since = parsed["args"]["sinceSec"].as_f64().unwrap_or(300.0).max(1.0);- native_alerts(h.as_ref(), since)+ native_alerts(&**h, since) } // Native since Phase 2: the background-action ledger, a file the bridge appends.- "fusion_action_ledger" | "action_ledger" => native_action_ledger(h.as_ref(), &parsed["args"]),+ "fusion_action_ledger" | "action_ledger" => native_action_ledger(&**h, &parsed["args"]), // Native since Phase 2: the measured run-time report from the verb log.- "fusion_verb_times" | "verb_times" => native_verb_times(h.as_ref(), parsed["args"]["limit"].as_u64().unwrap_or(40) as usize),+ "fusion_verb_times" | "verb_times" => native_verb_times(&**h, parsed["args"]["limit"].as_u64().unwrap_or(40) as usize), // Native since Phase 2: offline library checks on the golden-tested .lbr engine. "fusion_library_parts" | "library_parts" => native_library_parts(&parsed["args"]), "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]),@@ -316,6 +364,7 @@ fn main() { } })); if outcome.is_err() { eprintln!("[fusion-bridge] a handler panicked on {path}; the shell stays up"); }+ }); } } @@ -795,3 +844,56 @@ fn native_aps_status(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) -> else { ("Ready. Use fusion_aps_search {\"query\":\"...\"}.".into(), "ready") }; json!({"success": true, "output": summary, "data": data, "_hint": hint, "shell": "rust"}) }+++/// 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.+fn shadow_readiness(h: &dyn HostBoth, caller: &Caller) -> Value {+ use fusion_core::readiness::{compute, 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 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();+ let streaming = h.image_running(&["streamer.exe", "FusionDL.exe", "Fusion Client Downloader.exe"]) || h.fusion_install_streaming();+ let running = installed_raw && !h.fusion_processes().is_empty();+ let addin = if running { addin_probe() } else { Value::Null };+ let facts = if running { match fusion_core::ab::Loopback::from_env() { Some(ab) => fusion_core::windows_facts::facts_from_ab(&ab, caller), None => Default::default() } } else { Default::default() };+ let expected = std::env::current_exe().ok().and_then(|e| e.parent().map(|d| d.join("addin").join("AdomBridge").join("AdomBridge.manifest")))+ .and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|m| m["version"].as_str().map(str::to_string)).unwrap_or_else(|| "unknown".into());+ let aps = fusion_core::aps::ApsStore::default_location();+ let aps_configured = !aps.client_id().is_empty();+ let aps_signed = aps.valid_access_token().is_some();+ let mcp = std::net::TcpStream::connect_timeout(&"127.0.0.1:27182".parse().unwrap(), Duration::from_millis(400)).is_ok();+ let addin_ok = addin.is_object();+ let provisional_ready = installed_raw && running && addin_ok && !facts.needs_signin && facts.licensing_dialog.is_none();+ let roundtrip_ok = if provisional_ready {+ ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(4)).send_json(json!({"command": "get_app_state", "args": {}})).ok()+ .and_then(|r| r.into_json::<Value>().ok()).map(|v| v["success"].as_bool().unwrap_or(false)).unwrap_or(false)+ } else { true };+ let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();+ let kicad_ingest = std::env::var("PATH").unwrap_or_default().split(';').any(|d| std::path::Path::new(d).join("adom-lbr.exe").is_file() || std::path::Path::new(d).join("adom-lbr").is_file())+ || std::env::var("LOCALAPPDATA").map(|la| std::path::Path::new(&la).join("Programs").join("adom-lbr").join("adom-lbr.exe").is_file() || std::path::Path::new(&la).join("adom-lbr").join("adom-lbr.exe").is_file()).unwrap_or(false);+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);+ let now_iso = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();+ let marker = std::path::Path::new(&std::env::var("TEMP").unwrap_or(home.clone())).join("adom-fusion").join("addin-start-failed.json");+ let addin_start_failed = std::fs::read_to_string(&marker).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|mut m| {+ m["markerPath"] = json!(marker.to_string_lossy());+ m["_hint"] = json!(format!("Fusion loaded a BROKEN copy of the add-in from {}. The bridge re-syncs every AddIns dir on start; restart Fusion with fusion_restart_for_addin {{save:true}} so it loads the repaired copy. If it keeps failing, delete that folder and restart Fusion: the bridge reinstalls it.", m["addinDir"].as_str().unwrap_or("")));+ m });+ let inputs = Inputs {+ installed_raw, streaming, running, addin_version: addin["version"].as_str().map(str::to_string).or(if addin_ok { Some("unknown".into()) } else { None }),+ expected_addin_version: expected, needs_signin: facts.needs_signin, licensing_dialog: facts.licensing_dialog.is_some(),+ updating: h.fusion_update_in_progress(), aps_configured, aps_signed_in: aps_signed, mcp_port_open: mcp,+ auto_update_enabled: prefs.get("autoUpdateFusion").and_then(|v| v.as_bool()).unwrap_or(true), roundtrip_ok, kicad_ingest,+ fusion_user: None, modeling_orientation: None, bridge_version: VERSION.into(), prefs, now, now_iso,+ 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+}
rust/core/src/ab.rs+11−1@@ -46,8 +46,18 @@ pub struct Loopback { impl Loopback { pub fn from_env() -> Option<Loopback> {- let base = std::env::var("ADOM_DIRECT_API_URL").ok()?.trim().trim_end_matches('/').to_string();+ let mut base = std::env::var("ADOM_DIRECT_API_URL").unwrap_or_default().trim().trim_end_matches('/').to_string();+ if base.is_empty() {+ // ab's fallback for a bridge spawned without the env var: ~/.adom/direct-api-port+ let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();+ if let Ok(port) = std::fs::read_to_string(std::path::Path::new(&home).join(".adom").join("direct-api-port")) {+ let port = port.trim();+ if !port.is_empty() { base = format!("http://127.0.0.1:{port}"); }+ }+ } if base.is_empty() { return None; }+ // ab exempts a bridge's own calls from its caller-identity gate through this token; it+ // is injected at spawn and is the reason a hand-launched exe reports degraded window facts. Some(Loopback { base, token: std::env::var("ADOM_BRIDGE_TOKEN").unwrap_or_default() }) } }
rust/core/src/host.rs+11@@ -39,6 +39,10 @@ pub trait HostProcess: Send + Sync { /// Whether the bridge runs elevated (drives the installer's --globalinstall decision). fn is_elevated(&self) -> bool; + /// Is any process with one of these image names running (case-insensitive)? The Fusion+ /// streamer / installer check on the readiness poll. In-process, like `fusion_processes`.+ fn image_running(&self, _names: &[&str]) -> bool { false }+ /// Tie a child (the Python sidecar during the port) to THIS process's lifetime, so a hard /// kill of the bridge by ab (every bridge_install, every supervisor respawn) takes the /// child with it instead of leaving an orphan on a random port. A Windows job object@@ -62,4 +66,11 @@ pub trait HostPaths: Send + Sync { fn bridge_state_dir(&self) -> PathBuf; /// A scratch dir for staged libraries, STEPs and screenshots. fn scratch_dir(&self) -> PathBuf;++ /// A fresh install is streaming: some webdeploy dir has the launcher but no complete+ /// Fusion360.exe exists anywhere yet (disk state, process-independent).+ fn fusion_install_streaming(&self) -> bool { false }+ /// Fusion is applying an auto-update: two or more launcher builds and one of them+ /// touched in the last ~20 minutes (stale leftover builds never trigger it).+ fn fusion_update_in_progress(&self) -> bool { false } }
rust/core/src/lib.rs+1@@ -12,6 +12,7 @@ pub mod host; pub mod alerts; pub mod aps; pub mod windows_facts;+pub mod readiness; pub mod ab; pub use host::{HostPaths, HostProcess};
rust/core/src/readiness.rsadded+243@@ -0,0 +1,243 @@+//! Phase 3: `fusion_readiness`, the launch observer, ported as a PURE computation over probed+//! inputs. The probes (Toolhelp, the add-in HTTP status, ab's window rows, APS, prefs, the+//! timings store) are gathered by the exe; this module turns them into the exact result the+//! Python handler produces, hint text included (`readiness_hints.json` is derived from the+//! Python goldens, never hand-typed). Side effects (seat auto-resolve, crash janitor, launch+//! self-heal, auto-update) are NOT here: they are returned as `Actions` for the caller to+//! run when it owns readiness, and the pref writes come back as `prefs_after`.+//!+//! AI hint: `tests/golden/readiness.json` was generated by running the Python handler with+//! every helper stubbed per scenario. A difference from it is a bug here until proven+//! otherwise; regenerate the goldens only when server.py's handler changes on purpose.++use serde_json::{json, Map, Value};++/// Python's `%r` of a str (single-quoted unless it holds a single quote and no double).+fn py_repr_str(s: &str) -> String { if s.contains('\'') && !s.contains('"') { format!("\"{s}\"") } else { format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) } }++const HINTS: &str = include_str!("readiness_hints.json");+fn hint(key: &str) -> String {+ let h: Value = serde_json::from_str(HINTS).expect("readiness_hints.json");+ h[key].as_str().unwrap_or("").to_string()+}++/// What the exe probed, the same facts the Python handler gathers itself.+#[derive(Debug, Clone, Default)]+pub struct Inputs {+ pub installed_raw: bool,+ /// installer process seen OR a launcher dir with no complete app anywhere+ pub streaming: bool,+ pub running: bool,+ /// the add-in's /status version when it answered, None when silent+ pub addin_version: Option<String>,+ pub expected_addin_version: String,+ pub needs_signin: bool,+ pub licensing_dialog: bool,+ pub updating: bool,+ pub aps_configured: bool,+ pub aps_signed_in: bool,+ pub mcp_port_open: bool,+ pub auto_update_enabled: bool,+ /// `get_app_state` round-trip through the add-in succeeded (only asked when ready)+ pub roundtrip_ok: bool,+ pub kicad_ingest: bool,+ pub fusion_user: Option<Value>,+ pub modeling_orientation: Option<String>,+ pub bridge_version: String,+ pub prefs: Map<String, Value>,+ pub now: f64,+ pub now_iso: String,+ /// `timings.summary()["launch"]` and `timings.stats("launch","cold")`+ pub timings_launch: Value,+ pub launch_stats: Value,+ /// A caller-supplied progress block for tests; production builds one from timings.+ pub progress_override: Option<Value>,+ pub addin_start_failed: Option<Value>,+ pub crash_dialogs_cleared: usize,+ /// The owner ran the silent update click before computing: the button it clicked.+ pub update_applied_button: Option<String>,+ /// Fusion is offering an update right now (the sidecar's `_LAST_UPDATE_OFFER`).+ pub update_offered: bool,+}++/// The side effects the Python handler would have taken, for the owner to run.+#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]+pub struct Actions {+ #[serde(rename = "resolveSeatDialog")] pub resolve_seat_dialog: bool,+ #[serde(rename = "applyUpdateSilently")] pub apply_update_silently: bool,+ #[serde(rename = "healLaunch")] pub heal_launch: Option<u64>,+ #[serde(rename = "recordLaunchSeconds")] pub record_launch_seconds: Option<f64>,+}++pub struct Outcome { pub result: Value, pub prefs_after: Map<String, Value>, pub actions: Actions }++fn pct(sorted: &[f64], q: f64) -> Option<f64> {+ if sorted.is_empty() { return None; }+ let i = (q * (sorted.len() as f64 - 1.0)).round_ties_even() as i64;+ Some(sorted[i.clamp(0, sorted.len() as i64 - 1) as usize])+}++/// `timings.summary()` over the store's JSON ({"phase|kind": {samples, lastAt}}).+pub fn timings_summary(store: &Value) -> Value {+ let mut out = Map::new();+ if let Some(m) = store.as_object() {+ for (k, row) in m {+ let (phase, kind) = match k.find('|') { Some(i) => (&k[..i], &k[i + 1..]), None => (k.as_str(), "") };+ let mut s: Vec<f64> = row["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();+ if s.is_empty() { continue; }+ s.sort_by(|a, b| a.partial_cmp(b).unwrap());+ let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));+ entry[kind] = json!({"p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "count": s.len(), "lastAt": row["lastAt"]});+ }+ }+ Value::Object(out)+}++/// `timings.stats(phase, kind)`.+pub fn timings_stats(store: &Value, phase: &str, kind: &str) -> Value {+ let mut s: Vec<f64> = store[format!("{phase}|{kind}")]["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();+ s.sort_by(|a, b| a.partial_cmp(b).unwrap());+ json!({"p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "count": s.len()})+}++/// `timings.progress_block` for the launch phase (percent derived from elapsed / estimate).+pub fn progress_block(stats: &Value, started: f64, now: f64, label: &str, fallback: f64) -> Value {+ let elapsed = (now - started).max(0.0);+ let (count, p50) = (stats["count"].as_u64().unwrap_or(0), stats["p50"].as_f64());+ let (est0, mut conf) = match (count, p50) { (c, Some(p)) if c >= 2 && p > 0.0 => (p, "measured"), (1, Some(p)) if p > 0.0 => (p, "typical"), _ => (fallback, "typical") };+ let mut est = est0;+ let overdue = est0 > 0.0 && elapsed > est0;+ if overdue {+ est = elapsed * 1.25;+ if conf == "measured" { conf = "typical"; }+ if elapsed > 2.0 * est0 { conf = "unknown"; }+ }+ let mut percent: i64 = if est0 <= 0.0 { 0 } else if !overdue { (80.0 * elapsed / est0) as i64 } else { let over = elapsed - est0; (80.0 + 15.0 * (over / (over + est0))) as i64 };+ percent = percent.clamp(0, if est > 0.0 { 99 } else { 0 });+ let eta = if est > 0.0 { Some(((est - elapsed).round() as i64).max(0)) } else { None };+ json!({"phase": "launch", "stepLabel": label, "percent": percent, "elapsedSec": elapsed.round() as i64,+ "estimatedSec": if est > 0.0 { json!(est.round() as i64) } else { Value::Null }, "etaSec": eta,+ "confidence": if est > 0.0 { conf } else { "unknown" }, "startKind": "cold", "overdue": overdue})+}++pub fn compute(i: &Inputs) -> Outcome {+ let mut prefs = i.prefs.clone();+ let mut actions = Actions::default();+ let (installed, installing) = if i.streaming && !i.running { (false, true) } else { (i.installed_raw, !i.installed_raw && i.streaming) };+ let running = if i.installed_raw { i.running } else { false };+ let addin_ok = i.addin_version.is_some();+ let needs_signin = running && i.needs_signin;+ let mut ready = installed && running && addin_ok && !needs_signin;+ let licensing = running && i.licensing_dialog;+ if licensing { actions.resolve_seat_dialog = true; ready = false; }+ let updating = if installed && !ready { i.updating } else { false };+ let mut stale = Map::new();+ if addin_ok {+ let reported = i.addin_version.clone().unwrap_or_default();+ let is_stale = reported != "unknown" && i.expected_addin_version != "unknown" && reported != i.expected_addin_version;+ stale.insert("addinVersion".into(), json!(reported));+ stale.insert("expectedAddinVersion".into(), json!(i.expected_addin_version));+ stale.insert("addinStale".into(), json!(is_stale));+ if is_stale { stale.insert("_staleHint".into(), json!(hint("stale_template").replace("{reported}", &reported).replace("{expected}", &i.expected_addin_version))); }+ }+ let mut hint_text = if updating && !ready { hint("updating") }+ else if needs_signin { hint("needs_signin") }+ else if ready && stale["addinStale"] == json!(true) { stale["_staleHint"].as_str().unwrap_or("").to_string() }+ else if ready {+ actions.apply_update_silently = true;+ let mut msg = if i.mcp_port_open { hint("mcp_on") } else { hint("mcp_off") };+ if let Some(b) = &i.update_applied_button { msg.push_str(&hint("upd_applied_template").replace("{button}", &py_repr_str(b))); }+ else if !i.auto_update_enabled { msg.push_str(&hint("upd_off")); }+ let base = if i.aps_signed_in { hint("ready_signed") } else if i.aps_configured { hint("ready_configured") } else { hint("ready_unconfigured") };+ base + &msg+ }+ else if installed && running && licensing { hint("licensing") }+ else if installed && running { hint("addin_silent") }+ else if installed { hint("installed_not_running") }+ else if installing { hint("installing") } else { hint("not_installed") };+ let mut note: Option<String> = None;+ if ready && !i.roundtrip_ok { ready = false; note = Some(hint("roundtrip_note")); }+ if let Some(n) = ¬e { hint_text = format!("{hint_text}{}{n}", hint("roundtrip_note_prefix")); }+ let orient_note = if i.modeling_orientation.as_deref() == Some("yup") {+ " ⚠ Default Modeling Orientation is Y-UP on this account, but Adom is a Z-UP environment (a fresh Autodesk account defaults to Y-up). Set it with fusion_set_preference {\"modelingOrientation\":\"zup\"} - no restart needed." } else { "" };+ let mut result = Map::new();+ result.insert("success".into(), json!(true));+ result.insert("hostApp".into(), json!("Fusion 360"));+ result.insert("installed".into(), json!(installed));+ result.insert("installing".into(), json!(installing));+ result.insert("running".into(), json!(running));+ result.insert("ready".into(), json!(ready));+ result.insert("kicadIngest".into(), json!(i.kicad_ingest));+ result.insert("fusionUser".into(), i.fusion_user.clone().unwrap_or(Value::Null));+ result.insert("modelingOrientation".into(), i.modeling_orientation.clone().map(Value::String).unwrap_or(Value::Null));+ result.insert("bridgeVersion".into(), json!(i.bridge_version));+ for (k, v) in &stale { result.insert(k.clone(), v.clone()); }+ result.insert("_hint".into(), json!(format!("{hint_text}{orient_note}")));+ // the launch observer+ result.insert("timings".into(), i.timings_launch.clone());+ let now = i.now;+ let mut lsa = prefs.get("launchStartedAt").and_then(|v| v.as_f64());+ if let Some(s) = lsa { if !ready && now - s > 900.0 { prefs.insert("launchStartedAt".into(), json!(now)); prefs.insert("launchHealedAt".into(), Value::Null); lsa = Some(now); } }+ if lsa.is_none() && running && !ready { prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now); }+ if !ready && running && i.crash_dialogs_cleared > 0 {+ result.insert("crashReportCleared".into(), json!(i.crash_dialogs_cleared));+ result.insert("fusionCrashed".into(), json!(true));+ prefs.insert("launchHealedAt".into(), Value::Null); prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now);+ }+ let healed = prefs.get("launchHealedAt").map(|v| !v.is_null()).unwrap_or(false);+ if !ready && running && !needs_signin && !licensing && !updating && lsa.is_some() && !healed {+ let p90 = i.launch_stats["p90"].as_f64().unwrap_or(0.0);+ let overdue_by = (2.0 * p90).max(180.0);+ if now - lsa.unwrap() > overdue_by {+ prefs.insert("launchHealedAt".into(), json!(now));+ result.insert("addinRestartTriggered".into(), json!(true));+ let attempt = prefs.get("launchHealAttempts").and_then(|v| v.as_u64()).unwrap_or(0) + 1;+ if attempt > 4 {+ result.insert("addinRestartExhausted".into(), json!(true));+ result.insert("_hint".into(), json!(format!("Fusion has been restarted {} times and the add-in still does not serve. This is no longer a slow launch: screenshot the Fusion hwnd and read screenshots[] for a blocking dialog, or check whether Fusion is crashing at startup (fusionCrashed / crash_report).", attempt - 1)));+ } else {+ prefs.insert("launchHealAttempts".into(), json!(attempt));+ result.insert("addinRestartAttempt".into(), json!(attempt));+ actions.heal_launch = Some(attempt);+ }+ }+ }+ if ready {+ prefs.insert("launchHealedAt".into(), Value::Null);+ prefs.insert("launchHealAttempts".into(), json!(0));+ if let Some(s) = lsa {+ let el = now - s;+ if (3.0..=3600.0).contains(&el) { actions.record_launch_seconds = Some(el); }+ prefs.insert("launchStartedAt".into(), Value::Null);+ }+ } else if let Some(s) = lsa {+ if now - s < 900.0 {+ let label = if needs_signin { "Signing in to Autodesk" } else if licensing { "Clearing the Autodesk seat dialog" } else if updating { "Applying a Fusion auto-update" } else if running { "Fusion is up - waiting for the Adom add-in to serve" } else { "Fusion is starting" };+ let mut pb = match &i.progress_override { Some(v) => { let mut v = v.clone(); v["stepLabel"] = json!(label); v }, None => progress_block(&i.launch_stats, s, now, label, 150.0) };+ if pb["overdue"] == json!(true) && pb["confidence"] == json!("unknown") {+ pb["stepLabel"] = json!(format!("{label} - this is longer than this machine has ever taken; restarting Fusion, since the add-in only loads at launch"));+ }+ result.insert("progress".into(), pb);+ result.insert("statusVerb".into(), json!("fusion_readiness"));+ }+ }+ if updating { result.insert("updating".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }+ if needs_signin { result.insert("needsSignin".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }+ if ready && running { prefs.insert("lastReadyAt".into(), json!(i.now_iso)); }+ if running && !needs_signin && ready { prefs.insert("lastSignedInAt".into(), json!(i.now_iso)); }+ result.insert("lastReadyAt".into(), prefs.get("lastReadyAt").cloned().unwrap_or(Value::Null));+ result.insert("lastSignedInAt".into(), prefs.get("lastSignedInAt").cloned().unwrap_or(Value::Null));+ if licensing { result.insert("licensingDialog".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }+ if i.update_offered {+ result.insert("fusionUpdateOffered".into(), json!(true));+ let handled = i.update_applied_button.is_some();+ result.insert("fusionUpdateHandled".into(), json!(handled));+ result.insert("fusionUpdateHint".into(), json!(if handled { hint("fusion_update_hint_handled") } else { hint("fusion_update_hint_pending_template").replace("{state}", if !i.auto_update_enabled { "opted out" } else { "pending" }) }));+ }+ if let Some(asf) = &i.addin_start_failed {+ result.insert("addinStartFailed".into(), asf.clone());+ let h = format!("{} {}", asf["_hint"].as_str().unwrap_or(""), result["_hint"].as_str().unwrap_or(""));+ result.insert("_hint".into(), json!(h));+ }+ Outcome { result: Value::Object(result), prefs_after: prefs, actions }+}
rust/core/src/windows_facts.rs+3−1@@ -28,6 +28,8 @@ pub struct WindowFacts { #[serde(rename = "licensingDialog")] pub licensing_dialog: Option<Value>, /// ab could not be asked (loopback missing or refused): facts unknown, not "none". pub degraded: bool,+ /// Why, when degraded (the ab error text), so a smoke test can tell 403 from absent.+ #[serde(skip_serializing_if = "Option::is_none")] pub error: Option<String>, } fn area(w: &Value) -> i64 { w["rect"]["width"].as_i64().unwrap_or(0) * w["rect"]["height"].as_i64().unwrap_or(0) }@@ -68,7 +70,7 @@ pub fn facts_from_ab(ab: &dyn AbClient, caller: &Caller) -> WindowFacts { let rows = v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned().unwrap_or_default(); facts_from_rows(&rows) }- Err(_) => WindowFacts { degraded: true, ..Default::default() },+ Err(e) => WindowFacts { degraded: true, error: Some(e.to_string()), ..Default::default() }, } }
rust/os-win/src/lib.rs+43@@ -118,6 +118,33 @@ fn find_fusion_launcher() -> Option<PathBuf> { None } +#[cfg(windows)]+fn snapshot_images() -> Vec<(u32, u32, String)> {+ use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};+ use windows_sys::Win32::System::Diagnostics::ToolHelp::{+ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,+ };+ let mut out = Vec::new();+ // SAFETY: same Toolhelp32 walk as snapshot_fusion, over a handle we own and close.+ unsafe {+ let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);+ if snap == INVALID_HANDLE_VALUE || snap.is_null() { return out; }+ let mut pe: PROCESSENTRY32W = std::mem::zeroed();+ pe.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;+ if Process32FirstW(snap, &mut pe) != 0 {+ loop {+ let len = pe.szExeFile.iter().position(|&c| c == 0).unwrap_or(pe.szExeFile.len());+ out.push((pe.th32ProcessID, pe.th32ParentProcessID, String::from_utf16_lossy(&pe.szExeFile[..len])));+ if Process32NextW(snap, &mut pe) == 0 { break; }+ }+ }+ CloseHandle(snap);+ }+ out+}+#[cfg(not(windows))]+fn snapshot_images() -> Vec<(u32, u32, String)> { Vec::new() }+ #[cfg(windows)] fn snapshot_fusion() -> Vec<FusionProcess> { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};@@ -178,6 +205,10 @@ fn start_shim(_exe: &Path) -> Result<u32, HostError> { impl HostProcess for WindowsHost { fn fusion_processes(&self) -> Vec<FusionProcess> { snapshot_fusion() } + fn image_running(&self, names: &[&str]) -> bool {+ snapshot_images().iter().any(|(_, _, img)| names.iter().any(|n| n.eq_ignore_ascii_case(img)))+ }+ fn launch_fusion(&self) -> Result<FusionProcess, HostError> { let exe = self.fusion_executable().ok_or_else(|| HostError::NotInstalled( "no webdeploy hash dir holds a complete Fusion360.exe".into()))?;@@ -273,6 +304,18 @@ impl HostPaths for WindowsHost { fn bridge_state_dir(&self) -> PathBuf { home().join(".adom").join("fusion-bridge") } + fn fusion_install_streaming(&self) -> bool {+ if any_app_complete() { return false; }+ webdeploy_bases().iter().any(|b| subdirs(b).iter().any(|s| s.join("FusionLauncher.exe").is_file()))+ }++ fn fusion_update_in_progress(&self) -> bool {+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+ let mut mtimes = Vec::new();+ for b in webdeploy_bases() { for s in subdirs(&b) { if s.join("FusionLauncher.exe").is_file() { mtimes.push(mtime_secs(&s)); } } }+ mtimes.len() >= 2 && mtimes.iter().any(|m| now.saturating_sub(*m) < 20 * 60)+ }+ fn scratch_dir(&self) -> PathBuf { env_dir("TEMP").unwrap_or_else(|| local_app_data().join("Temp")).join("adom-fusion") }
server.py+40−1@@ -5381,9 +5381,14 @@ def _handle_dismiss_blocking_dialogs(fusion_info: dict, args: dict) -> dict: guess, and never gets a stray Yes. """ classified = classify_blocking_dialogs()+ # `categories` narrows the sweep (the Rust readiness asks for ["crash_report"] only, the+ # janitor's exact scope); no filter keeps the full dismiss-everything behaviour.+ _want = set(a.strip() for a in (args.get("categories") or []) if str(a).strip())+ if _want:+ classified = [d for d in classified if d.get("category") in _want] if not classified: return {"success": True, "dismissed": [], "count": 0,- "_hint": "No blocking Fusion dialog is open."}+ "_hint": "No blocking Fusion dialog is open." if not _want else "No blocking dialog in the requested categories."} answered, upload_active = [], False for d in classified: hwnd, cat = d.get("hwnd"), d.get("category")@@ -14764,6 +14769,40 @@ def _handle_set_design_rules(fusion_info: dict, args: dict) -> dict: COMMAND_HANDLERS["set_design_rules"] = _handle_set_design_rules ++# ── Readiness executors (Phase 3 of the Rust port) ───────────────────────────────────────+# The Rust exe computes readiness itself and asks THIS process to perform the two side+# effects that still live here (UIA + screenshot verification against Fusion's dialogs).+def _handle_resolve_seat_dialog(fusion_info: dict, args: dict) -> dict:+ """Detect + resolve the seat/licensing modal in the background (max 3 clicks per call,+ screenshot-verified). Idempotent: nothing up -> {clicks: 0}."""+ try:+ r = _resolve_seat_dialog(max_clicks=int(args.get("maxClicks") or 3)) or {}+ except Exception as e:+ return {"success": False, "error": str(e)[:200]}+ still = False+ try:+ still = find_licensing_dialog() is not None+ except Exception:+ pass+ return {"success": True, "clicks": int(r.get("clicks", 0) or 0), "resolved": not still, "detail": r,+ "_hint": "Readiness executor: the seat dialog is clicked Continue (Suspend pre-selected) and verified gone by screenshot; poll fusion_readiness."}+++def _handle_apply_fusion_update(fusion_info: dict, args: dict) -> dict:+ """Click Fusion's update offer in the background when auto-update is on (never the+ exit-class buttons while a host op is active). Returns {applied, button, why}."""+ try:+ r = _apply_fusion_update_silently() or {}+ except Exception as e:+ return {"success": False, "error": str(e)[:200]}+ return {"success": True, **r, "offered": bool(_LAST_UPDATE_OFFER.get("offered")),+ "autoUpdateEnabled": _auto_update_enabled()}+++COMMAND_HANDLERS["resolve_seat_dialog"] = _handle_resolve_seat_dialog+COMMAND_HANDLERS["apply_fusion_update"] = _handle_apply_fusion_update+ def _handle_reset_workspace(args: dict) -> dict: """fusion_reset_workspace - bring Fusion back to a clean slate between runs (#59).
tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.sh