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 155 file(s)
46 files changed
+8731−274
BRIDGE_VERSION+1−1bridge.json+6−8contracts/describe.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1install.sh+1−1package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/PORTING.md+267rust/bridge/Cargo.toml+5rust/bridge/src/addin.rs+289rust/bridge/src/addin_install.rs+110rust/bridge/src/aps_signin.rs+217rust/bridge/src/chainexec.rs+201rust/bridge/src/chains.rs+606rust/bridge/src/demo.rs+488rust/bridge/src/epg.rs+110rust/bridge/src/etiquette.rs+322rust/bridge/src/fusion_prefs.rs+153rust/bridge/src/janitor.rs+132rust/bridge/src/launch.rs+329rust/bridge/src/lifecycle.rs+281rust/bridge/src/livefeed.rs+85rust/bridge/src/main.rs+193−11rust/bridge/src/maintenance.rs+188rust/bridge/src/managed.rs+386rust/bridge/src/mcp.rs+132rust/bridge/src/mint.rs+472rust/bridge/src/orchestrations.rs+867rust/bridge/src/prove.rs+268rust/bridge/src/readiness_exec.rs+170rust/bridge/src/services.rs+245rust/bridge/src/signin.rs+491rust/bridge/src/small_verbs.rs+464rust/bridge/src/testproject.rs+186rust/bridge/src/timings.rs+127rust/core/src/ab.rs+9−1rust/core/src/dialogs.rs+64rust/core/src/host.rs+28rust/core/src/lib.rs+1rust/lbr/src/lib.rs+17−1rust/os-win/Cargo.toml+1−1rust/os-win/src/lib.rs+247server.py+554−212tools/release.sh+2−6BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.356+1.9.357
bridge.json+6−8@@ -2,20 +2,20 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.356",+ "version": "1.9.357", "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.", "license": "MIT", "spawn": {- "kind": "python",- "entrypoint": "server.py",+ "kind": "exe",+ "entrypoint": "adom-fusion-bridge.exe", "port": 0, "healthEndpoint": "/status", "stopMethod": "kill",- "killImageName": "python.exe",+ "killImageName": "adom-fusion-bridge.exe", "persistent": true,- "_hint": "spawn.kind python is the PUBLIC-safe shape: ab provisions the interpreter. The zip also carries adom-fusion-bridge.exe (the Rust shell, Phase 1 of the port); the insiders tier flips to kind exe / entrypoint adom-fusion-bridge.exe once ab can provision Python for an exe bridge (adom-bridge issue #134). Both files are always in the payload, so a tier can flip without a rebuild."+ "_hint": "spawn.kind exe: one sha256-verified binary, no interpreter provisioning. The Python bridge left the package in Phase 4 of the Rust port (2026-09); an owner pref of \"sidecar\" in ~/.adom/fusion-bridge/prefs.json is honoured only where server.py still sits beside the exe." }, "verbPrefixes": [ "fusion_"@@ -79,9 +79,7 @@ "fusion_stop", "fusion_walk_cloud_tree" ],- "dependencies": {- "python": ">=3.10"- },+ "dependencies": {}, "category": "CAD", "tags": [ "fusion360",
contracts/describe.json+1−1@@ -1,6 +1,6 @@ { "contract": "fusion-bridge verb catalog",- "frozen": "2026-09-03",+ "frozen": "2026-09-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": [
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.356"+version = "1.9.357" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.356"+version = "1.9.357" 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.356"+DASH_VERSION="1.9.357" 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.356",+ "version": "1.9.357", "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.356",+ "version": "1.9.357", "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.356"+version = "1.9.357" edition = "2021" license = "MIT"
rust/PORTING.md+267@@ -84,6 +84,194 @@ cargo test -p fusion-core # the frozen catalog must parse and be effects (prefs, add-in auto-restart, seat-dialog auto-resolve, Fusion update handling). It ports in Phase 3 behind the drill, not as a Phase 2 leaf; the pieces it needs (Toolhelp poll, launcher path, log dir, alert parser, add-in probe) already exist natively.+- **The import chains run in the exe (2026-09-03, `rust/bridge/src/chains.rs`), behind the+ pref `chainsOwner: "exe"` in `~/.adom/fusion-bridge/prefs.json`, exactly like+ `readinessOwner`.** `fusion_create_library`, `fusion_install_library_bundle` and+ `fusion_prove_result` are orchestrated by the exe step for step from server.py: the same+ run store (`%TEMP%/fusion-prove-runs.json` + `fusion-prove-last.json`, so cancel and+ polling work from either side and `owner: "exe"` is the only tell), the same stage plan+ with `timings.json` estimates, the launch gate (`chain_addin_restart_info`, the+ `user_restart_hold.json` hold), the bind (`build_library_3d` on the sidecar, busy pre-wait,+ 40x3s main-thread-busy retries), the per-deviceset proof (`prove_library`, shared-package+ skips, one transient retry), the cloud publish and the closing hygiene. The leaves that+ still drive Fusion run on the sidecar through the `chain_*` executor verbs; the bundle+ unpack/pair/place/split is the lbr crate. Frames stream while a proof call is in flight:+ the exe passes `_frameSink` (a file path) and `_handle_prove_library` appends one JSON+ line per frame, which the exe tails every 1.5s and publishes to the run store, so a+ poller sees the ganged view arrive last exactly as before.+- **Lesson (cost one soak run): a `std::sync::Mutex` locked twice in ONE statement+ deadlocks the thread silently.** `Some(100.0 * (*m.lock().unwrap() + x))` followed by+ `Some(y - *m.lock().unwrap())` in the same call expression keeps the first guard alive as+ a temporary until the statement ends, so the second lock waits forever; no panic, no+ log line, the run just sits at its last saved label while the banner watchdog keeps+ re-sending. Progress counters a single chain thread owns are a `Cell`, and any guard+ whose value feeds a long call is cloned into a local and dropped before the call+ (`let last = m.lock().unwrap().clone();` then the HTTP call), never held through an+ `if let` body.+- **Phase 4 has started: the add-in pass-through is exe-owned behind `addinOwner: "exe"`+ (`rust/bridge/src/addin.rs`).** Every ADDIN_COMMANDS verb is sent to the add-in on :8774+ by the exe: the two busy gates (own long command, add-in /status busy), the per-command+ timeouts, assembly_bom's treatAsUnit defaults, the raw close_document upload gate, the+ stale-add-in enrichment, the API-error classifier and the background annotation are+ native and word-for-word. The Python etiquette that still drives Win32 stays on the+ sidecar as executors the exe calls around the HTTP hop: `chain_pre_verb` (document+ snapshot, foreground reason or the background envelope, dialog pre-dismiss for a walk),+ `chain_addin_gate` (the preamble: installed / running / auto-start / hang gate / add-in+ restart contract, extracted verbatim into `_addin_preamble`), `chain_sentinel` (the+ save-prompt sentinel around a doc-switching call), `chain_diagnose_addin_timeout`,+ `chain_dialog_array` (mutating verbs), `chain_verb_indicator` (taskbar bar / flash) and+ `chain_post_verb` (documents we opened, bridge docs, the surface shot, the chokepoint+ sweep, the etiquette note, the recent-actions ledger). The Python dispatcher calls the+ SAME extracted functions (`_window_etiquette_pre/_post`, `_addin_preamble`), so the two+ shells cannot drift. Porting those seven executors natively (Win32 envelope, UIA clicks+ through ab, window facts) is what stands between here and deleting the sidecar.+- **More native leaves (2026-09-03 evening):** `fusion_set_auto_update`, `fusion_set_seat_takeover`,+ `fusion_set_window_sizing`, `fusion_set_driving_badge` (pref toggles, always native),+ `fusion_addin_status`, `fusion_plan_library_import`, `fusion_notify_owner` (loopback+ notify_user with the CLI fallback), `fusion_describe` (the frozen contract), and behind+ `addinOwner:"exe"`: `fusion_get_preferences` / `fusion_set_preference` (the preference+ script is `rust/bridge/src/pref_script.py`, pinned byte-for-byte to server.py's+ `_PREF_SCRIPT` by a test), `fusion_reset_workspace`, `fusion_close_window` and+ `fusion_screenshot_fusion` (ab's desktop_close_window / desktop_screenshot_window, never+ our own Win32), `fusion_dismiss_blocking_dialogs` (the title classifier is+ `fusion_core::dialogs` with `dialog_rules.json` generated from `handlers/dialog_classify.py`+ and goldens in `core/tests/golden/dialog_titles.json`; the dialog rows come from ab's+ desktop_enum_windows, body text from desktop_find_controls). The desktop-shaped work goes+ through ab verbs on purpose (John's standing rule): the exe never grows its own EnumWindows.+- **Next native block, scoped 2026-09-03 late: the launch handler.** `_handle_launch`+ (server.py, ~340 lines) is what `chain_addin_gate`, `fusion_start`, the readiness+ self-heal and the timeout diagnosis's hang branch all lean on. Its pieces: detect (native+ already), the warm path (add-in probe, busy guard, the user restart hold, a 3x8s+ persistence gate, kill-then-relaunch), recovery-file relocation, `ensure_fusion_running`+ (the breakaway launch is native; the background envelope and the seat sweep are not),+ launch-dialog classification (native classifier now), `_resolve_seat_dialog` (UIA clicks+ through ab, capped at ten), `ensure_reasonable_window` (SetWindowPos through ab or a+ future ab verb), the add-in wait with the timings progress block, and the `launch|cold`+ and `launch|warm` samples (`timings::record` exists). Port it as `fusion_start` behind+ the same `addinOwner` pref, prove it through a real stop/start on ConfRoomROG, then+ `chain_addin_gate` collapses into it and the hang branch (`_confirmed_hung`) follows.+ After that the sidecar holds only the demo tour, the managed-library choreography, APS+ sign-in and the bind/prove leaves (`build_library_3d`, `prove_library`, `open_lbr`).+- **Native since the chain proof:** the timings store (`bridge/src/timings.rs`: rolling+ window, run log, verb estimate; stage samples now feed the estimate on BOTH shells, which+ Python had never done) and the taskbar indicator through ab's `desktop_taskbar`; the+ post-op dialog array and the failure-dialog guard for mutating verbs (classifier plus+ ab screenshots, no sidecar hop).+- **The etiquette layer is native (2026-09-03, late):** `bridge/src/etiquette.rs` carries+ the document registry (snapshot before, diff after, `bridgeDocs` in prefs), the foreground+ rule (reason plus the 3s heads-up through ab's notify_user, focus events to+ `focus-events.jsonl`) and the background envelope (`HostProcess::background_envelope`, the+ Win32 port in os-win: foreground-lock timeout, minimize animation off,+ LockSetForegroundWindow, the watcher that pushes a Fusion window back down unless the user+ brought it forward), the driving badge through desktop_taskbar, the save-prompt sentinel+ (Don't Save through desktop_ui_click for OUR documents only; an unattributed prompt is+ cancelled with WM_CLOSE only after two sightings with the main thread blocked), the+ chokepoint sweep (upload-close No, create-confirm No, launch-error OK) and the etiquette+ note. The three sidecar executors it replaced (`chain_pre_verb`, `chain_post_verb`,+ `chain_sentinel`) stay registered for the Python path and can be deleted when the sidecar+ goes. One difference to know: Python posted a WM_KEYDOWN Escape at an unattributed prompt;+ the exe sends WM_CLOSE through desktop_close_window, the same Cancel semantics without+ a PostMessage of our own. The surface shot for `capture:true` serves ab's own safe-size+ frame (at most 1400px) where Python resampled to 640px.+- **The launch handler is native (2026-09-04): `fusion_start` and the add-in preamble+ (`bridge/src/launch.rs`).** The warm path (busy guard, the user's restart hold, the 3x8s+ persistence gate, kill-then-relaunch), the breakaway launch plus the background envelope,+ the launch-dialog sweep (native classifier), the seat loop, window sizing through ab's+ `display` and `desktop_set_window_bounds` (restore:false, so nothing activates), the add-in+ wait with the measured progress block and the taskbar launch watch, and the `launch|cold`+ and `launch|warm` samples. `gate` is `_addin_preamble` word for word: auto-start, the hang+ branch, the restart contract. Proven on ConfRoomROG: warm start answered natively; after a+ stop, the cold start relaunched Fusion in the background and a gated verb answered once+ readiness flipped. Known shape, inherited from Python: a warm-but-silent add-in walks the+ persistence gate, the kill and the 50s launch loop in one call and overruns ab's 90s relay+ budget; the work completes, the caller sees `timed_out` and polls readiness. The sidecar+ still serves the Win32/UIA leaves under it: `chain_dismiss_recovery` (relocate plus the+ Recovered Documents dismissal), `chain_hung_check` (IsHungAppWindow plus the 20s probe),+ `chain_clean_incomplete_webdeploy`, `resolve_seat_dialog`, `chain_addin_restart_info` (the+ toast), `restart_for_addin`, `kill`/`stop`, and `chain_diagnose_addin_timeout`.+- **Lifecycle and the timeout diagnosis are native (2026-09-04):** `bridge/src/lifecycle.rs`+ carries `fusion_stop`, `fusion_kill` (ab's process_list / process_kill first, the hidden+ taskkill sweep as the degraded path), `fusion_restart_for_addin` (save through the+ pass-through, stop or kill, native start, the round-trip wait; the async form still runs+ on the sidecar's handler because a thread cannot borrow the request context) and+ `_diagnose_addin_timeout` (health and status probes, native classifier plus the seat modal+ by shape, screenshots of the main window and every dialog through ab, the hang verdict+ through `chain_hung_check`). Proven on ConfRoomROG: a native stop closed the document and+ the window, and the native cold start that followed came back ready inside one call.+ Sidecar executors still under the exe: `chain_hung_check`, `chain_dismiss_recovery`,+ `chain_clean_incomplete_webdeploy`, `resolve_seat_dialog`, `chain_addin_restart_info`.+- **Readiness executors and the first orchestration are native (2026-09-04):**+ `bridge/src/readiness_exec.rs` carries the seat resolver (UIA Continue / Check again+ through ab, verified by the parent's owned-popup count), the recovery relocation and+ dismissal (No and Close through UIA where Python grabbed the foreground for Tab+Enter),+ and the kill-grade hang verdict; `IsHungAppWindow` and `GetProcessTimes` are the two new+ audited Win32 reads in os-win, behind `HostProcess::window_hung` / `family_cpu_ticks`.+ `bridge/src/orchestrations.rs` carries `fusion_open_lbr` (preflight, the verified close of+ a same-named bridge doc, `Document.newDesignFromLocal` through the pass-through with the+ 8.3 short path from `HostPaths::short_path`, the slow-open state check, the symbol EDIT,+ the export verification, the part-metadata report, the post-open capture), proven on the+ Demo Board library. The remaining sidecar work is orchestrations (the show-surface+ family, make_test_project, capture_library_views, the bind and prove leaves, the cloud+ opens, board_stackup, generate_package, the GLB export), the demo tour and live feed,+ managed libraries, the sign-in family, install and uninstall, prefs_open/close,+ set_design_rules, fusion_state, apply_fusion_update, and the MCP proxy.+- **`fusion_show_symbol` / `fusion_show_footprint` are native (2026-09-04):** name resolution+ without asking Fusion (exact, case-insensitive, via the deviceset, difflib-style close+ match, stem prefix), the wrapper library for bare fragments (skeleton pinned to server.py's+ constants by a test), the active-library guard with reopen, the EDIT with the busy and+ Design-workspace retries, the Create-new prompt declined inside the call, WINDOW FIT and+ the background capture. Proven on the Demo Board library: a near-miss name resolved, a+ footprint shown from the remembered library, a missing name refused with `available`.+- **`fusion_show_library`, `fusion_show_project` (query and urn), the open-with-screenshot+ family (`show_schematic`, `show_2d_board`, `show_3d_board`, `open_schematic`, `open_board`,+ `import_electronics`) and `fusion_state` are native (2026-09-04).** The generated-test-project+ branch of show_project stays with the sidecar (its state is in-memory there). `fusion_state`+ reads the focus log from `focus-events.jsonl`, the timings store, ab's window rows, the+ native classifier and the seat-by-shape rule, the add-in status, the launch progress block,+ the driving badge the exe itself set, and captures through ab.+- **`fusion_capture_library_views` is native (2026-09-04):** name resolution up front, the+ build-in-flight guard (`builds_in_flight.json`, written by the sidecar's async build), EDIT+ per view with the background dialog sweep, WINDOW FIT, labeled captures copied to+ `C:/tmp/conduit-screenshots` (PNG through ab where Python without PIL wrote BMP), the+ workspace check that marks a frame `wrong_editor_captured`, and the 3D view through+ Create3DPackage / Package3DStop. **Ship discipline:** `tools/rust-ship.sh` is the gate+ before a commit or a deploy (both targets, tests, guards); a grep-filtered cargo pipeline+ once hid a type error and a broken commit was pushed.+- **The bind and prove leaves are native (2026-09-04): `bridge/src/mint.rs` and `prove.rs`.**+ `make_3d_package` sends the SAME Fusion-side script server.py builds (the template is+ derived from a Python-generated golden, `tests/golden/make3d_script.json`, and a test pins+ it byte for byte), through the pass-through; the workspace folder discovery and the APS+ upload fallback use the exe's APS client. `build_library_3d` loops the mint, injects every+ binding in one pass (lbr crate) and opens once; detached by default with progress in the+ shared run store and `builds_in_flight.json`. `bind_library_3d` is the single-part form.+ `prove_library` decides the verdict from the file (artifact names, connects), captures the+ four frames measured and workspace-stamped, counts the bodies in the bound f3d, checks the+ cloud thumbnail (`check_3d_thumbnail`, APS), and streams each frame to `_frameSink`. With+ `addinOwner: "exe"` the exe chain calls these natively instead of the sidecar's copies.+ **Field note:** the regression drill on 2026-09-04 stalled in bind three times running+ because Fusion on ConfRoomROG was applying an auto-update (readiness: `updating`) and+ restarted itself every minute; that is the environment, not the port.+- **More orchestrations native (2026-09-04, midday):** `capture_library_views`, `open_cloud_file`,+ `save_lbr`, `cleanup_cloud_files`, `inspect_bodies` (its script file pinned), `board_stackup`,+ the optimized-GLB export family (sync, fetch by jobId, the detached form polled by exportId),+ `set_design_rules` (the wiki rule packs, cached an hour), `apply_fusion_update` (the update+ offer found through ab's desktop_find_control, exit-class buttons only when verifiably idle),+ `install_fusion` (the official streamer, per-user when not elevated), `uninstall`, and+ `make_test_project` (the .sch/.brd generator, four Python-generated golden cases byte for+ byte). Two more discipline rules from today: `tools/rust-commit.sh` gates before it commits+ (a grep-filtered pipeline let two broken commits out), and every Fusion-side script the exe+ sends is derived from a golden the Python side generated, never retyped.+- **Drill three (2026-09-04, run through Fusion's auto-update):** verdict proven, 5 of 5, 20+ frames, even though the update storm killed the add-in during the bind of three parts;+ those parts proved on the bindings the fresh install carried over (`inject_package3d_bindings`+ merge). The chain's hygiene, the sidecar's auto-restart and the readiness `updating`+ detection all did their jobs; nothing in the port needed a hand.+- **Native prove, proven live (2026-09-04):** `fusion_prove_library` on the Demo Board's CH340G+ through the exe: proven, four frames at 1568x848, symbol and footprint and ganged stamped+ Electronics Library, the 3D frame stamped Design with one body counted, the scratch closed.+ The relay times the call out (a proof takes minutes) and the verdict is read back from the+ run store, exactly as with the Python bridge. `fusion_live_feed`, `fusion_aps_open`,+ `open_design` and the EAGLE import are native too. - **Member and permission verbs** (Data MCP parity) live on the Manufacturing Data Model v3 GraphQL API (`/mfg/v3/graphql/public`, mutations `addProjectMembers` / `addHubMembers`), which is metered past a subscription's included quota since@@ -193,3 +381,82 @@ in the roadmap's ab list); `desktop_wait_for_window` replaces the four polling l 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).++## 2026-09-04: the ALL-NATIVE drill is proven (run4)++First `fusion_create_library` drill with `readinessOwner`, `chainsOwner` AND `addinOwner` all `exe` on+ConfRoomROG (exe 2c352be38685): the chain, the bind leaf, the prove leaf and every add-in pass-through ran+in the exe; the sidecar served only the `chain_*` executors that have since been ported natively+(docs_open, close_docs_since, close_bridge_doc, sweep_orphans, publish_library, addin_restart_info).++| stage | seconds | result |+|---|---|---|+| bind | 692.8 | 5/5 packages bound (QFN-48, SOIC-16, LQFP-64, Crystal 3225, IN-S63BTG) |+| prove | 1909.4 | 5/5 proven, 20 evidence frames; CH340G re-proved after a timing miss (not a part fault) |++Verdict `proven`, no cloud publish requested. Ported in the same session, each behind+`tools/rust-commit.sh`: prefs_open/close, generate_package (EPG golden), mcp_*, library_manager ++managed_library_*, signin/signin_2fa/web_signin (+ janitor), chain executors, restart-choice watcher.+Lessons: (1) `cargo test | grep FAILED && exit 1 || true` under pipefail never exits on a red test -+the gate now runs cargo test bare; (2) a Python f-string's own escapes (backslash-newline) must be+decoded when a template is lifted from source; (3) `py_repr` must escape like CPython (newlines).++## 2026-09-04 (afternoon): the exe answers every verb; the sidecar is idle++Ported natively, each behind `tools/rust-commit.sh`: `prefs_open`/`prefs_close` (image-space clicks in+Fusion's Preferences through ab), `generate_package` (the EPG script lifted from the Python f-string with+its escapes decoded, pinned by `tests/golden/epg_script.json`), `mcp_status/tools/call/resources/enable`+(a plain JSON-RPC client of Autodesk's server on 27182), `library_manager` + `managed_library_create/+link/pull/push/unlink` (ribbon DIPs scaled by the window's real DPI and ClientToScreen, both new+`HostProcess` hooks in `os-win`; CEF dialogs answered by rect fraction; judged by Fusion's own alert log+with a watermark), `signin`/`signin_2fa`/`web_signin` (the OAuth URL read from Chrome/Edge History with+rusqlite, re-opened in the right profile, SSO driven over the extension, the walls toasted, a janitor+that closes the windows once the goal is met), `aps_signin` (PKCE with sha2/getrandom, a tiny_http+listener on the registered callback ports, the AUTH-001 probe, the remembered browser), the APS browser+prefs, `fusion_demo` (the staged first-run demo and the async tour with the desktop remote),+`demo_progress`/`demo_control`, the chain executors (`docs_open`, `close_docs_since`, `close_bridge_doc`,+`sweep_orphans`, `publish_library`, `addin_restart_info` with the restart-choice watcher), the webdeploy+cleanup, the auto add-in restart (posted to the exe's own `/command`, `launch::SELF_URL`), banners and+taskbar progress through a bare add-in hop (`addin::addin_http`), and the timeout diagnosis with no+sidecar fallback.++What still names the sidecar: `ExeChainHost::call`'s catch-all (every named verb is native), the+`ctx.sidecar` closure `call_addin` carries (unused on the exe-owned paths), and the readiness /+chains / add-in owner prefs, which default to the sidecar until Phase 4 flips them.++First native tour on ConfRoomROG (tour-f40cd85b): done in 248s, 7 views, the 3D chip bound (1 part),+the test project generated; the 2D/3D board switches failed because the generated project leaves its+SCHEMATIC document active - `show_2d_board` answers "Please open or create an electronics design".+Fixed by porting the test-project handle (`testProject` in prefs.json, `testproject::test_project_+activate`) and putting the board document in front before the board switches and the exports;+`show_project` without args now serves the generated project natively too.++## Phase 4, what is left, and the one command that flips the package (on John's word)++The exe now runs alone: every owner defaults to `exe`, the sidecar is spawned only when a pref says+`"sidecar"` (or `ADOM_FUSION_SIDECAR=1`), the add-in sync and the orphaned-run reconcile moved into+the exe's startup, and the native tour and the drill ran with no Python process on ConfRoomROG.+The PACKAGE still carries the Python bridge and `spawn.kind: python`, so a fresh install still+provisions an interpreter and starts server.py (which then owns nothing). `tools/exe-only-flip.sh`+makes the release exe-only (bridge.json spawn.kind exe, the Python files out of the zip, the+import checks out of release.sh); it edits the tree and commits nothing, so the diff is reviewed,+then `tools/release.sh` publishes insiders-first, and promotion to public is John's word.++## 2026-09-04 (late afternoon): the exe-only drill, no Python process on the box (run5)++Exe d038afe62530 with every owner defaulting to `exe` and no sidecar spawned (the only python.exe on+ConfRoomROG was the KiCad bridge's). `fusion_install_library_bundle` on the five-part drill bundle:++| stage | seconds | result |+|---|---|---|+| bind | 404.4 | 5/5 packages bound |+| prove | 1458.6 | 5/5 proven first pass, 20 evidence frames, no re-proves |++Verdict `proven`. Faster than run4 (693 s / 1909 s) with the same parts; the sidecar's absence cost+nothing. The native tour ran twice the same hour (7 views, then 9 after the board-document fix).++Landed after the drill started: the add-in sync and the orphaned-run reconcile at exe startup+(`addin_install.rs`), the janitor tick (`janitor.rs`: seat takeover, window sizing through ab with+two Win32 fact hooks, badge shedding, the bridge-doc Unsaved prompt, idle dirty bridge docs, stray+Create-new prompts, the crash reporter, the five-minute silent update), and the contract without+the 18 chain executor verbs (134). `tools/exe-only-flip.sh` holds the package flip for John's word.
rust/bridge/Cargo.toml+5@@ -10,7 +10,12 @@ name = "adom-fusion-bridge" path = "src/main.rs" [dependencies]+roxmltree = "0.20" regex = "1"+sha2 = "0.10"+getrandom = "0.2"+base64 = "0.22"+rusqlite = { version = "0.32", features = ["bundled"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } fusion-core = { path = "../core" } fusion-lbr = { path = "../lbr" }
rust/bridge/src/addin.rsadded+289@@ -0,0 +1,289 @@+//! Phase 4: the add-in pass-through, owned by the exe behind the pref `addinOwner: "exe"`.+//! Mirrors server.py's ADDIN_COMMANDS path: the preamble (installed / running / auto-start /+//! hang gate) is native in `launch.rs` (`gate`), leaning on the sidecar only for the hang+//! verdict, the recovery dismissal and the restart-info toast,+//! the two busy gates and the HTTP call to the add-in are native, a timeout is diagnosed by+//! `chain_diagnose_addin_timeout`, and a mutating verb carries the post-op dialog array (native:+//! `small_verbs::merge_dialog_array`, the classifier plus ab screenshots). The+//! taskbar indicator around the call is native (`timings::Indicator`, ab's desktop_taskbar).+//! The window etiquette (document registry, background envelope, save-prompt sentinel, the+//! post-verb sweep) is native in `etiquette.rs`; only the preamble and the timeout diagnosis+//! still run on the sidecar.++use serde_json::{json, Value};+use std::time::{Duration, Instant};+use std::sync::Mutex;++pub const ADDIN_COMMANDS: &[&str] = &[+ "activity_banner", "assembly_bom", "physical_properties", "get_app_state", "document_info", "activate_document", "import_step",+ "export_step", "export_stl", "export_3mf", "export_f3d", "export_fbx", "export_usdz", "export_dxf", "export_dwg", "export_iges", "export_obj", "export_sat", "export_skp",+ "get_design_info", "get_parameters", "set_parameter", "take_screenshot", "electron_run", "execute_text_command", "electron_zoom", "electron_pan", "electron_select",+ "open_electronics", "list_text_commands", "export_source", "export_eagle_source", "export_lbr", "close_document", "close_all_documents", "board_info", "open_by_urn",+ "run_modeling_script", "save_to_cloud", "list_cloud_projects", "list_cloud_files", "delete_cloud_file", "create_cloud_folder", "check_recovery", "search_cloud_files",+ "walk_cloud_tree", "export_cloud_file", "export_bom", "export_cpl", "export_gerbers", "export_board_image", "detect_layers",+];+/// `_WINDOW_RAISING` ∩ ADDIN_COMMANDS: these keep the sidecar's etiquette envelope.+pub const WINDOW_RAISING: &[&str] = &[+ "close_document", "close_window", "open_cloud_file", "open_by_urn", "aps_open", "open_design", "open_lbr", "open_schematic", "open_board", "import_electronics", "import_step",+ "new_electronics_from_eagle", "show_schematic", "show_2d_board", "show_3d_board", "run_modeling_script", "execute_text_command", "electron_run", "attach_3d_package",+ "make_3d_package", "build_library_3d", "capture_library_views", "generate_package", "export_optimized_glb", "board_stackup", "save_to_cloud", "start",+ "show_symbol", "show_footprint", "show_3d_package", "make_test_project",+];+const LONG_RUNNING: &[&str] = &["walk_cloud_tree", "search_cloud_files"];+const BUSY_EXEMPT: &[&str] = &["activity_banner"];+const MUTATING: &[&str] = &["run_modeling_script", "execute_text_command", "electron_run", "close_document", "close_all_documents", "import_step", "set_parameter", "save_to_cloud", "delete_cloud_file"];+const ASSEMBLY_BOM_UNIT_DEFAULTS: &[&str] = &["with fasteners", "with fastener", "with bearings", "bearing", "idler pulley", "pulley", "idler"];++fn addin_name(cmd: &str) -> &str { if cmd == "import_step" { "import_file" } else { cmd } }++pub fn timeout_secs(cmd: &str) -> u64 { timeout_secs_or(cmd, 30) }+/// `ADDIN_COMMAND_TIMEOUTS.get(cmd, default)`.+pub fn timeout_secs_or(cmd: &str, default: u64) -> u64 {+ match cmd {+ "show_symbol" | "show_footprint" => 180, "show_3d_package" | "make_test_project" => 300, "bind_library_3d" => 480, "show_library" | "show_project" => 240, "demo_progress" => 15,+ "assembly_bom" | "physical_properties" | "export_step" | "export_iges" | "export_sat" | "export_stl" | "export_3mf" | "export_usdz" | "export_obj" | "export_f3d" | "export_fbx" | "export_skp" => 300,+ "export_dxf" | "export_dwg" => 120, "export_gerbers" => 180, "export_bom" | "export_cpl" | "export_board_image" | "close_all_documents" => 60,+ "walk_cloud_tree" => 600, "search_cloud_files" => 180, "open_by_urn" => 240, "run_modeling_script" => 180, _ => default,+ }+}++/// Does the exe take this verb natively? Every add-in verb: the window-raising ones carry the+/// sidecar's etiquette through `chain_pre_verb` / `chain_post_verb` / `chain_sentinel`.+pub fn owns(cmd: &str) -> bool { ADDIN_COMMANDS.contains(&cmd) || OPEN_WITH_SCREENSHOT.contains(&cmd) }+/// `_OPEN_WITH_SCREENSHOT`: add-in opens the orchestration layer wraps with a capture.+pub const OPEN_WITH_SCREENSHOT: &[&str] = &["open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"];+const DOC_SWITCHING: &[&str] = &["open_lbr", "open_cloud_file", "open_by_urn", "open_schematic", "open_board", "show_schematic", "show_2d_board", "show_3d_board", "import_electronics", "execute_text_command", "electron_run", "new_electronics_from_eagle"];++/// The native timeout diagnosis needs the shell context; `call_addin` reaches it through the+/// same per-call registry the sentinel uses (the borrowed `Ctx` cannot cross into `call_addin`).+fn diagnose(cmd: &str, original: &Value, sidecar: &dyn Fn(&str, Value, u64) -> Value) -> Value {+ let reg = SENTINEL_CTX.lock().unwrap().clone();+ match reg {+ Some((sd, ca, pids)) => {+ let host = crate::host_for_diagnosis();+ let dlg = |r: Value| r;+ let ctx = Ctx { host: &*host, fusion_exe: host_paths_exe(), short_path: |p| crate::host().short_path(p), state_dir: sd, caller: ca, pids, sidecar, dialogs: &dlg };+ crate::lifecycle::diagnose_addin_timeout(&ctx, cmd, original)+ }+ None => {+ // no per-call registry (a bare probe): diagnose on a fresh context all the same+ let host = crate::host_for_diagnosis();+ let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx = Ctx { host: &*host, fusion_exe: host_paths_exe(), short_path: |p| crate::host().short_path(p), state_dir: crate::host().bridge_state_dir(), caller: fusion_core::ab::Caller::default(), pids, sidecar, dialogs: &dlg };+ crate::lifecycle::diagnose_addin_timeout(&ctx, cmd, original)+ }+ }+}+fn host_paths_exe() -> Option<std::path::PathBuf> { crate::host_for_diagnosis_paths() }++/// What a sentinel needs (set per pass-through call, read by call_addin's doc-switching path).+static SENTINEL_CTX: Mutex<Option<(std::path::PathBuf, fusion_core::ab::Caller, Vec<u32>)>> = Mutex::new(None);++/// The exe's own long-command marker (`_long_command`): a walk or search in flight.+static LONG_COMMAND: Mutex<Option<(String, Instant)>> = Mutex::new(None);++fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++pub fn addin_status_probe(timeout: Duration) -> Option<Value> {+ ureq::get("http://127.0.0.1:8774/status").timeout(timeout).call().ok().and_then(|r| r.into_json().ok())+}++/// The two busy gates, in server.py's order: our own long command, then the add-in's /status.+fn busy_gate(cmd: &str) -> Option<Value> {+ if LONG_RUNNING.contains(&cmd) || BUSY_EXEMPT.contains(&cmd) { return None; }+ let busy = LONG_COMMAND.lock().unwrap().clone();+ if let Some((bc, at)) = busy {+ let st = addin_status_probe(Duration::from_secs(3));+ let progress = st.as_ref().and_then(|s| s.get("walkProgress").cloned()).filter(|p| !p.is_null());+ let elapsed = (at.elapsed().as_secs_f64() * 10.0).round() / 10.0;+ let pct = progress.as_ref().and_then(|p| { let v = p["foldersVisited"].as_f64()?; let q = p["queueSize"].as_f64()?; let t = v + q; if v > 0.0 && t > 0.0 { Some((100.0 * v / t).round() as i64) } else { None } });+ return Some(json!({"success": false,+ "error": format!("Fusion main thread busy — {bc} has been running for {elapsed}s. Your command '{cmd}' cannot execute until it finishes."),+ "errorCode": "main_thread_busy", "busyCommand": bc, "elapsedSeconds": elapsed, "progress": progress,+ "_hint": format!("A cloud search ({bc}) is in progress{}, running for {elapsed}s. Do NOT retry add-in commands (get_app_state, document_info, etc.) — they will all be rejected until the search finishes. Commands that still work right now: fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, fusion_send_key, fusion_close_window. Wait for the search to complete, then retry.", pct.map(|p| format!(" (~{p}% done)")).unwrap_or_default())}));+ }+ if let Some(st) = addin_status_probe(Duration::from_secs(3)) {+ if st["busy"].as_bool() == Some(true) {+ let elapsed = st.get("elapsedSeconds").cloned().unwrap_or(json!(0));+ let bc = st["busyCommand"].as_str().unwrap_or("unknown").to_string();+ let mut resp = json!({"success": false,+ "error": format!("Fusion main thread busy — {bc} running for {elapsed}s (from another bridge/session). Your command '{cmd}' cannot execute until it finishes."),+ "errorCode": "main_thread_busy", "busyCommand": bc, "elapsedSeconds": elapsed,+ "_hint": format!("A long-running command ({bc}) is in progress from another session. Do NOT retry add-in commands — they will all be rejected until it finishes. Do NOT press Escape — the add-in is working, not stuck on a dialog. Commands that still work: fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, fusion_send_key, fusion_close_window.")});+ if let Some(w) = st.get("walkProgress").filter(|w| !w.is_null()) { resp["progress"] = w.clone(); }+ return Some(resp);+ }+ }+ None+}++/// `_API_ERROR_PATTERNS`: a raw Fusion API exception gets a stable errorCode and remedy.+fn classify_api_error(mut r: Value) -> Value {+ if !r.is_object() || r["success"].as_bool() == Some(true) || !r["errorCode"].is_null() { return r; }+ let tb = r["data"]["traceback"].as_str().unwrap_or("").to_string();+ let blob = format!("{} {} {}", r["error"].as_str().unwrap_or(""), r["output"].as_str().unwrap_or(""), tb).to_lowercase();+ let pats: [(&str, &str, &str); 3] = [+ ("part design documents can only contain one component", "part_template_single_component", "This document came from Fusion's PART template, which permits exactly ONE component, so occurrences cannot be added. Documents created via documents.add(FusionDesignDocumentType) ARE assembly-capable. Either model with BODIES inside the single component, or create a new document through the API and build there. Fusion's API and UI defaults differ here, which is why this is surprising."),+ ("root component name cannot be changed", "root_rename_unsupported", "The root component always takes the DOCUMENT's name and cannot be renamed directly. Rename the document on save instead, or rename a sub-occurrence."),+ ("refers to a deleted object", "stale_api_handle", "A handle you are holding points at an object that was closed or deleted. Re-fetch it from the CURRENT app.activeDocument / activeProduct instead of reusing one from an earlier call."),+ ];+ for (needle, code, hint) in pats { if blob.contains(needle) { r["errorCode"] = json!(code); r["_hint"] = json!(hint); break; } }+ r+}++/// `_proxy_to_addin` minus the save-prompt sentinel (window-raising verbs are not taken here).+/// A bare add-in hop (no etiquette, no gate, no diagnosis): banners and probes.+pub fn addin_http(cmd: &str, args: &Value, timeout: u64) -> Value {+ let body = json!({"command": addin_name(cmd), "args": args});+ match ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(timeout)).set("Content-Type", "application/json").send_string(&body.to_string()) {+ Ok(r) => r.into_json::<Value>().unwrap_or(json!({"success": false, "error": "Add-in request failed: non-JSON answer"})),+ Err(ureq::Error::Status(_, r)) => r.into_json::<Value>().unwrap_or(json!({"success": false, "error": "Add-in request failed: non-JSON answer"})),+ Err(e) => json!({"success": false, "error": format!("Add-in request failed: {e}")}),+ }+}+fn call_addin(cmd: &str, args: &Value, timeout: u64, sidecar: &dyn Fn(&str, Value, u64) -> Value) -> Value {+ let mut args = args.clone();+ if cmd == "assembly_bom" && args.get("treatAsUnit").map(|v| v.is_null() || v.as_array().map(|a| a.is_empty()).unwrap_or(false) || v == &json!("")).unwrap_or(true) {+ args["treatAsUnit"] = json!(ASSEMBLY_BOM_UNIT_DEFAULTS);+ }+ let body = json!({"command": addin_name(cmd), "args": args});+ let sentinel = if DOC_SWITCHING.contains(&cmd) { SENTINEL_CTX.lock().unwrap().clone().map(|(sd, ca, pids)| crate::etiquette::Sentinel::start(sd, ca, pids, timeout as f64 + 10.0)) } else { None };+ let sent = ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(timeout)).set("Content-Type", "application/json").send_string(&body.to_string());+ if let Some(s) = &sentinel { s.soft_stop(12.0); }+ let result: Value = match sent {+ Ok(resp) => resp.into_json::<Value>().unwrap_or(json!({"success": false, "error": "Add-in request failed: non-JSON answer"})),+ Err(ureq::Error::Status(_, resp)) => resp.into_json::<Value>().unwrap_or(json!({"success": false, "error": "Add-in request failed: non-JSON answer"})),+ Err(e) => {+ let s = e.to_string();+ let sl = s.to_lowercase();+ if sl.contains("connection refused") || sl.contains("no connection") || sl.contains("actively refused") {+ return json!({"success": false, "error": "Fusion 360 AdomBridge add-in not running. Install it with: python plugins/fusion360/install_addin.py, then restart Fusion 360."});+ }+ if sl.contains("timed out") || sl.contains("timeout") {+ return diagnose(cmd, &json!({"error": s}), sidecar);+ }+ return json!({"success": false, "error": format!("Add-in request failed: {s}")});+ }+ };+ if result["success"].as_bool() != Some(true) && result["error"].as_str().unwrap_or("").to_lowercase().contains("timed out") {+ return diagnose(cmd, &result, sidecar);+ }+ let mut result = result;+ if result["success"].as_bool() != Some(true) {+ let err = result["error"].as_str().unwrap_or("").to_lowercase();+ if ["unknown command", "no such command", "not supported", "unsupported command"].iter().any(|s| err.contains(s)) {+ let ver = addin_status_probe(Duration::from_secs(1)).and_then(|s| s["version"].as_str().map(str::to_string));+ let stale = crate::small_verbs::addin_staleness(ver.as_deref());+ if stale["addinStale"].as_bool() == Some(true) {+ result["errorCode"] = json!("addin_stale"); result["_hint"] = stale["_staleHint"].clone();+ for k in ["addinVersion", "expectedAddinVersion", "addinStale"] { result[k] = stale[k].clone(); }+ }+ }+ }+ classify_api_error(result)+}++/// Everything the pass-through needs from the shell.+pub struct Ctx<'a> {+ pub host: &'a dyn fusion_core::HostProcess,+ /// `HostPaths::fusion_executable()` at call time (None = not installed)+ pub fusion_exe: Option<std::path::PathBuf>,+ /// `HostPaths::short_path` (the 8.3 form Fusion's text commands need)+ pub short_path: fn(&str) -> String,+ pub state_dir: std::path::PathBuf,+ pub caller: fusion_core::ab::Caller,+ pub pids: Vec<u32>,+ /// the sidecar executors (`chain_addin_gate`, `chain_diagnose_addin_timeout`)+ pub sidecar: &'a dyn Fn(&str, Value, u64) -> Value,+ /// the post-op dialog array for a mutating verb+ pub dialogs: &'a dyn Fn(Value) -> Value,+}++impl<'a> Ctx<'a> { pub fn host_paths_short(&self, p: &str) -> String { (self.short_path)(p) } }++/// The whole pass-through for one verb.+pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value {+ let sidecar = ctx.sidecar;+ let dialogs = ctx.dialogs;+ let t0 = Instant::now();+ let since = now_f();+ let window_raising = WINDOW_RAISING.contains(&cmd);+ *SENTINEL_CTX.lock().unwrap() = Some((ctx.state_dir.clone(), ctx.caller.clone(), ctx.pids.clone()));+ // a direct add-in hop for the etiquette's own probes (document_info, get_app_state)+ let direct = |verb: &str, a: Value, t: u64| call_addin(verb, &a, t, sidecar);+ let pre = crate::etiquette::pre(ctx.host, &ctx.state_dir, &ctx.caller, cmd, args, &ctx.pids, &direct);+ let args_owned: Value = pre.args.clone();+ let args = &args_owned;+ let ran_bg = pre.ran_backgrounded;+ let res = (|| {+ if let Some(r) = &pre.refusal { return r.clone(); }+ if let Some(refusal) = crate::launch::gate(ctx, cmd, args) { return refusal; }+ if let Some(b) = busy_gate(cmd) { return b; }+ // raw close_document gate: never close over an in-flight Hub upload (John, 2026-08-21)+ if cmd == "close_document" {+ let deadline = Instant::now() + Duration::from_secs(45);+ let mut drained = false;+ while Instant::now() < deadline {+ let jr = call_addin("run_modeling_script", &json!({"script": "import adsk.core\nresult={'jobs': adsk.core.Application.get().hasActiveJobs}"}), 10, sidecar);+ if jr["data"]["result"]["jobs"].as_bool() != Some(true) { drained = true; break; }+ std::thread::sleep(Duration::from_secs(5));+ }+ if !drained {+ return json!({"success": false, "errorCode": "upload_in_flight_close_deferred",+ "error": "Fusion is still uploading packages to the Hub - closing now would raise a lose-changes confirm and risk destroying the upload. The close was NOT issued.",+ "_hint": "Re-call fusion_close_document in ~30s; the bridge closes the moment app.hasActiveJobs drains. Nothing was lost and no dialog was raised."});+ }+ }+ let timeout = timeout_secs(cmd);+ if LONG_RUNNING.contains(&cmd) {+ *LONG_COMMAND.lock().unwrap() = Some((cmd.to_string(), Instant::now()));+ let r = call_addin(cmd, args, timeout, sidecar);+ *LONG_COMMAND.lock().unwrap() = None;+ return r;+ }+ let r = call_addin(cmd, args, timeout, sidecar);+ if MUTATING.contains(&cmd) { return dialogs(r); }+ r+ })();+ let mut res = res;+ if ran_bg && res.is_object() {+ if res.get("windowEtiquette").is_none() { res["windowEtiquette"] = json!("background"); }+ let note = "Window opened/changed in the BACKGROUND - the user's focus was NOT touched; do NOT foreground anything to compensate. Render results via fusion_state. If the USER asks to watch, re-call with {\"foreground\": true, \"foregroundReason\": \"<why, shown to the user as a 3s caption>\"}.";+ let h = res["_hint"].as_str().map(str::to_string);+ res["_hint"] = json!(match h { Some(h) if !h.contains(note) => format!("{h} {note}"), Some(h) => h, None => note.to_string() });+ }+ let _ = window_raising;+ let res = crate::etiquette::post(&ctx.state_dir, &ctx.caller, cmd, args, res, &pre, since, &ctx.pids, &direct);+ let _ = t0;+ res+}++pub fn long_command_active() -> Option<Value> {+ LONG_COMMAND.lock().unwrap().clone().map(|(c, at)| json!({"command": c, "startedAt": now_f() - at.elapsed().as_secs_f64()}))+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn api_error_classifier_matches_python_table() {+ let r = classify_api_error(json!({"success": false, "error": "3 : Part Design documents can only contain one component"}));+ assert_eq!(r["errorCode"], "part_template_single_component");+ let r = classify_api_error(json!({"success": false, "error": "x", "data": {"traceback": "RuntimeError: refers to a deleted object"}}));+ assert_eq!(r["errorCode"], "stale_api_handle");+ // an errorCode already present is never overwritten; a success is untouched+ let r = classify_api_error(json!({"success": false, "errorCode": "mine", "error": "refers to a deleted object"}));+ assert_eq!(r["errorCode"], "mine");+ let r = classify_api_error(json!({"success": true, "error": "refers to a deleted object"}));+ assert!(r.get("errorCode").is_none());+ }+ #[test]+ fn timeouts_and_ownership() {+ assert_eq!(timeout_secs("walk_cloud_tree"), 600);+ assert_eq!(timeout_secs("get_app_state"), 30);+ assert_eq!(addin_name("import_step"), "import_file");+ assert!(owns("get_app_state") && owns("open_by_urn") && !owns("open_lbr") && !owns("readiness"));+ assert!(WINDOW_RAISING.contains(&"save_to_cloud") && !WINDOW_RAISING.contains(&"document_info"));+ }+}
rust/bridge/src/addin_install.rsadded+110@@ -0,0 +1,110 @@+//! What the Python bridge did at startup and the exe must do alone: sync the bundled+//! AdomBridge add-in into EVERY Fusion add-in location (Fusion moved the scan dir across+//! versions and ignores the others), refusing a payload whose relative imports do not resolve+//! inside it (issue #69: half a sync bricked the add-in), and fail the runs a previous bridge+//! process left mid-flight (a respawn is the only way a chain thread vanishes).+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++fn source_dir() -> Option<PathBuf> { std::env::current_exe().ok()?.parent().map(|d| d.join("addin").join("AdomBridge")) }+fn py_files(root: &Path) -> Vec<PathBuf> {+ let mut out = Vec::new();+ fn walk(d: &Path, out: &mut Vec<PathBuf>) { if let Ok(rd) = std::fs::read_dir(d) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { walk(&p, out); } else if p.extension().map(|x| x == "py").unwrap_or(false) { out.push(p); } } } }+ walk(root, &mut out); out+}+/// `_check_relative_imports`: "file imports .mod" problems; empty means the payload is whole.+pub fn check_relative_imports(root: &Path) -> Vec<String> {+ let files = py_files(root);+ let have: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();+ let have_dirs: std::collections::HashSet<PathBuf> = files.iter().filter_map(|p| p.parent().map(|d| d.to_path_buf())).collect();+ let re = regex::Regex::new(r"(?m)^\s*from\s+(\.+)([\w.]*)\s+import").unwrap();+ let mut problems = Vec::new();+ for f in &files {+ let Ok(txt) = std::fs::read_to_string(f) else { continue };+ for m in re.captures_iter(&txt) {+ let (dots, module) = (&m[1], &m[2]);+ if module.is_empty() { continue; }+ let mut base = f.parent().map(|d| d.to_path_buf()).unwrap_or_default();+ for _ in 0..dots.len() - 1 { base = base.parent().map(|d| d.to_path_buf()).unwrap_or_default(); }+ let mut cand = base.clone(); for part in module.split('.') { cand = cand.join(part); }+ if !(have.contains(&cand.with_extension("py")) || have.contains(&cand.join("__init__.py")) || have_dirs.contains(&cand)) {+ problems.push(format!("{} imports {}{}", f.strip_prefix(root).unwrap_or(f).to_string_lossy().replace('\\', "/"), dots, module));+ }+ }+ }+ problems+}+/// `_sync_directory`: copy new or changed files, remove what the source no longer has (never+/// `__pycache__`), keep the target dir itself so Fusion's run-on-startup metadata survives.+fn sync_directory(source: &Path, target: &Path, log: &mut Vec<String>) -> std::io::Result<()> {+ let mut keep: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();+ fn walk(d: &Path, out: &mut Vec<PathBuf>) { if let Ok(rd) = std::fs::read_dir(d) { for e in rd.flatten() { let p = e.path(); out.push(p.clone()); if p.is_dir() { walk(&p, out); } } } }+ let mut src_all = Vec::new(); walk(source, &mut src_all);+ for src in src_all {+ let rel = src.strip_prefix(source).unwrap().to_path_buf(); keep.insert(rel.clone());+ let dst = target.join(&rel);+ if src.is_dir() { std::fs::create_dir_all(&dst)?; }+ else { if let Some(p) = dst.parent() { std::fs::create_dir_all(p)?; } let same = std::fs::read(&src).ok().zip(std::fs::read(&dst).ok()).map(|(a, b)| a == b).unwrap_or(false); if !same { std::fs::copy(&src, &dst)?; log.push(format!("Updated: {}", rel.to_string_lossy().replace('\\', "/"))); } }+ }+ let mut dst_all = Vec::new(); walk(target, &mut dst_all); dst_all.sort(); dst_all.reverse();+ for dst in dst_all {+ let rel = dst.strip_prefix(target).unwrap().to_path_buf();+ if rel.to_string_lossy().contains("__pycache__") || keep.contains(&rel) { continue; }+ if dst.is_file() { let _ = std::fs::remove_file(&dst); log.push(format!("Removed: {}", rel.to_string_lossy().replace('\\', "/"))); }+ else if dst.is_dir() && std::fs::read_dir(&dst).map(|mut r| r.next().is_none()).unwrap_or(false) { let _ = std::fs::remove_dir(&dst); }+ }+ Ok(())+}+fn copy_tree(source: &Path, target: &Path) -> std::io::Result<()> {+ std::fs::create_dir_all(target)?;+ for e in std::fs::read_dir(source)?.flatten() { let p = e.path(); let t = target.join(e.file_name()); if p.is_dir() { copy_tree(&p, &t)?; } else { std::fs::copy(&p, &t)?; } }+ Ok(())+}+/// `install_addin.install`: the add-in into every candidate dir; `{installedTo, refused, log}`.+pub fn install(addin_dirs: &[PathBuf]) -> Value {+ let Some(src) = source_dir().filter(|s| s.is_dir()) else { return json!({"installedTo": [], "refused": "Source add-in not found beside the exe", "log": []}) };+ let bad = check_relative_imports(&src);+ if !bad.is_empty() { return json!({"installedTo": [], "refused": format!("payload has unresolved relative imports - installing it would brick the add-in (issue #69): {}", bad.join("; ")), "log": []}); }+ let mut log = Vec::new(); let mut installed: Vec<String> = Vec::new();+ for dir in addin_dirs {+ let target = dir.join("AdomBridge");+ let r: std::io::Result<()> = (|| { std::fs::create_dir_all(dir)?; if target.exists() { sync_directory(&src, &target, &mut log)?; log.push(format!("Updated: {}", target.display())); } else { copy_tree(&src, &target)?; log.push(format!("Installed: {}", target.display())); } Ok(()) })();+ if let Err(e) = r { log.push(format!("skip {}: {e}", target.display())); continue; }+ let mut on_disk = check_relative_imports(&target);+ if !on_disk.is_empty() {+ log.push(format!("REPAIRING {}: copy on disk is incomplete ({})", target.display(), on_disk.join("; ").chars().take(200).collect::<String>()));+ let _ = std::fs::remove_dir_all(&target); let _ = copy_tree(&src, &target);+ on_disk = check_relative_imports(&target);+ if !on_disk.is_empty() { log.push(format!("STILL BROKEN after repair: {}: {:?}", target.display(), on_disk)); continue; }+ }+ installed.push(target.to_string_lossy().replace('\\', "/"));+ }+ json!({"installedTo": installed, "refused": Value::Null, "log": log})+}+/// `_reconcile_orphaned_runs`: fail every run the previous bridge process left mid-flight.+pub fn reconcile_orphaned_runs() -> usize {+ let mut runs = crate::chains::read_runs();+ let mut orphaned = 0;+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);+ for (_, st) in runs.iter_mut() {+ if !matches!(st["stage"].as_str(), None | Some("done") | Some("failed")) {+ let stage = st["stage"].as_str().unwrap_or("").to_string();+ st["stage"] = json!("failed"); st["success"] = json!(false); st["verdict"] = json!("not_proven"); st["errorCode"] = json!("bridge_restarted_mid_run");+ st["error"] = json!(format!("The bridge process restarted (a bridge_install or crash) while this run was at '{stage}', which killed the run. Nothing after that stage happened. Start the import again."));+ st["orphanedAt"] = json!(now); orphaned += 1;+ }+ }+ if orphaned > 0 { crate::chains::write_runs_pub(&runs); if let Some(last) = runs.values().last() { let _ = std::fs::write(std::env::temp_dir().join("fusion-prove-last.json"), last.to_string()); } }+ orphaned+}+/// Everything the Python bridge did at startup, once, before the exe serves.+pub fn startup(state_dir: &Path, addin_dirs: &[PathBuf], fusion_installed: bool) {+ let orphaned = reconcile_orphaned_runs();+ if orphaned > 0 { eprintln!("[fusion-bridge] {orphaned} run(s) were mid-flight when the previous bridge died - marked failed (bridge_restarted_mid_run)"); }+ crate::fusion_prefs::action_ledger(state_dir, "bridge_started", json!({"orphanedRunsFailed": orphaned, "shell": "rust"}));+ if fusion_installed || addin_dirs.iter().any(|d| d.exists()) {+ let r = install(addin_dirs);+ match r["refused"].as_str() { Some(why) => eprintln!("[fusion-bridge] add-in sync REFUSED: {why}"), None => eprintln!("[fusion-bridge] add-in synced into {} location(s)", r["installedTo"].as_array().map(|a| a.len()).unwrap_or(0)) }+ for l in r["log"].as_array().cloned().unwrap_or_default() { if let Some(s) = l.as_str() { if s.starts_with("Updated: ") || s.starts_with("Removed") || s.starts_with("REPAIR") || s.starts_with("skip") { eprintln!("[fusion-bridge] {s}"); } } }+ }+}
rust/bridge/src/aps_signin.rsadded+217@@ -0,0 +1,217 @@+//! `fusion_aps_signin`, `fusion_aps_set_client_id`, `fusion_aps_set/get/forget_browser`: the+//! bridge's own 3-legged PKCE OAuth against APS (public client, no secret). A localhost+//! listener on one of the registered callback ports captures the code and exchanges it.+use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};+use fusion_core::aps::{ApsStore, APS_BASE, REDIRECT_PATH, REDIRECT_PORTS, SCOPES, TOKEN_URL, all_registered_callbacks};+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::Duration;++const ABE_UPSELL: &str = "OPTIONAL, offer it: installing the Adom browser extension lets me DRIVE this sign-in instead of asking the user to click (fill, read the result, screenshot, clean up). ABE can install it for them automatically - it is just slow, roughly a 5-minute AI-driven process. See the 'installing-the-extension' flow. Everything here works WITHOUT it; the extension only buys automation.";++/// The sign-in in flight: {verifier, state, redirectUri, done, error, startedAt}.+pub static SIGNIN_STATE: Mutex<Option<Value>> = Mutex::new(None);+pub fn signin_pending() -> bool { SIGNIN_STATE.lock().unwrap().as_ref().map(|s| s["done"].as_bool() != Some(true) && now() - s["startedAt"].as_f64().unwrap_or(0.0) < 180.0).unwrap_or(false) }+fn now() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }+fn redirect_uri(port: u16) -> String { format!("http://localhost:{port}{REDIRECT_PATH}") }+fn ab_call(ctx: &Ctx, verb: &str, args: Value, timeout: u64) -> Value {+ match Loopback::from_env() { Some(ab) => match ab.call(verb, args, &ctx.caller, timeout) { Ok(v) => v, Err(e) => json!({"_adCallError": format!("{e:?}")}) }, None => json!({"_adCallError": "ab loopback unavailable"}) }+}+fn b64url(b: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b) }+fn pkce_pair() -> (String, String) {+ use sha2::Digest;+ let mut raw = [0u8; 48]; let _ = getrandom::getrandom(&mut raw);+ let verifier = b64url(&raw);+ let challenge = b64url(&sha2::Sha256::digest(verifier.as_bytes()));+ (verifier, challenge)+}+fn urlencode_form(pairs: &[(&str, &str)]) -> String { pairs.iter().map(|(k, v)| format!("{}={}", fusion_core::aps::urlencode(k), fusion_core::aps::urlencode(v))).collect::<Vec<_>>().join("&") }++/// `_exchange_code`: the authorization code for tokens, saved with obtained_at/expires_at.+fn exchange_code(store: &ApsStore, code: &str, verifier: &str, redirect: &str) -> Value {+ let body = urlencode_form(&[("grant_type", "authorization_code"), ("code", code), ("client_id", &store.client_id()), ("redirect_uri", redirect), ("code_verifier", verifier)]);+ let (status, text) = match ureq::post(TOKEN_URL).set("Content-Type", "application/x-www-form-urlencoded").timeout(Duration::from_secs(30)).send_string(&body) {+ Ok(r) => { let s = r.status(); (s, r.into_string().unwrap_or_default()) }+ Err(ureq::Error::Status(c, r)) => (c, r.into_string().unwrap_or_default()),+ Err(e) => (0, format!("{{\"error\": {}}}", Value::String(e.to_string()))),+ };+ let parsed = serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text));+ if status == 200 && parsed["access_token"].as_str().map(|s| !s.is_empty()).unwrap_or(false) {+ let mut t = parsed; let n = now() as u64;+ t["obtained_at"] = json!(n); t["expires_at"] = json!(n + t["expires_in"].as_u64().unwrap_or(3600) - 60);+ store.save_tokens(&t);+ return json!({"ok": true});+ }+ json!({"ok": false, "status": status, "body": parsed})+}++/// `_probe_authorize_auth001`: does /authorize answer AUTH-001 before any login? A healthy app+/// redirects to login; a broken one returns the JSON. False on any ambiguity.+fn probe_auth001(auth_url: &str) -> bool {+ let agent = ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(8)).build();+ let body = match agent.get(auth_url).call() {+ Ok(r) => { if (300..400).contains(&r.status()) { return false; } r.into_string().unwrap_or_default() }+ Err(ureq::Error::Status(c, r)) => { if matches!(c, 301 | 302 | 303 | 307 | 308) { return false; } r.into_string().unwrap_or_default() }+ Err(_) => return false,+ };+ body.contains("AUTH-001") || body.to_lowercase().contains("does not have access to the api product")+}+pub fn auth001_recovery_hint(client_id: &str) -> String {+ format!("⚠ APS AUTH-001 - the APS app (Client ID {}...) LOST its Data Management API product, so authorize fails BEFORE any login. This is fixable; here is the exact recovery (this one genuinely needs ABE/nbrowser: recovery CLICKS and EVALs inside the portal, which the extension-free nbrowser_open_os_window cannot do - it only opens a URL. If the extension is not installed, install adom-browser-extension first; ABE can do it automatically, roughly a 5-minute AI-driven process. Alternatively walk the USER through steps 1-3 by hand): (1) nbrowser_open_window {{sessionId, thread, purpose, profile:<the warm Edge/Chrome profile>, url:'https://aps.autodesk.com/myapps/'}} and open the app whose Client ID is this one. (2) On the app's App-settings page, API Access section: confirm 'Data Management API' is checked, then FORCE a re-commit - nbrowser_eval a click on the VISIBLE 'Save changes' button (filter buttons by /save changes/i AND offsetParent!==null; the bare text matches ~9 hidden duplicates so text-click is ambiguous). If Save is disabled, toggle any one unneeded API off first to dirty the form. (3) WAIT ~1-3 min for Autodesk to propagate. (4) Retry fusion_aps_signin; authorize now reaches the CONSENT screen ('<app> is requesting permission ... View/Manage/Write your data ... Allow') - nbrowser_navigate the session to the new authUrl, then nbrowser_click 'Allow'; the URL flips to localhost:<port>/fusion-aps/callback?code=... and the bridge captures the token. (5) Poll fusion_aps_status until signedIn:true. Verified live 2026-07-14. ⚠ FIRST CHECK before driving anything: open myapps AS THIS USER - if it shows 'It seems you don't have a hub yet', this Autodesk account is NOT in the APS developer hub that owns the app, the app is invisible to it, and NO portal driving from this account can fix AUTH-001 (dead-ended live 2026-08-21, adom-bridge#93). Route the fix to the app's owner (the developer-hub admin), and have them add this user to the developer hub so the next AUTH-001 is self-servable. Meanwhile the library mint works WITHOUT APS since 1.9.133 (the add-in saves the f3d straight to the cloud); APS stays needed only for cloud search and thumbnail verification.", client_id.chars().take(8).collect::<String>())+}++fn browser_file(store: &ApsStore) -> std::path::PathBuf { store.dir.join("signin-browser.json") }+pub fn load_signin_browser(store: &ApsStore) -> Option<Value> { std::fs::read_to_string(browser_file(store)).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).filter(|v| v.is_object()) }+pub fn save_signin_browser(store: &ApsStore, pref: &Value) -> bool {+ let _ = std::fs::create_dir_all(&store.dir);+ let mut keep = json!({});+ for k in ["name", "exe", "profileDir", "newWindow", "how"] { if !pref[k].is_null() { keep[k] = pref[k].clone(); } }+ keep["updatedAt"] = json!(chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string());+ std::fs::write(browser_file(store), serde_json::to_string_pretty(&keep).unwrap_or_default()).is_ok()+}+pub fn forget_signin_browser(store: &ApsStore) -> bool { match std::fs::remove_file(browser_file(store)) { Ok(()) => true, Err(e) => e.kind() == std::io::ErrorKind::NotFound } }+/// `_launch_url_in_browser`: a SPECIFIC browser+profile (never the OS default) at url.+fn launch_url_in_browser(pref: &Value, url: &str) -> bool {+ let Some(exe) = pref["exe"].as_str().filter(|e| !e.is_empty() && std::path::Path::new(e).exists()) else { return false };+ let mut cmd = std::process::Command::new(exe);+ if let Some(p) = pref["profileDir"].as_str().filter(|p| !p.is_empty()) { cmd.arg(format!("--profile-directory={p}")); }+ if pref["newWindow"].as_bool().unwrap_or(true) { cmd.arg("--new-window"); }+ cmd.arg(url).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+ #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); }+ cmd.spawn().is_ok()+}+/// `_open_os_window_extension_free`: the user's REAL browser profile, no extension required.+fn open_os_window_extension_free(ctx: &Ctx, url: &str, profile: Option<&str>) -> Value {+ let profile = profile.map(str::to_string).or_else(crate::signin::remembered_signin_profile_pub);+ let sess = format!("aps-signin-{}", now() as i64);+ let mut a = json!({"sessionId": sess, "thread": "fusion-aps-signin", "purpose": "Autodesk APS consent", "url": url, "background": true});+ if let Some(p) = &profile { a["profile"] = json!(p); }+ let mut r = ab_call(ctx, "nbrowser_open_window", a.clone(), 60);+ let owned = r["_adCallError"].is_null() && (r["ok"].as_bool() == Some(true) || !r["sessionId"].is_null());+ if !owned { r = ab_call(ctx, "nbrowser_open_os_window", a.clone(), 60); }+ if !r["_adCallError"].is_null() { return json!({"launched": false, "profile": profile, "error": r["_adCallError"]}); }+ let launched = r["ok"].as_bool() == Some(true) || !r["hwnd"].is_null() || !r["sessionId"].is_null() || r["_hint"].as_str().unwrap_or("").contains("Launch fired");+ if launched { crate::signin::track_signin_window(&sess, profile.as_deref().unwrap_or(""), "aps"); }+ json!({"launched": launched, "agentOwned": owned, "where": r["displayName"].as_str().or(r["profile"].as_str()).map(str::to_string).or(profile.clone()), "profile": r["profile"].as_str().map(str::to_string).or(profile), "hwnd": r["hwnd"], "extensionInstalled": r["extensionInstalled"],+ "error": if launched { Value::Null } else { json!(r["error"].as_str().or(r["_hint"].as_str()).unwrap_or("").chars().take(200).collect::<String>()) }})+}+fn abe_state(ctx: &Ctx) -> Value {+ let r = ab_call(ctx, "nbrowser_readiness", json!({}), 30);+ if !r["_adCallError"].is_null() || r["ready"].is_null() { return json!({"known": false, "note": "could not probe ABE (verb missing or CLI too old); the extension-free path does not need it"}); }+ json!({"known": true, "ready": r["ready"], "extensionConnected": r["extensionConnected"], "profiles": r["profiles"].as_array().cloned().unwrap_or_default(), "chromeInstalled": r["chromeInstalled"], "extensionStale": r["extensionStale"], "staleProfiles": r["staleProfiles"].as_array().cloned().unwrap_or_default()})+}+fn setup_chain(opened: bool, abe: &Value) -> Value {+ if opened { return json!([{"step": "user_approves", "do": "Tell the user the exact profile it opened in and ask them to click Approve."}, {"step": "poll", "verb": "fusion_aps_status", "until": "signedIn:true"}]); }+ let mut chain = vec![+ json!({"step": "open_consent", "verb": "nbrowser_open_os_window", "args": {"sessionId": "<task id>", "thread": "<your thread>", "purpose": "Autodesk APS consent", "profile": "chrome:<their-autodesk-email>", "url": "<authUrl from this response>"}, "note": "EXTENSION-FREE - works with no Adom extension installed. If this verb is unknown, the adom-bridge CLI on this box is too old: update it (or have the user paste authUrl into the right profile by hand)."}),+ json!({"step": "user_approves", "do": "Ask the user to approve in that profile; without the extension we cannot click for them."}),+ json!({"step": "poll", "verb": "fusion_aps_status", "until": "signedIn:true"})];+ if abe["known"].as_bool() == Some(true) && abe["extensionConnected"].as_bool() != Some(true) { chain.push(json!({"step": "optional_extension", "flow": "installing-the-extension", "why": "lets the assistant DRIVE the sign-in instead of asking for clicks", "cost": "~5 minutes, ABE automates it"})); }+ json!(chain)+}++/// The localhost callback listener for one sign-in: answers the redirect, exchanges the code.+fn serve_callback(server: tiny_http::Server, store: ApsStore) {+ let deadline = std::time::Instant::now() + Duration::from_secs(180);+ while std::time::Instant::now() < deadline {+ if SIGNIN_STATE.lock().unwrap().as_ref().map(|s| s["done"].as_bool() == Some(true)).unwrap_or(true) { break; }+ let Ok(Some(req)) = server.recv_timeout(Duration::from_secs(1)) else { continue };+ let url = req.url().to_string();+ let (path, query) = url.split_once('?').unwrap_or((&url, ""));+ if path != REDIRECT_PATH { let _ = req.respond(tiny_http::Response::empty(404)); continue; }+ let q: std::collections::HashMap<String, String> = query.split('&').filter_map(|kv| kv.split_once('=')).map(|(k, v)| (k.to_string(), urldecode(v))).collect();+ let st = SIGNIN_STATE.lock().unwrap().clone().unwrap_or(json!({}));+ let mut ok = false;+ if let Some(code) = q.get("code") { if q.get("state").map(String::as_str) == st["state"].as_str() {+ let res = exchange_code(&store, code, st["verifier"].as_str().unwrap_or(""), st["redirectUri"].as_str().unwrap_or(""));+ ok = res["ok"].as_bool() == Some(true);+ if let Some(s) = SIGNIN_STATE.lock().unwrap().as_mut() { s["done"] = json!(true); s["error"] = if ok { Value::Null } else { res }; }+ } }+ let html = format!("<html><body style='font-family:sans-serif;background:#111;color:#eee;text-align:center;padding-top:80px'><h2>Fusion APS: {}</h2><p>You can close this tab and return to Adom.</p></body></html>", if ok { "signed in ✓" } else { "sign-in failed" });+ let _ = req.respond(tiny_http::Response::from_string(html).with_header(tiny_http::Header::from_bytes("Content-Type", "text/html").unwrap()));+ }+}+fn urldecode(s: &str) -> String {+ let b = s.as_bytes(); let mut out = Vec::with_capacity(b.len()); let mut i = 0;+ while i < b.len() { match b[i] { b'%' if i + 2 < b.len() + 0 && i + 2 <= b.len() - 1 => { if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(v); i += 3; continue; } out.push(b'%'); i += 1; } b'+' => { out.push(b' '); i += 1; } c => { out.push(c); i += 1; } } }+ String::from_utf8_lossy(&out).to_string()+}++/// `fusion_aps_signin` (`start_signin` + `handle_signin`).+pub fn signin(ctx: &Ctx, args: &Value) -> Value {+ let store = ApsStore::default_location();+ let client_id = store.client_id();+ if client_id.is_empty() {+ return json!({"success": false, "error": "No APS Client ID configured.", "errorCode": "aps_not_configured", "_hint": format!("Call fusion_aps_set_client_id {{\"clientId\":\"...\"}} first (register a Desktop/PKCE app at https://aps.autodesk.com with Data Management API + callbacks {}).", all_registered_callbacks().join(", "))});+ }+ let (verifier, challenge) = pkce_pair();+ let mut sb = [0u8; 12]; let _ = getrandom::getrandom(&mut sb); let state = b64url(&sb);+ let mut server = None; let mut bound = 0u16;+ for port in REDIRECT_PORTS { if let Ok(s) = tiny_http::Server::http(("127.0.0.1", port)) { server = Some(s); bound = port; break; } }+ let Some(server) = server else { return json!({"success": false, "error": format!("All callback ports busy on this machine: {:?}", REDIRECT_PORTS), "_hint": "Close whatever is holding these ports, or a sign-in is already in progress. These are only bound for ~3 min during sign-in."}) };+ let redirect = redirect_uri(bound);+ *SIGNIN_STATE.lock().unwrap() = Some(json!({"verifier": verifier, "state": state, "redirectUri": redirect, "done": false, "error": null, "startedAt": now()}));+ let st2 = ApsStore { dir: store.dir.clone() };+ std::thread::spawn(move || serve_callback(server, st2));+ let auth_url = format!("{APS_BASE}/authentication/v2/authorize?{}", urlencode_form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", &redirect), ("scope", SCOPES), ("code_challenge", &challenge), ("code_challenge_method", "S256"), ("state", &state)]));+ if probe_auth001(&auth_url) {+ *SIGNIN_STATE.lock().unwrap() = None;+ return json!({"success": false, "error": "APS AUTH-001: the app's Client ID does not have access to the Data Management API product.", "errorCode": "aps_app_missing_product", "authUrl": auth_url, "appManagementUrl": "https://aps.autodesk.com/myapps/", "clientId": client_id, "recoverable": true, "_hint": auth001_recovery_hint(&client_id)});+ }+ let passed = args["exe"].as_str().filter(|e| !e.is_empty()).map(|exe| json!({"name": args["browser"].as_str().or(args["name"].as_str()), "exe": exe, "profileDir": args["profileDir"], "newWindow": args["newWindow"].as_bool().unwrap_or(true)}));+ let pref = passed.clone().or_else(|| load_signin_browser(&store));+ let mut opened_via = "none_use_extension"; let mut opened_pref = Value::Null; let mut ext_free = Value::Null;+ let abe = abe_state(ctx);+ if let Some(p) = pref.filter(|p| launch_url_in_browser(p, &auth_url)) {+ opened_via = if passed.is_some() { "specified_browser" } else { "remembered_browser" };+ opened_pref = json!({"name": p["name"], "exe": p["exe"], "profileDir": p["profileDir"]});+ save_signin_browser(&store, &p);+ } else if args["allowDefaultBrowser"].as_bool() == Some(true) && load_signin_browser(&store).is_none() {+ let ok = { #[cfg(windows)] { std::process::Command::new("cmd").args(["/C", "start", "", &auth_url]).spawn().is_ok() } #[cfg(not(windows))] { std::process::Command::new("xdg-open").arg(&auth_url).spawn().is_ok() } };+ if ok { opened_via = "os_default_browser"; }+ } else {+ ext_free = open_os_window_extension_free(ctx, &auth_url, args["profile"].as_str());+ if ext_free["launched"].as_bool() == Some(true) { opened_via = "os_window_extension_free"; }+ }+ let hint = match opened_via {+ "remembered_browser" | "specified_browser" => format!("Opened the Autodesk consent in the REMEMBERED browser ({}{}) — the one that authed last time. A background listener on port {bound} captures the token once you approve. Poll fusion_aps_status until signedIn:true.", opened_pref["name"].as_str().unwrap_or("cached"), opened_pref["profileDir"].as_str().map(|d| format!("/{d}")).unwrap_or_default()),+ "os_default_browser" => "Opened in the OS DEFAULT browser (fallback). On a multi-browser machine this may be the wrong profile; prefer driving authUrl in the user's real browser via nbrowser_open_window (new window), then call fusion_aps_set_browser to remember it. Poll fusion_aps_status.".to_string(),+ "os_window_extension_free" => format!("Opened the Autodesk consent in {} using the EXTENSION-FREE path (nbrowser_open_os_window) - no Adom browser extension required. TELL THE USER which profile it opened in (never just 'your browser') and that THEY need to click Approve, because without the extension I cannot see or drive that window. A listener on port {bound} captures the token once they approve. Poll fusion_aps_status until signedIn:true. {ABE_UPSELL}", ext_free["where"].as_str().unwrap_or("the user's browser profile")),+ _ => format!("Could not open a browser for you. No REMEMBERED Autodesk browser, and the EXTENSION-FREE open (nbrowser_open_os_window) did not fire{}. DO THIS: run the setupChain in this response in order. Simplest manual fallback - have the USER paste authUrl into the browser profile their Autodesk account lives in. A listener on port {bound} captures the token once they approve; poll fusion_aps_status until signedIn:true. {ABE_UPSELL}", ext_free["error"].as_str().filter(|e| !e.is_empty()).map(|e| format!(": {}", e.chars().take(120).collect::<String>())).unwrap_or_default()),+ };+ let ok_open = opened_via != "none_use_extension";+ json!({"success": true, "output": format!("Autodesk sign-in started. {}", if ok_open { "Approve access in the opened browser, then call fusion_aps_status." } else { "Open authUrl in the user's real browser (new window) to approve." }),+ "data": {"authUrl": auth_url, "redirectUri": redirect, "openedVia": opened_via, "openedBrowser": opened_pref, "needsExtensionOpen": !ok_open,+ "_surfaceOptions": {"nb": "BEST if installed: nbrowser_eval (CDP) fills/clicks in the user's REAL browser - their real Google/MS logins, Autodesk accepts it, no focus steal, DOM is live so no stale-screenshot problem. Cost: the nbe install is a heavy ask.",+ "pup": "No install, fully background. BUT (1) pup's profile only has accounts YOU signed into it - it will NOT have an arbitrary account the user 'has'; (2) Autodesk gates pup at the PASSWORD page ('browser not supported', survives a clean UA - it fingerprints the CDP launch), so prefer the email-first PASSWORDLESS email-OTP path (fetch the code via adom-google) and avoid the password page; (3) screenshots are stale unless pup_configure {highFps:on}.",+ "foreground": "desktop_click on the real browser - real logins, vendor-accepted, but STEALS FOCUS (rude). Last resort for one gated step; narrate it.",+ "_pathFacts": "Not every email has an Autodesk account ([email protected] did NOT - email-first said 'couldn't find an account'); such users MUST use SSO or Create Account. 'Continue with Google' only lists accounts signed into THE BROWSER PROFILE you are driving."}},+ "extensionFree": ext_free, "errorCode": if ok_open { Value::Null } else { json!("browser_open_failed") }, "abe": abe, "setupChain": setup_chain(ok_open, &abe), "_hint": hint})+}++pub fn set_client_id(args: &Value) -> Value {+ let cid = args["clientId"].as_str().or(args["client_id"].as_str()).unwrap_or("").trim().to_string();+ if cid.is_empty() { return json!({"success": false, "error": "Missing clientId."}); }+ let store = ApsStore::default_location();+ let mut cfg = store.read("config.json"); if !cfg.is_object() { cfg = json!({}); }+ cfg["client_id"] = json!(cid); store.write("config.json", &cfg);+ json!({"success": true, "output": format!("Saved APS Client ID ({}...). Now call fusion_aps_signin.", cid.chars().take(6).collect::<String>()), "data": {"configured": true}})+}+pub fn set_browser(args: &Value) -> Value {+ let store = ApsStore::default_location();+ let exe = args["exe"].as_str().unwrap_or("").trim().to_string();+ let how = args["how"].as_str().map(str::to_string).unwrap_or_else(|| if exe.is_empty() { "extension".into() } else { "launch".into() });+ let pref = json!({"name": args["name"].as_str().or(args["browser"].as_str()), "exe": if exe.is_empty() { Value::Null } else { json!(exe) }, "profileDir": args["profileDir"], "newWindow": args.get("newWindow").cloned().unwrap_or(json!(true)), "how": how});+ if exe.is_empty() && how != "extension" { return json!({"success": false, "error": "Provide exe (browser binary path) or how:'extension'.", "_hint": "e.g. {name:'Edge', exe:'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', profileDir:'Default'}"}); }+ let ok = save_signin_browser(&store, &pref);+ json!({"success": ok, "output": format!("Remembered Autodesk sign-in browser: {}.", pref["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string).unwrap_or_else(|| if exe.is_empty() { how.clone() } else { exe.clone() })), "data": {"saved": ok, "browser": load_signin_browser(&store)}})+}+pub fn get_browser() -> Value {+ let pref = load_signin_browser(&ApsStore::default_location());+ json!({"success": true, "output": match &pref { Some(p) => format!("Remembered: {}", p["name"].as_str().or(p["exe"].as_str()).or(p["how"].as_str()).unwrap_or("")), None => "No Autodesk sign-in browser remembered yet.".to_string() }, "data": {"browser": pref}})+}+pub fn forget_browser() -> Value {+ let ok = forget_signin_browser(&ApsStore::default_location());+ json!({"success": ok, "output": if ok { "Forgot the remembered Autodesk sign-in browser." } else { "Could not clear it." }, "data": {"cleared": ok}})+}
rust/bridge/src/chainexec.rsadded+201@@ -0,0 +1,201 @@+//! The chain executors the exe used to ask the sidecar for (`chain_*`), native.+//! Each mirrors one `_handle_chain_*` in server.py; the chain itself (chains.rs) is unchanged+//! and reaches these through `ExeChainHost::call` when the exe owns the add-in.+use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};+use serde_json::{json, Value};+use std::time::{Duration, Instant};++fn now() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++/// `chain_docs_open`: every document open right now (the chain's `before` snapshot).+pub fn docs_open(pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut names = crate::orchestrations::open_doc_names(pass);+ names.sort();+ json!({"success": true, "documents": names})+}++/// `chain_close_docs_since`: close what was not in `before`, keeping `keep`.+pub fn close_docs_since(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let strs = |k: &str| -> Vec<String> { args[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default() };+ json!({"success": true, "closed": crate::mint::close_docs_opened_since(ctx, &strs("before"), &strs("keep"), pass)})+}++/// `chain_close_bridge_doc`: close one bridge document, verified.+pub fn close_bridge_doc(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut r = crate::orchestrations::close_bridge_doc_verified(ctx, args["name"].as_str().unwrap_or(""), 60, pass);+ r["success"] = json!(true); r+}++/// `chain_sweep_orphans`: close scratch documents a chain that died with a bridge respawn left+/// open. Only runs on file as bridge_restarted_mid_run and not yet swept; only never-modified+/// Untitled* and <pkg>_3d designs. A user's named or modified document is never touched.+pub fn sweep_orphans(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut runs = crate::chains::read_runs();+ let pending: Vec<String> = runs.iter().filter(|(_, st)| st["errorCode"].as_str() == Some("bridge_restarted_mid_run") && st["scratchSwept"].as_bool() != Some(true)).map(|(k, _)| k.clone()).collect();+ if pending.is_empty() { return json!({"success": true, "swept": 0}); }+ if !crate::launch::round_trip_ready(1) { return json!({"success": true, "swept": 0, "why": "add-in not answering"}); }+ let di = pass("document_info", json!({}));+ let mut closed: Vec<String> = Vec::new();+ for d in di["data"]["openDocuments"].as_array().cloned().unwrap_or_default() {+ let raw = d["name"].as_str().unwrap_or("").to_string();+ let n = raw.trim_end_matches('*').trim().to_string();+ let modified = d["isModified"].as_bool().unwrap_or(false) || d["modified"].as_bool().unwrap_or(false) || raw.ends_with('*');+ let l = n.to_lowercase();+ if (l.starts_with("untitled") && !modified) || l.ends_with("_3d") {+ if crate::orchestrations::close_bridge_doc_verified(ctx, &n, 60, pass)["closed"].as_bool() == Some(true) { closed.push(n); }+ }+ }+ for rid in &pending { if let Some(st) = runs.get_mut(rid) { st["scratchSwept"] = json!(true); st["scratchSweptDocs"] = json!(closed); } }+ crate::chains::write_runs_pub(&runs);+ crate::fusion_prefs::action_ledger(&ctx.state_dir, "orphan_scratch_docs_swept", json!({"runs": pending, "closed": closed}));+ json!({"success": true, "swept": closed.len(), "closed": closed})+}++/// `chain_publish_library` / `_publish_library_to_cloud`: save the bound library into the+/// user's cloud project so Fusion's Library Manager lists it. Folder created if missing; a+/// same-named library already there is reported (this saves a NEW document and says so).+pub fn publish_library(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let p = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ if p.is_empty() { return json!({"success": false, "error": "libraryPath required"}); }+ let folder = args["folder"].as_str().filter(|s| !s.is_empty()).unwrap_or("Adom Libraries").to_string();+ let stem = p.rsplit('/').next().unwrap_or("").rsplitn(2, '.').last().unwrap_or("").to_string();+ let name = { let n = args["name"].as_str().unwrap_or("").trim().to_string(); if n.is_empty() { stem.clone() } else { n } };+ let mut out = json!({"success": true, "saved": false, "name": name, "folder": folder});+ let short = |s: &str| s.chars().take(120).collect::<String>();+ // the project: the default upload project by id, else "Main", else the first+ let pr = pass("list_cloud_projects", json!({}));+ let mut projects: Vec<Value> = Vec::new();+ for hub in pr["data"]["hubs"].as_array().cloned().unwrap_or_default() { for pj in hub["projects"].as_array().cloned().unwrap_or_default() { if pj["name"].as_str().map(|s| !s.is_empty()).unwrap_or(false) { projects.push(pj); } } }+ if pr["success"].as_bool() != Some(true) { out["projectNote"] = json!(short(pr["error"].as_str().unwrap_or("list_cloud_projects failed"))); }+ let mut project_name = projects.iter().find(|pj| pj["id"].as_str() == Some(crate::mint::DEFAULT_UPLOAD_PROJECT)).and_then(|pj| pj["name"].as_str()).unwrap_or("").to_string();+ if project_name.is_empty() { project_name = projects.iter().find(|pj| pj["name"].as_str() == Some("Main")).or(projects.first()).and_then(|pj| pj["name"].as_str()).unwrap_or("").to_string(); }+ out["project"] = if project_name.is_empty() { Value::Null } else { json!(project_name) };+ let pn = |mut a: Value| -> Value { if !project_name.is_empty() { a["projectName"] = json!(project_name); } a };+ let r = pass("create_cloud_folder", pn(json!({"folderName": folder})));+ if r["success"].as_bool() == Some(true) { out["folderCreated"] = json!(r["data"]["existed"].as_bool() != Some(true)); } else { out["folderNote"] = json!(short(r["error"].as_str().unwrap_or("create_cloud_folder failed"))); }+ let lst = pass("list_cloud_files", pn(json!({"folderPath": folder})));+ if lst["success"].as_bool() == Some(true) {+ let older: Vec<Value> = lst["data"]["files"].as_array().cloned().unwrap_or_default().into_iter().filter(|f| f["name"].as_str().unwrap_or("").to_lowercase() == name.to_lowercase()).collect();+ out["duplicatesBefore"] = json!(older.len());+ out["olderSameName"] = json!(older.iter().map(|f| f["id"].clone()).collect::<Vec<_>>());+ } else { out["duplicatesBefore"] = Value::Null; }+ // the library must be open to be saved+ let names = crate::orchestrations::open_doc_names(pass);+ let bare: Vec<String> = names.iter().map(|n| { let s = n.replace('*', ""); let s = match s.find(" (") { Some(i) => s[..i].to_string(), None => s }; s.trim().to_lowercase() }).collect();+ if !names.is_empty() && !bare.contains(&stem.to_lowercase()) {+ let op = crate::orchestrations::open_lbr(ctx, &json!({"filePath": p}), pass);+ out["reopened"] = json!(op["success"].as_bool() == Some(true));+ if op["success"].as_bool() != Some(true) { out["error"] = json!(format!("could not reopen the library to save it: {}", op["error"].as_str().unwrap_or("").chars().take(160).collect::<String>())); return out; }+ }+ let deadline = Instant::now() + Duration::from_secs(120);+ let mut seen = json!(null); let mut active_ok = false;+ while Instant::now() < deadline {+ let _ = pass("activate_document", json!({"name": stem}));+ let st = pass("get_app_state", json!({}));+ seen = st["data"].clone();+ if seen["activeDocument"].as_str().unwrap_or("").to_lowercase().contains(&stem.to_lowercase()) { active_ok = true; break; }+ std::thread::sleep(Duration::from_secs(2));+ }+ if !active_ok { out["error"] = json!(format!("library document {} could not be made active (last seen: {}), so it was not saved", fusion_lbr::py_repr_str(&stem), seen.to_string().chars().take(160).collect::<String>())); return out; }+ let r = pass("save_to_cloud", pn(json!({"name": name, "folderPath": folder, "description": "Imported by Adom (bound 3D packages)"})));+ let d = r["data"].clone();+ if r["success"].as_bool() != Some(true) { out["error"] = json!(short(r["error"].as_str().unwrap_or("save_to_cloud failed"))); return out; }+ out["saved"] = json!(true); out["project"] = d["project"].clone(); if d["folder"].as_str().map(|s| !s.is_empty()).unwrap_or(false) { out["folder"] = d["folder"].clone(); }+ out["fileId"] = d["fileId"].clone(); out["versionId"] = d["versionId"].clone(); out["versionNumber"] = d["versionNumber"].clone();+ let mut clean = false;+ for _ in 0..2 {+ let di = pass("document_info", json!({}));+ for dd in di["data"]["openDocuments"].as_array().cloned().unwrap_or_default() {+ let n = dd["name"].as_str().unwrap_or("");+ if n.trim_end_matches('*').trim().to_lowercase() == stem.to_lowercase() { clean = dd["isSaved"].as_bool().unwrap_or(false) && !(dd["isModified"].as_bool().unwrap_or(false) || n.ends_with('*')); }+ }+ if clean { break; }+ let _ = pass("run_modeling_script", json!({"script": "import adsk.core\napp=adsk.core.Application.get()\nd=app.activeDocument\nok=d.save('Imported by Adom') if d and not d.isSaved else True\nresult={'saved': bool(ok), 'isSaved': bool(d.isSaved) if d else None}"}));+ }+ out["savedClean"] = json!(clean);+ out+}++fn user_unsaved_docs(state_dir: &std::path::Path, names: &[String]) -> Vec<String> {+ names.iter().filter(|raw| {+ let s = raw.replace('*', ""); let bare = match s.find(" (") { Some(i) => s[..i].to_string(), None => s }; let bare = bare.trim().to_lowercase();+ !bare.is_empty() && crate::etiquette::bridge_doc_in(state_dir, &bare).is_none() && !bare.starts_with("untitled") && !bare.ends_with("_3d")+ }).cloned().collect()+}++/// `chain_addin_restart_info` / `_addin_restart_info`: everything a caller needs to tell a+/// human that Fusion must restart, plus a toast (throttled to one per 15 minutes) whose+/// answer a background watcher honours. Document counts come from the add-in, which by+/// definition may not be answering here; `documentsKnown` says which. A count we cannot+/// take is not a count we may invent.+pub fn addin_restart_info(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let fire_toast = args["fireToast"].as_bool().unwrap_or(true);+ let mut info = json!({"success": true, "restartRequired": true, "reason": "addin_installed_while_fusion_running",+ "explanation": "The AdomBridge add-in was installed or updated while Fusion was already running. Fusion loads add-ins only at launch, so this Fusion cannot see it until it restarts. Nothing is wrong with the install and nothing is lost - it just needs a relaunch.",+ "userMessage": "Fusion needs a quick restart to finish setting up the Adom add-in. We can save your open documents first.",+ "remedyVerb": "fusion_restart_for_addin", "documentsKnown": false, "openDocuments": null, "unsavedDocuments": null});+ let st = pass("get_app_state", json!({}));+ let docs = if st["documents"].is_array() { st["documents"].clone() } else { st["data"]["documents"].clone() };+ let mut unames: Option<Vec<String>> = None;+ if let Some(ds) = docs.as_array() {+ info["documentsKnown"] = json!(true); info["openDocuments"] = json!(ds.len());+ let un: Vec<String> = ds.iter().filter(|d| d["isSaved"].as_bool() != Some(true)).filter_map(|d| d["name"].as_str().map(str::to_string)).collect();+ info["unsavedDocuments"] = json!(un.len());+ info["unsavedDocumentNames"] = json!(un.iter().take(20).collect::<Vec<_>>());+ info["documentNames"] = json!(ds.iter().filter_map(|d| d["name"].as_str()).take(20).collect::<Vec<_>>());+ unames = Some(un);+ }+ let yours: Vec<String> = match &unames { Some(u) => { let y = user_unsaved_docs(&ctx.state_dir, u); info["userUnsaved"] = json!(y); info["autoRestart"] = json!(y.is_empty()); y } None => { info["autoRestart"] = json!(false); vec![] } };+ if fire_toast && info["autoRestart"].as_bool() != Some(true) {+ let f = ctx.state_dir.join("addin_restart_toast.json");+ let _ = std::fs::create_dir_all(&ctx.state_dir);+ let last = std::fs::read_to_string(&f).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v["at"].as_f64()).unwrap_or(0.0);+ if now() - last > 900.0 {+ let body = if !yours.is_empty() { format!("Fusion needs a quick restart to finish setting up the Adom add-in. Blocked only by {} unsaved document(s) that look like YOURS: {}. Everything else open is Adom's own scratch and is handled automatically. We will save yours first.", yours.len(), yours.iter().take(5).cloned().collect::<Vec<_>>().join(", ")) }+ else if info["documentsKnown"].as_bool() == Some(true) { format!("Fusion needs a quick restart to finish setting up the Adom add-in. You have {} document(s) open{} - we will save them first.", info["openDocuments"], if info["unsavedDocuments"].as_u64().unwrap_or(0) > 0 { format!(", {} unsaved", info["unsavedDocuments"]) } else { String::new() }) }+ else { info["userMessage"].as_str().unwrap_or("").to_string() };+ let r = match Loopback::from_env() {+ Some(ab) => ab.call("notify_user", json!({"title": "Fusion needs a restart", "body": body, "level": "info", "target": "all", "buttons": [{"label": "Save and restart", "id": "restart"}, {"label": "I'll handle it", "id": "later"}]}), &ctx.caller, 20).unwrap_or_else(|e| json!({"error": format!("{e:?}")})),+ None => json!({"error": "ab loopback unavailable"}),+ };+ let delivered = r["success"].as_bool() == Some(true) || r["status"].as_str() == Some("ok") || r["summary"]["ok"].as_f64().unwrap_or(0.0) > 0.0 || r["delivered"].as_bool() == Some(true);+ let id = r["id"].clone().as_str().map(str::to_string).or_else(|| r["data"]["id"].as_str().map(str::to_string)).or_else(|| r["notificationId"].as_str().map(str::to_string));+ info["toast"] = json!({"delivered": delivered, "id": id, "error": r["error"], "choices": ["Save and restart", "I'll handle it"]});+ let _ = std::fs::write(&f, json!({"at": now(), "id": id}).to_string());+ if let Some(nid) = id { watch_restart_choice(ctx.state_dir.clone(), ctx.caller.clone(), nid); }+ } else { info["toast"] = json!({"delivered": false, "suppressed": "already toasted recently"}); }+ }+ info+}++/// `_watch_restart_choice`: wait in the background for the user's answer to the restart+/// notice and honour it. "Save and restart" -> fusion_restart_for_addin {save:true} on this+/// exe; "I'll handle it" -> nothing but the ledger entry. Either way "why did my Fusion+/// restart" has an answer with a timestamp.+fn watch_restart_choice(state_dir: std::path::PathBuf, caller: fusion_core::ab::Caller, nid: String) {+ std::thread::spawn(move || {+ let Some(ab) = Loopback::from_env() else { return };+ let deadline = Instant::now() + Duration::from_secs(1800);+ while Instant::now() < deadline {+ if let Ok(x) = ab.call("notify_response", json!({"id": nid, "target": "all"}), &caller, 30) {+ let x = if x["data"].is_object() { x["data"].clone() } else { x };+ let resp = if x["response"].is_object() { x["response"]["action"].clone().as_str().or(x["response"]["button"].as_str()).or(x["response"]["id"].as_str()).map(str::to_string) } else { x["response"].as_str().map(str::to_string) };+ let btn = x["buttonId"].as_str().or(x["button"].as_str()).map(str::to_string).or(resp);+ if x["pending"].as_bool() == Some(false) || btn.is_some() || x["dismissed"].as_bool() == Some(true) {+ crate::fusion_prefs::action_ledger(&state_dir, "restart_choice_seen", json!({"raw": btn.clone().unwrap_or_default().chars().take(40).collect::<String>(), "notification": nid}));+ match btn.as_deref() {+ Some("restart") | Some("Save and restart") => {+ crate::fusion_prefs::action_ledger(&state_dir, "restart_choice", json!({"choice": "restart", "notification": nid}));+ let r = crate::launch::SELF_URL.lock().unwrap().clone().and_then(|u| ureq::post(&u).timeout(Duration::from_secs(900)).send_json(json!({"command": "fusion_restart_for_addin", "args": {"save": true, "wait": true}})).ok()).and_then(|r| r.into_json::<Value>().ok()).unwrap_or(json!({}));+ crate::fusion_prefs::action_ledger(&state_dir, "restart_choice_result", json!({"success": r["success"], "errorCode": r["errorCode"], "error": r["error"]}));+ }+ _ => { crate::fusion_prefs::action_ledger(&state_dir, "restart_choice", json!({"choice": "later", "raw": btn, "notification": nid})); }+ }+ return;+ }+ }+ std::thread::sleep(Duration::from_secs(5));+ }+ });+}
rust/bridge/src/chains.rsadded+606@@ -0,0 +1,606 @@+//! Phase 3: the import chains, orchestrated by the exe. `create_library` (bind + prove) and+//! `install_library_bundle` (unpack, pair, place, split, then the chain) are ported from+//! server.py step for step; the leaves that still carry Fusion-driving lessons run on the+//! sidecar through its executor verbs (`chain_*`, `build_library_3d`, `prove_library`).+//! The run store is the SAME two files the Python bridge writes (`%TEMP%/fusion-prove-runs.json`+//! and `fusion-prove-last.json`), so `fusion_prove_result` and `fusion_cancel_run` work from+//! either side and a caller cannot tell who ran the chain except by `owner: "exe"`.+//!+//! AI hint: the chain is a background thread; the verb answers at once with a runId and the+//! caller polls `fusion_prove_result`. Cancel is cooperative (`cancelRequested` in the run+//! store, set by fusion_cancel_run) and lands at the next part boundary.++use serde_json::{json, Map, Value};+use std::sync::{Arc, Mutex};+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};++/// What the chain needs from the shell: a way to call the sidecar / add-in, the state dir.+pub trait ChainHost: Send + Sync {+ fn call(&self, verb: &str, args: Value, timeout_secs: u64) -> Value;+ fn state_dir(&self) -> std::path::PathBuf;+ fn addin_round_trip_ready(&self) -> bool;+ fn addin_status(&self) -> Value;+ fn fusion_running(&self) -> bool;+ fn set_long_op(&self, name: Option<String>);+ fn taskbar(&self, pct: Option<f64>);+}++fn now() -> f64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }+fn runs_path() -> std::path::PathBuf { std::env::temp_dir().join("fusion-prove-runs.json") }+fn last_path() -> std::path::PathBuf { std::env::temp_dir().join("fusion-prove-last.json") }++pub fn read_runs() -> Map<String, Value> {+ std::fs::read_to_string(runs_path()).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v.as_object().cloned()).unwrap_or_default()+}++pub fn write_runs_pub(runs: &Map<String, Value>) { write_runs(runs) }+fn write_runs(runs: &Map<String, Value>) {+ let p = runs_path();+ let tmp = p.with_extension("json.tmp");+ if std::fs::write(&tmp, serde_json::to_string(runs).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, &p); }+}++/// Persist one run's state (per-run store, capped at 20, plus the "last" file).+pub fn save_state_pub(run_id: &str, state: &Value) { save_state(run_id, state) }+pub fn lib_label_pub(p: &str) -> String { lib_label(p) }+fn save_state(run_id: &str, state: &Value) {+ let _ = std::fs::write(last_path(), serde_json::to_string(state).unwrap_or_default());+ let mut runs = read_runs();+ runs.insert(run_id.to_string(), state.clone());+ while runs.len() > 20 { let k = runs.keys().next().cloned().unwrap(); runs.remove(&k); }+ write_runs(&runs);+}++pub fn cancel_requested_pub(run_id: &str) -> bool { cancel_requested(run_id).is_some() }+fn cancel_requested(run_id: &str) -> Option<Value> {+ let runs = read_runs();+ let r = runs.get(run_id)?;+ if r["cancelRequested"].as_bool() == Some(true) { Some(json!({"by": r["cancelledBy"].as_str().unwrap_or("user")})) } else { None }+}++/// timings.json `phase|any` estimate: (seconds, confidence) like `timings.estimate`.+pub fn estimate(state_dir: &std::path::Path, phase: &str, fallback: f64) -> (f64, &'static str) {+ let t: Value = std::fs::read_to_string(state_dir.join("timings.json")).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));+ let st = fusion_core::readiness::timings_stats(&t, phase, "any");+ let (count, p50) = (st["count"].as_u64().unwrap_or(0), st["p50"].as_f64());+ 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") }+}++fn log_run(state_dir: &std::path::Path, verb: &str, seconds: f64, ok: bool) { crate::timings::log_run(state_dir, verb, seconds, ok) }++fn lib_label(p: &str) -> String {+ let stem = p.replace('\\', "/").rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_else(|| p.rsplit('/').next().unwrap_or("").to_string());+ if stem.is_empty() { "library".into() } else { stem }+}+fn lib_stem(p: &str) -> String { lib_label(p) }++fn named(name: &str, descs: &Map<String, Value>) -> String {+ let d = descs.get(&name.to_lowercase()).and_then(|v| v.as_str()).unwrap_or("");+ if d.is_empty() { return if name.is_empty() { "library".into() } else { name.into() }; }+ let d: String = if d.chars().count() <= 70 { d.to_string() } else { format!("{}…", d.chars().take(69).collect::<String>().trim_end()) };+ format!("{name}: {d}")+}++fn library_descriptions(lbr_path: &str) -> Map<String, Value> {+ let mut out = Map::new();+ if let Ok(xml) = std::fs::read_to_string(lbr_path) {+ if let Ok(md) = fusion_lbr::library_part_metadata(&xml) {+ for ds in md.devicesets {+ let d = ds.description.trim().to_string();+ if d.is_empty() { continue; }+ out.insert(ds.name.to_lowercase(), json!(d));+ for v in ds.variants { if !v.package.is_empty() { out.entry(v.package.to_lowercase()).or_insert(json!(d)); } }+ }+ }+ }+ out+}++/// The in-Fusion palette, fire-and-forget on a thread; a watchdog re-sends the last state+/// every 10 s while a run is active (a sibling add-in copy's stop() can tear it down).+pub struct Banner { host: Arc<dyn ChainHost>, last: Mutex<Option<Value>>, active: Mutex<bool> }+impl Banner {+ pub fn new(host: Arc<dyn ChainHost>) -> Arc<Banner> { Arc::new(Banner { host, last: Mutex::new(None), active: Mutex::new(false) }) }+ pub fn send(self: &Arc<Self>, action: &str, title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>, tone: &str) {+ let args = json!({"action": action, "title": title, "detail": detail, "percent": percent, "etaSec": eta.map(|e| e.max(0.0)), "tone": tone});+ if action == "hide" { *self.active.lock().unwrap() = false; } else { *self.active.lock().unwrap() = true; *self.last.lock().unwrap() = Some(args.clone()); }+ let host = self.host.clone();+ std::thread::spawn(move || { let _ = host.call("activity_banner", args, 20); });+ }+ pub fn watchdog(self: &Arc<Self>, run_id: String) {+ let me = self.clone();+ std::thread::spawn(move || loop {+ std::thread::sleep(Duration::from_secs(10));+ if !*me.active.lock().unwrap() { break; }+ if !read_runs().get(&run_id).map(|r| !matches!(r["stage"].as_str(), Some("done") | Some("failed") | Some("cancelled"))).unwrap_or(false) { break; }+ let last = me.last.lock().unwrap().clone();+ if let Some(mut a) = last { a["action"] = json!("update"); let _ = me.host.call("activity_banner", a, 20); }+ });+ }+ pub fn hide_later(self: &Arc<Self>, secs: u64) { let me = self.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_secs(secs)); me.send("hide", "", "", None, None, "working"); }); }+}++fn taskbar(host: &dyn ChainHost, pct: Option<f64>) { host.taskbar(pct); }++pub fn note_bridge_doc_pub(state_dir: &std::path::Path, name: &str) { note_bridge_doc(state_dir, name) }+fn note_bridge_doc(state_dir: &std::path::Path, name: &str) {+ let n = name.replace('\\', "/").rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_else(|| name.to_string()).trim().to_lowercase();+ if n.len() < 3 { return; }+ let p = state_dir.join("prefs.json");+ let mut d: 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 mut docs: Vec<String> = d["bridgeDocs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+ if !docs.contains(&n) { docs.push(n); }+ while docs.len() > 24 { docs.remove(0); }+ docs.sort();+ d["bridgeDocs"] = json!(docs);+ 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); }+}++fn hold_active(state_dir: &std::path::Path) -> bool {+ let f = state_dir.join("user_restart_hold.json");+ let Ok(t) = std::fs::read_to_string(&f) else { return false };+ let at = serde_json::from_str::<Value>(&t).ok().and_then(|v| v["at"].as_f64()).unwrap_or(0.0);+ now() - at < 4.0 * 3600.0+}++fn contains_any(s: &str, needles: &[&str]) -> bool { needles.iter().any(|n| s.contains(n)) }++/// `fusion_create_library`: the whole "create a Fusion library" task, one call, one poll verb.+pub fn create_library(host: Arc<dyn ChainHost>, args: Value) -> Value {+ let lbr_p = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ let sym_p = args["kicadSymbolPath"].as_str().unwrap_or("").replace('\\', "/");+ let mod_p = args["kicadFootprintPath"].as_str().unwrap_or("").replace('\\', "/");+ let step_p = args["stepPath"].as_str().unwrap_or("").replace('\\', "/");+ let parts_in: Vec<Value> = args["parts"].as_array().cloned().unwrap_or_default();+ // an import already running? attach, never start a second one+ let runs = read_runs();+ if let Some((rid, rv)) = runs.iter().filter(|(_, v)| matches!(v["verb"].as_str(), Some("create_library") | None)+ && !matches!(v["stage"].as_str(), Some("done") | Some("failed") | Some("cancelled") | None)+ && v["startedAt"].as_f64().map(|s| now() - s < 1800.0).unwrap_or(false)).last() {+ return json!({"success": true, "started": false, "attached": true, "runId": rid, "statusVerb": "fusion_prove_result", "stage": rv["stage"],+ "_hint": format!("An import is ALREADY running ({rid}, stage: {}). Not starting a second one; two chains on one Fusion would fight over documents. Poll this runId.", rv["stage"].as_str().unwrap_or("?"))});+ }+ let _ = host.call("chain_sweep_orphans", json!({}), 60);+ if lbr_p.is_empty() && sym_p.is_empty() && mod_p.is_empty() {+ return json!({"success": false, "errorCode": "no_input", "error": "Give me EITHER libraryPath (a ready .lbr) OR kicadSymbolPath / kicadFootprintPath (KiCad sources).",+ "_hint": "Shape A: {libraryPath, stepPath}. Shape B: {kicadSymbolPath, kicadFootprintPath, stepPath, pinMap?}. Importing a whole wiki Cloud Library? fusion_install_library_bundle {zipPath} pairs every deviceset to its STEP for you."});+ }+ if !sym_p.is_empty() || !mod_p.is_empty() {+ return json!({"success": false, "errorCode": "adom_lbr_unavailable", "kicadIngest": false,+ "error": "adom-lbr (the canonical KiCad->EAGLE converter) does not run on this platform - Adom binaries are Linux-container-only by design - and this bridge deliberately does NOT convert KiCad formats itself.",+ "_hint": "Convert at AUTHORING time, where adom-lbr genuinely runs: in a container, `adom-lbr generate --sym <.kicad_sym> --fp <.kicad_mod> -o <out.lbr>` (auto-linted), publish the .lbr on the part page, then call fusion_create_library shape A {libraryPath, stepPath}."});+ }+ // preflight, re-checked a few times (a file still landing)+ let mut pf = fusion_lbr::preflight_lbr(std::path::Path::new(&lbr_p));+ let mut tries = 0;+ while !pf.ok && tries < 3 { std::thread::sleep(Duration::from_millis(600)); pf = fusion_lbr::preflight_lbr(std::path::Path::new(&lbr_p)); tries += 1; }+ if !pf.ok {+ return json!({"success": false, "errorCode": "lbr_preflight_failed", "preflight": pf, "runId": Value::Null, "preflightRetries": tries,+ "error": format!("Pre-analysis says Fusion will NOT open this library (re-checked {} times over {:.1}s): {}", tries + 1, tries as f64 * 0.6, pf.errors.join("; ").chars().take(360).collect::<String>()),+ "_hint": "This is NOT 'Fusion failed to start' - there is no runId because the chain refused to begin. Fix the named checks; fusion_preflight_lbr runs these checks in isolation."});+ }+ let run_id: String = format!("{:x}", md5_like(&format!("{}{}", now(), std::process::id())))[..12].to_string();+ {+ let mut runs = read_runs();+ runs.insert(run_id.clone(), json!({"runId": run_id, "verb": "create_library", "stage": "starting", "stageLabel": "accepted, chain starting", "startedAt": now(), "libraryPath": if lbr_p.is_empty() { Value::Null } else { json!(lbr_p) }, "percent": 0, "owner": "exe"}));+ write_runs(&runs);+ }+ let (h2, rid2, a2) = (host.clone(), run_id.clone(), args.clone());+ std::thread::spawn(move || run_chain(h2, rid2, a2, lbr_p, step_p, parts_in));+ json!({"success": true, "started": true, "runId": run_id, "statusVerb": "fusion_prove_result", "shape": "A (.lbr direct)", "owner": "exe",+ "_hint": "The chain runs in the background (it outlives the relay window by design): poll fusion_prove_result {runId} for measured progress, streamed proof frames and the verdict. ONE call, ONE consent, ONE poll verb."})+}++fn md5_like(s: &str) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); s.hash(&mut h); h.finish() }++fn run_chain(host: Arc<dyn ChainHost>, run_id: String, args: Value, lbr_p: String, step_p: String, parts_in: Vec<Value>) {+ let sd = host.state_dir();+ let banner = Banner::new(host.clone());+ let mut state = json!({"stage": "starting", "success": Value::Null, "libraryPath": if lbr_p.is_empty() { Value::Null } else { json!(lbr_p) }, "runId": run_id, "startedAt": now(), "verb": "create_library", "owner": "exe"});+ host.set_long_op(Some(format!("create_library:{run_id}")));+ banner.watchdog(run_id.clone());+ banner.send("show", &format!("{}: starting", lib_label(&lbr_p)), "checking the bundle and Fusion", None, None, "working");+ let docs_before: Vec<String> = host.call("chain_docs_open", json!({}), 15)["documents"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+ if !lbr_p.is_empty() { note_bridge_doc(&sd, &lbr_p); }+ let save = |st: &Value| save_state(&run_id, st);+ save(&state);+ // the plan and its estimates+ let needs_launch = !host.addin_round_trip_ready();+ let n_parts = parts_in.len().max(1);+ let (per_part, _) = estimate(&sd, "stage:bind_per_part", 60.0);+ let stages: Vec<(&str, f64, bool)> = vec![("launch", 60.0, needs_launch), ("convert", 5.0, false), ("bind", per_part * n_parts as f64, !step_p.is_empty() || !parts_in.is_empty()), ("prove", 300.0, true)];+ let plan: Vec<Value> = stages.iter().filter(|(_, _, a)| *a).map(|(n, f, _)| { let (e, c) = estimate(&sd, &format!("stage:{n}"), *f); json!({"stage": n, "estimatedSec": (e.max(*f).max(1.0) * 10.0).round() / 10.0, "confidence": c}) }).collect();+ let est_by: Map<String, Value> = plan.iter().map(|p| (p["stage"].as_str().unwrap().to_string(), p["estimatedSec"].clone())).collect();+ let est_total: f64 = est_by.values().filter_map(|v| v.as_f64()).sum::<f64>().max(1.0);+ let est_of = |k: &str| est_by.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);+ state["plan"] = json!(plan); state["stagesDone"] = json!([]);+ let est_done = std::cell::Cell::new(0.0f64);+ let target_lbr = lbr_p.clone();+ let descs = library_descriptions(&target_lbr);+ let enter = |state: &mut Value, stage_name: &str| {+ state["stage"] = json!(stage_name); state["stageStartedAt"] = json!(now()); save(state);+ let pct = 100.0 * est_done.get() / est_total;+ taskbar(&*host, Some(pct));+ let n = state["partsTotal"].as_u64().unwrap_or(1).max(1);+ let key = stage_name.split(" (").next().unwrap_or(stage_name).to_lowercase();+ let what = if key.starts_with("starting") { "Unpacking the bundle and pairing every deviceset with its STEP".to_string() }+ else if key.starts_with("launch") { "Starting Fusion and waiting for the Adom add-in".into() }+ else if key.starts_with("convert") { "Converting KiCad sources into an EAGLE library".into() }+ else if key.starts_with("binding 3d") { format!("Minting one 3D package per footprint ({} STEP import{}), then binding the urns into the library", n, if n == 1 { "" } else { "s" }) }+ else if key.starts_with("proving") { format!("Opening every deviceset and capturing its symbol, footprint, 3D and component views ({} part{})", n, if n == 1 { "" } else { "s" }) }+ else { stage_name.to_string() };+ banner.send("update", &format!("{}: {}", lib_label(&target_lbr), stage_name.split(" (").next().unwrap_or(stage_name)), &what, Some(pct), Some(est_total - est_done.get()), "working");+ };+ let leave = |state: &mut Value, key: &str| {+ let took = now() - state["stageStartedAt"].as_f64().unwrap_or(now());+ log_run(&sd, &format!("stage:{key}"), took, true);+ crate::timings::record(&sd, &format!("stage:{key}"), took, None); // what estimate("stage:<key>") reads next run+ let mut done = state["stagesDone"].as_array().cloned().unwrap_or_default(); done.push(json!({"stage": key, "seconds": (took * 10.0).round() / 10.0})); state["stagesDone"] = json!(done);+ est_done.set(est_done.get() + est_of(key));+ taskbar(&*host, Some(100.0 * est_done.get() / est_total));+ };+ let fail = |state: &mut Value, code: &str, msg: &str| { state["stage"] = json!("failed"); state["success"] = json!(false); state["verdict"] = json!("not_proven"); state["errorCode"] = json!(code); state["error"] = json!(msg); save(state); };+ let cancelled = |state: &mut Value, at_stage: &str, detail: &str| {+ let by = cancel_requested(&run_id).and_then(|c| c["by"].as_str().map(str::to_string)).unwrap_or_else(|| "user".into());+ state["stage"] = json!("cancelled"); state["success"] = json!(false); state["verdict"] = json!("cancelled"); state["errorCode"] = json!("cancelled_by_user");+ state["cancelRequested"] = json!(true); state["cancelledAt"] = json!(at_stage); state["cancelledBy"] = json!(by);+ state["error"] = json!(format!("Cancelled by {by} while {at_stage}. {detail}")); state["stageLabel"] = json!(format!("cancelled while {at_stage}")); save(state);+ banner.send("update", "Import cancelled", &detail.chars().take(120).collect::<String>(), None, Some(0.0), "failed"); banner.hide_later(20);+ };+ let outcome: Result<(), ()> = (|| {+ // ── launch ──+ if needs_launch {+ if host.fusion_running() {+ let info = host.call("chain_addin_restart_info", json!({"fireToast": false}), 30);+ let mine: Vec<Value> = info["userUnsaved"].as_array().cloned().unwrap_or_default();+ let mut chosen = String::new();+ if mine.is_empty() {+ enter(&mut state, "Fusion is open but its Adom add-in is not answering - restarting Fusion (only bridge documents are open)");+ state["stageReason"] = json!("addin_not_answering"); state["restartChoice"] = json!("auto");+ state["restartReason"] = json!(format!("no unsaved user documents; open: {}", info["openDocuments"])); save(&state);+ let rr = host.call("restart_for_addin", json!({"save": true, "auto": true, "wait": true}), 900);+ state["restartResult"] = json!({"success": rr["success"], "error": rr["error"], "errorCode": rr["errorCode"]}); save(&state);+ chosen = "restarted".into();+ }+ if chosen != "restarted" {+ let names: Vec<String> = mine.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();+ enter(&mut state, &format!("Fusion is open but its Adom add-in is not answering - asking the user before restarting ({} unsaved document(s) look like the user's: {})", names.len(), names.join(", ").chars().take(70).collect::<String>()));+ state["stageReason"] = json!("addin_not_answering");+ let ri = host.call("chain_addin_restart_info", json!({"fireToast": true}), 30);+ state["errorCode"] = json!("addin_restart_required"); state["userMessage"] = ri["userMessage"].clone(); state["documentsKnown"] = ri["documentsKnown"].clone();+ state["openDocuments"] = ri["openDocuments"].clone(); state["unsavedDocuments"] = ri["unsavedDocuments"].clone(); state["remedyVerb"] = json!("fusion_restart_for_addin");+ state["notificationId"] = ri["toast"]["id"].clone(); save(&state);+ let deadline = Instant::now() + Duration::from_secs(1800);+ while Instant::now() < deadline {+ if host.addin_round_trip_ready() { chosen = "restarted".into(); break; }+ if hold_active(&sd) { chosen = "hold".into(); break; }+ std::thread::sleep(Duration::from_secs(4));+ }+ if chosen == "hold" { fail(&mut state, "user_owns_restart", "You chose 'I'll handle it' on the restart notice, so nothing was restarted for you. Save and restart Fusion yourself, then start the import again."); return Err(()); }+ if chosen != "restarted" { fail(&mut state, "awaiting_user_restart", "Fusion's add-in stayed silent for 30 minutes and nobody restarted Fusion. Save and restart Fusion, then start the import again."); return Err(()); }+ if let Some(o) = state.as_object_mut() { o.remove("errorCode"); o.entry("restartChoice").or_insert(json!("restart")); }+ save(&state);+ }+ } else {+ enter(&mut state, "launching Fusion (add-in loads at start)");+ state["stageReason"] = json!("fusion_not_running"); save(&state);+ let _ = host.call("start", json!({"wait": true}), 400);+ }+ let deadline = Instant::now() + Duration::from_secs(240);+ while Instant::now() < deadline && !host.addin_round_trip_ready() { std::thread::sleep(Duration::from_secs(5)); }+ if !host.addin_round_trip_ready() { state["launch"] = json!({"success": false, "error": "Fusion did not become ready within 240s of the chain launching it."}); fail(&mut state, "launch_timeout", "Fusion did not become ready within 240s of the chain launching it."); return Err(()); }+ leave(&mut state, "launch");+ }+ state["libraryPath"] = json!(target_lbr);+ let mut parts = parts_in.clone();+ if parts.is_empty() && !step_p.is_empty() {+ let pkgs = std::fs::read_to_string(&target_lbr).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()).map(|n| n.pac).unwrap_or_default();+ parts.push(json!({"package": pkgs.first().cloned().unwrap_or_default(), "lbrPath": target_lbr, "modelPath": step_p}));+ }+ state["partsTotal"] = json!(parts.len());+ // ── bind ──+ if !parts.is_empty() {+ enter(&mut state, "binding 3D (STEP -> f3d -> package3d)");+ for _ in 0..60 {+ let st = host.addin_status();+ if !st["busy"].as_bool().unwrap_or(false) || matches!(st["busyCommand"].as_str(), Some("activity_banner") | Some("get_app_state")) { break; }+ state["stageLabel"] = json!("waiting for Fusion to finish a previous command before binding"); save(&state);+ std::thread::sleep(Duration::from_secs(2));+ }+ let mut bind = json!({});+ for _ in 0..40 {+ bind = host.call("build_library_3d", json!({"lbrPath": target_lbr, "parts": parts, "outLbrPath": target_lbr, "async": false, "_cancelKey": run_id}), 3600);+ let berr = format!("{}{}", bind["error"].as_str().unwrap_or(""), bind["errorCode"].as_str().unwrap_or("")).to_lowercase();+ if bind["success"].as_bool() == Some(true) || (!berr.contains("main thread busy") && !berr.contains("main_thread_busy")) { break; }+ std::thread::sleep(Duration::from_secs(3));+ }+ state["bind"] = json!({"success": bind["success"], "error": bind["error"], "wip_urn": bind["wip_urn"], "reopened": bind["reopened"], "reopenError": bind["reopenError"],+ "parts": if bind["results"].is_null() { bind["parts"].clone() } else { bind["results"].clone() }, "partsBound": bind["partsBound"]});+ if bind["cancelled"].as_bool() == Some(true) || cancel_requested(&run_id).is_some() {+ let detail = format!("{} of {} part(s) were bound before the stop; their urns are in the library.", bind["partsBound"].as_u64().unwrap_or(0), parts.len());+ cancelled(&mut state, "binding 3D", &detail); return Err(());+ }+ if bind["success"].as_bool() != Some(true) {+ if state["bind"]["error"].is_null() || state["bind"]["error"].as_str() == Some("") {+ let u = state["bind"]["wip_urn"].as_str().unwrap_or("").to_string();+ state["bind"]["error"] = json!(if u.is_empty() { "bind failed and returned no reason and no urn".to_string() } else { format!("bind reported failure but gave no reason; a urn WAS minted ({u}) silently") });+ }+ let msg = format!("3D bind failed: {}", state["bind"]["error"].as_str().unwrap_or("").chars().take(300).collect::<String>());+ let code = bind["errorCode"].as_str().unwrap_or("bind_failed").to_string();+ fail(&mut state, &code, &msg); return Err(());+ }+ leave(&mut state, "bind");+ for cd in args["_cleanupAfterBind"].as_array().cloned().unwrap_or_default() {+ if let Some(p) = cd.as_str() { if p.starts_with(&std::env::temp_dir().to_string_lossy().to_string()) { let _ = std::fs::remove_dir_all(p); } }+ }+ }+ // ── prove ──+ enter(&mut state, "proving (four evidence frames)");+ let names = std::fs::read_to_string(&target_lbr).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()).unwrap_or_default();+ let want = args["name"].as_str().unwrap_or("").trim().to_string();+ let targets: Vec<String> = if !want.is_empty() { vec![want] } else if names.dev.is_empty() { vec![String::new()] } else { names.dev.clone() };+ state["partsToProve"] = json!(targets.len()); state["provedParts"] = json!([]);+ let dev_pac = names.dev_pac.clone();+ let uniq: std::collections::HashSet<Vec<String>> = dev_pac.values().cloned().collect();+ let uniq_pkgs = if uniq.is_empty() { targets.len() } else { uniq.len() };+ state["framesExpected"] = json!(4 * uniq_pkgs.min(targets.len()) + 2 * targets.len().saturating_sub(uniq_pkgs));+ let mut results: Vec<Value> = Vec::new();+ let mut pkg_proved_by: std::collections::HashMap<Vec<String>, String> = std::collections::HashMap::new();+ let mut last_beat = now();+ let wip = state["bind"]["wip_urn"].as_str().unwrap_or("").to_string();+ let lib_stem = lib_stem(&target_lbr).to_lowercase();+ let publish = |state: &mut Value, mut entry: Value, dev: &str, retry: bool| {+ entry["part"] = json!(dev); entry["kind"] = json!("proof"); if retry { entry["retry"] = json!(true); }+ let mut ev = state["evidence"].as_array().cloned().unwrap_or_default(); ev.push(entry.clone());+ let n = ev.len(); state["evidence"] = json!(ev); state["evidenceCount"] = json!(n); state["lastArtifact"] = entry["step"].clone();+ let tot = state["framesExpected"].as_u64().unwrap_or(0).max(1) as usize;+ state["stageLabel"] = json!(format!("part {} of {}: captured {} (frame {} of {})", state["partIndex"], state["partsToProve"], entry["step"].as_str().unwrap_or("?"), n, tot));+ let pct = 100.0 * (est_done.get() + est_of("prove") * n as f64 / tot as f64) / est_total;+ banner.send("update", &format!("{}: proving part {} of {}", lib_label(&target_lbr), state["partIndex"], state["partsToProve"]),+ &format!("{}. Captured {} (frame {} of {})", named(if dev.is_empty() { "library" } else { dev }, &descs), entry["step"].as_str().unwrap_or("?"), n, tot), Some(pct), Some(est_total - est_done.get() - est_of("prove") * n as f64 / tot as f64), "working");+ save(state);+ };+ let mut prove_one = |state: &mut Value, dev: &str, pi: usize, retry: bool| -> Value {+ let key: Vec<String> = dev_pac.get(dev).cloned().unwrap_or_default();+ let shared = if key.is_empty() || retry { None } else { pkg_proved_by.get(&key).cloned() };+ // frames stream through a sink file the sidecar appends to; we tail it while the+ // call is in flight so a poller sees each artifact the instant it lands+ let sink = std::env::temp_dir().join(format!("fusion-prove-frames-{}-{}.jsonl", run_id, md5_like(&format!("{dev}{retry}{}", now()))));+ let _ = std::fs::remove_file(&sink);+ let mut a = json!({"libraryPath": target_lbr, "name": dev, "f3dUrn": wip, "_frameSink": sink.to_string_lossy().replace('\\', "/")});+ if let Some(s) = &shared { a["_skipSteps"] = json!(["footprint", "package3d"]); a["_sharedWith"] = json!(s); }+ let (tx, rx) = std::sync::mpsc::channel::<Value>();+ let h2 = host.clone();+ std::thread::spawn(move || { let _ = tx.send(h2.call("prove_library", a, 3600)); });+ let mut seen = 0usize;+ let mut drain = |state: &mut Value| {+ let lines: Vec<String> = std::fs::read_to_string(&sink).map(|t| t.lines().filter(|l| !l.trim().is_empty()).map(str::to_string).collect()).unwrap_or_default();+ for l in lines.iter().skip(seen) { if let Ok(e) = serde_json::from_str::<Value>(l) { publish(state, e, dev, retry); } }+ seen = seen.max(lines.len());+ };+ let r = loop {+ match rx.recv_timeout(Duration::from_millis(1500)) {+ Ok(r) => { drain(state); break r; }+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => drain(state),+ Err(_) => break json!({"success": false, "proven": false, "error": "prove_library call thread ended without a result"}),+ }+ };+ let _ = std::fs::remove_file(&sink);+ if seen == 0 { for e in r["evidence"].as_array().cloned().unwrap_or_default() { publish(state, e, dev, retry); } }+ let proven = r["proven"].as_bool().unwrap_or(false);+ if proven && !key.is_empty() && shared.is_none() { pkg_proved_by.insert(key, dev.to_string()); }+ let closed = host.call("chain_close_docs_since", json!({"before": docs_before, "keep": [lib_stem]}), 120)["closed"].clone();+ let _ = pi;+ json!({"part": dev, "proven": proven, "error": r["error"], "sharedPackageProof": shared, "scratchClosed": closed})+ };+ for (i, dev) in targets.iter().enumerate() {+ let pi = i + 1;+ let gap = now() - last_beat;+ if gap > 900.0 {+ state["provedParts"] = json!(results);+ cancelled(&mut state, "proving", &format!("The machine was asleep or the bridge was frozen for {} minutes; {} of {} parts were proven before that. Run the import again for a full proof.", (gap / 60.0) as i64, pi - 1, targets.len()));+ state["errorCode"] = json!("interrupted_by_sleep"); state["cancelledBy"] = json!("sleep");+ state["error"] = json!(state["error"].as_str().unwrap_or("").replace("Cancelled by user while", "Stopped while")); save(&state); return Err(());+ }+ if cancel_requested(&run_id).is_some() {+ state["provedParts"] = json!(results);+ cancelled(&mut state, "proving", &format!("{} of {} parts were proven before the stop; the library keeps every binding. Run the import again for a full proof for every part", pi - 1, targets.len())); return Err(());+ }+ state["stageLabel"] = json!(format!("proving {} (part {} of {})", if dev.is_empty() { "library" } else { dev }, pi, targets.len()));+ state["partIndex"] = json!(pi); save(&state);+ let frac = (pi - 1) as f64 / targets.len().max(1) as f64;+ banner.send("update", &format!("{}: proving part {} of {}", lib_label(&target_lbr), pi, targets.len()),+ &format!("{}. Opening its symbol, footprint, 3D package and ganged view", named(if dev.is_empty() { "library" } else { dev }, &descs)),+ Some(100.0 * (est_done.get() + est_of("prove") * frac) / est_total), Some(est_total - est_done.get() - est_of("prove") * frac), "working");+ let r = prove_one(&mut state, dev, pi, false);+ results.push(r); state["provedParts"] = json!(results); last_beat = now(); save(&state);+ }+ let transient = ["ACTIVE document", "addin_command_timeout", "not responding", "main thread busy", "main_thread_busy"];+ let retry_idx: Vec<usize> = results.iter().enumerate().filter(|(_, r)| r["proven"].as_bool() != Some(true) && contains_any(r["error"].as_str().unwrap_or(""), &transient)).map(|(i, _)| i).collect();+ for i in retry_idx {+ if cancel_requested(&run_id).is_some() { break; }+ let dev = results[i]["part"].as_str().unwrap_or("").to_string();+ state["stageLabel"] = json!(format!("re-proving {dev} (its first pass hit a timing miss, not a part fault)")); save(&state);+ let r2 = prove_one(&mut state, &dev, i + 1, true);+ results[i]["proven"] = r2["proven"].clone(); results[i]["error"] = r2["error"].clone(); results[i]["retried"] = json!(true); results[i]["scratchClosed"] = r2["scratchClosed"].clone();+ state["provedParts"] = json!(results); save(&state);+ }+ let all_ok = results.iter().all(|r| r["proven"].as_bool() == Some(true));+ let prove = json!({"proven": all_ok, "parts": results, "partsProven": results.iter().filter(|r| r["proven"].as_bool() == Some(true)).count(), "partsTotal": results.len()});+ leave(&mut state, "prove");+ let frames = state["evidence"].as_array().map(|a| a.len()).unwrap_or(0);+ if frames == 0 {+ state["stage"] = json!("failed"); state["success"] = json!(false); state["verdict"] = json!("not_proven"); state["errorCode"] = json!("no_evidence_captured");+ state["error"] = json!("The chain ended with zero evidence frames: nothing was photographed, so the library is reported as not proven rather than done.");+ state["evidenceCount"] = json!(0); state["prove"] = prove;+ state["_hint"] = json!("stage is 'failed', not 'done', because zero of the four evidence frames exist; the bind likely never produced a document to photograph.");+ } else {+ state["stage"] = json!("done"); state["success"] = json!(all_ok); state["verdict"] = json!(if all_ok { "proven" } else { "not_proven" }); state["evidenceCount"] = json!(frames); state["prove"] = prove;+ }+ if let Some(pub_) = args["_publishToCloud"].as_object() {+ if state["bind"]["success"].as_bool() == Some(true) {+ state["stageLabel"] = json!("saving the library to your cloud Library Manager"); save(&state);+ let r = host.call("chain_publish_library", json!({"libraryPath": target_lbr, "name": pub_.get("name").cloned().unwrap_or(json!("")), "folder": pub_.get("folder").cloned().unwrap_or(json!("Adom Libraries")), "replaceExisting": pub_.get("replaceExisting").and_then(|v| v.as_bool()).unwrap_or(false)}), 300);+ let mut cl = r.clone(); if let Some(o) = cl.as_object_mut() { o.remove("success"); o.remove("_hint"); }+ state["cloudLibrary"] = cl; save(&state);+ }+ }+ let ok = state["stage"].as_str() == Some("done") && state["success"].as_bool() == Some(true);+ banner.send("update", if ok { "Library imported" } else { "Import did not complete" },+ &(if ok { format!("{} of {} parts proven", state["prove"]["partsProven"], state["prove"]["partsTotal"]) } else { state["error"].as_str().or(state["errorCode"].as_str()).unwrap_or("").chars().take(120).collect() }),+ if ok { Some(100.0) } else { None }, Some(0.0), if ok { "done" } else { "failed" });+ banner.hide_later(20);+ state["partMetadata"] = crate::orchestrations::part_metadata_report(&target_lbr);+ save(&state);+ Ok(())+ })();+ let _ = outcome;+ // ── finally: hygiene ──+ host.set_long_op(None);+ taskbar(&*host, None);+ let keep_open = args["_publishToCloud"].is_object();+ let stem_done = lib_stem(state["libraryPath"].as_str().unwrap_or(&lbr_p));+ if !stem_done.is_empty() && !keep_open {+ let c = host.call("chain_close_bridge_doc", json!({"name": stem_done}), 60);+ state["libraryDocClosed"] = json!(c["closed"].as_bool().unwrap_or(false)); save(&state);+ }+ let keep: Vec<String> = if keep_open { vec![stem_done.to_lowercase()] } else { vec![] };+ state["scratchDocsClosed"] = host.call("chain_close_docs_since", json!({"before": docs_before, "keep": keep}), 120)["closed"].clone(); save(&state);+ if keep_open && !stem_done.is_empty() {+ let _ = host.call("activate_document", json!({"name": stem_done}), 10);+ state["libraryDocClosed"] = json!(false); state["libraryLeftOpen"] = json!(true); save(&state);+ }+}++/// `fusion_install_library_bundle`: unpack, pair every deviceset with its STEP, place the+/// library under Documents/AdomLibraries, split one library per part, then the chain.+pub fn install_library_bundle(host: Arc<dyn ChainHost>, args: Value) -> Value {+ use fusion_lbr::*;+ let zip_p = args["zipPath"].as_str().unwrap_or("").replace('\\', "/");+ let lbr_in = args["lbrPath"].as_str().or(args["libraryPath"].as_str()).unwrap_or("").replace('\\', "/");+ if zip_p.is_empty() && lbr_in.is_empty() {+ return json!({"success": false, "errorCode": "no_input", "error": "Give me zipPath (a staged Cloud Library bundle) or lbrPath (a staged .lbr with its STEP models beside it).",+ "_hint": "Stage with write_file / write_file_chunk first; the path must exist on THIS machine. Then poll fusion_prove_result with the runId."});+ }+ let src = if zip_p.is_empty() { lbr_in.clone() } else { zip_p.clone() };+ if !std::path::Path::new(&src).exists() { return json!({"success": false, "errorCode": "not_found", "error": format!("Not on this machine: {src}"), "_hint": "Stage it with write_file / write_file_chunk first, then pass that path."}); }+ let mut lib_name = args["libraryName"].as_str().unwrap_or("").trim().to_string();+ let mut work: Option<std::path::PathBuf> = None;+ let result = (|| -> Value {+ let (lbr_found, models, all_lbrs);+ if !zip_p.is_empty() {+ if std::fs::metadata(&zip_p).map(|m| m.len() > MAX_ZIP_BYTES).unwrap_or(false) { return json!({"success": false, "errorCode": "too_large", "error": "Bundle zip is larger than the 256 MB cap"}); }+ let w = std::env::temp_dir().join(format!("adom-lib-bundle-{}", md5_like(&format!("{}", now()))));+ work = Some(w.clone());+ let n = match safe_extract(std::path::Path::new(&zip_p), &w) { Ok(n) => n, Err(e) => return json!({"success": false, "errorCode": "bad_bundle", "error": format!("Could not unpack the bundle: {}", e.to_string().chars().take(160).collect::<String>())}) };+ if n == 0 { return json!({"success": false, "errorCode": "bad_bundle", "error": "The bundle zip is empty"}); }+ let f = find_bundle_files(&w);+ let Some(l) = f.lbr else { return json!({"success": false, "errorCode": "no_lbr", "error": "No .lbr in the bundle", "_hint": "Expected an Adom Cloud Library bundle for Fusion: <Lib>.lbr plus <Part>.step models (beside it or under 3dmodels/)."}) };+ lbr_found = l; models = f.models; all_lbrs = f.all_lbrs;+ if lib_name.is_empty() { lib_name = std::path::Path::new(&zip_p).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); }+ } else {+ lbr_found = lbr_in.clone();+ let d = std::path::Path::new(&lbr_in).parent().map(|p| p.to_path_buf()).unwrap_or_else(|| ".".into());+ let f = find_bundle_files(&d); models = f.models; all_lbrs = f.all_lbrs;+ if lib_name.is_empty() { lib_name = std::path::Path::new(&lbr_in).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); }+ }+ if !library_name_ok(&lib_name) { return json!({"success": false, "errorCode": "bad_name", "error": format!("Library name not usable: {}", fusion_lbr::py_repr_str(&lib_name)), "_hint": "Letters, digits, space, dot, dash, underscore; up to 64 chars."}); }+ let pf = preflight_lbr(std::path::Path::new(&lbr_found));+ if !pf.ok { return json!({"success": false, "errorCode": "lbr_preflight_failed", "error": format!("The .lbr will not open in Fusion: {}", pf.errors.join("; ").chars().take(300).collect::<String>()), "preflight": pf}); }+ let xml = std::fs::read_to_string(&lbr_found).unwrap_or_default();+ let meta = match library_part_metadata(&xml) { Ok(m) => m, Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": e.to_string()}) };+ let mut pairing = pair_parts(&lbr_found, &models, &meta);+ let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();+ let dest = std::path::Path::new(&home).join("Documents").join("AdomLibraries").join(regex::Regex::new(r"\s+").unwrap().replace_all(&lib_name, "_").to_string());+ // carry prior 3D bindings from an earlier install of the same library+ let dest_lbr = dest.join(std::path::Path::new(&lbr_found).file_name().unwrap_or_default());+ let old_bindings: Vec<Binding> = std::fs::read_to_string(&dest_lbr).ok().map(|t| {+ let re = regex::Regex::new(r#"<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)""#).unwrap();+ re.captures_iter(&t).map(|c| Binding { package: c[1].to_string(), wip_urn: c[2].to_string() }).collect() }).unwrap_or_default();+ let (lbr_path, placed_models) = match place_library(std::path::Path::new(&lbr_found), &models, &dest) { Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "place_failed", "error": e.to_string()}) };+ if !old_bindings.is_empty() {+ if let Ok(t) = std::fs::read_to_string(&lbr_path) { let _ = std::fs::write(&lbr_path, inject_package3d_bindings(&t, &old_bindings)); eprintln!("[fusion-bridge] carried {} prior 3D binding(s) into the fresh install", old_bindings.len()); }+ }+ let split_dir = std::env::temp_dir().join(format!("adom-lib-parts-{}", md5_like(&lbr_path)));+ let placed_xml = std::fs::read_to_string(&lbr_path).unwrap_or_default();+ let split = match split_per_part(&placed_xml, &split_dir) { Ok(s) => s, Err(e) => return json!({"success": false, "errorCode": "split_failed", "error": e.to_string()}) };+ let mut parts: Vec<Value> = Vec::new();+ for p in &pairing.parts {+ let ds = if p.deviceset.is_empty() { p.package.clone() } else { p.deviceset.clone() };+ let sp = (if p.package.is_empty() { None } else { split.get(&format!("{ds}::{}", p.package)) }).or_else(|| split.get(&ds));+ match sp {+ Some(e) if !e.lbr_path.is_empty() && !e.package.is_empty() => parts.push(json!({"deviceset": ds, "package": e.package, "lbrPath": e.lbr_path, "modelPath": placed_models.get(&p.model_path).cloned().unwrap_or(p.model_path.clone()).replace('\\', "/"), "orient": false})),+ _ => pairing.unbound.push(Unbound { package: ds.clone(), deviceset: ds.clone(), device: String::new(), why: "could not split this deviceset into its own library (no package on its devices?)".into() }),+ }+ }+ let report = crate::orchestrations::part_metadata_report(&lbr_path.replace('\\', "/"));+ let plan = host.call("plan_library_import", json!({"parts": parts}), 20);+ let mut out = json!({"success": true, "installed": true, "libraryName": lib_name, "libraryPath": lbr_path, "installDir": dest.to_string_lossy().replace('\\', "/"),+ "partsTotal": meta.devicesets.len(), "partsToBind": parts.len(),+ "parts": parts.iter().map(|p| json!({"deviceset": p["deviceset"], "package": p["package"], "modelPath": p["modelPath"], "mintedOn": p["lbrPath"]})).collect::<Vec<_>>(),+ "mint": "each part minted on its own single-deviceset library so the generator loads that part's footprint; STEP placed as published (orient:false, origin trusted)",+ "unbound": pairing.unbound, "modelsUnused": pairing.models_unused.iter().map(|m| std::path::Path::new(m).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()).collect::<Vec<_>>(),+ "shared": pairing.shared, "partMetadata": report, "fusion": plan["fusion"], "plan": plan["plan"], "estimatedSec": plan["estimatedSec"],+ "otherLbrsInBundle": all_lbrs.iter().skip(1).map(|x| std::path::Path::new(x).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()).collect::<Vec<_>>(), "owner": "exe"});+ if args["capture"].as_bool() == Some(false) {+ out["started"] = json!(false); out["statusVerb"] = Value::Null;+ out["_hint"] = json!("capture:false - the library is placed at libraryPath and nothing was opened in Fusion. Call fusion_create_library {parts} later to bind and prove.");+ return out;+ }+ if parts.is_empty() {+ out["started"] = json!(false); out["errorCode"] = json!("nothing_to_bind");+ out["_hint"] = json!("Installed, but no deviceset could be paired with a STEP, so there is nothing to bind or prove. Add STEPs named like the deviceset or its package and call again.");+ return out;+ }+ let chain = create_library(host.clone(), json!({"libraryPath": lbr_path, "parts": parts, "_cleanupAfterBind": [split_dir.to_string_lossy()],+ "_publishToCloud": {"name": lib_name, "folder": "Adom Libraries", "replaceExisting": args["replaceExisting"].as_bool().unwrap_or(false)}}));+ for k in ["started", "attached", "runId", "statusVerb", "stage"] { out[k] = chain[k].clone(); }+ if chain["success"].as_bool() != Some(true) { out["success"] = json!(false); out["error"] = chain["error"].clone(); out["errorCode"] = chain["errorCode"].clone(); }+ out["_hint"] = json!("Installed to libraryPath. The bind+prove chain is running in the background (it outlives the relay window): poll fusion_prove_result {runId}. partsBound < partsTotal means named skips in unbound[].");+ out+ })();+ if let Some(w) = work { let _ = std::fs::remove_dir_all(w); }+ result+}++/// `fusion_prove_result`: the run store, with the measured progress block for a live run.+pub fn prove_result(args: &Value) -> Value {+ let rid = args["runId"].as_str().unwrap_or("").trim().to_string();+ let runs = read_runs();+ let mut res: Option<Value> = None;+ if !rid.is_empty() {+ res = runs.get(&rid).cloned();+ if res.is_none() {+ return json!({"success": false, "errorCode": "unknown_run_id", "error": format!("no prove run with runId {} on this box", fusion_lbr::py_repr_str(&rid)),+ "_hint": "runId comes from fusion_create_library's response. Omit runId for the latest completed run (single-caller convenience only)."});+ }+ }+ if res.is_none() { res = std::fs::read_to_string(last_path()).ok().and_then(|t| serde_json::from_str(&t).ok()); }+ let Some(mut r) = res else {+ return json!({"success": false, "errorCode": "no_prove_run", "error": "No fusion_prove_library run has completed on this box yet.",+ "_hint": "Run fusion_prove_library {libraryPath}; if the relay times out, the bridge keeps working - poll this verb until the result appears."});+ };+ if args["inlineFrames"].as_bool() != Some(true) {+ for key in ["evidence"] { if let Some(a) = r[key].as_array_mut() { for e in a { if let Some(o) = e.as_object_mut() { o.remove("thumbnailDataUrl"); } } } }+ if let Some(a) = r["prove"]["evidence"].as_array_mut() { for e in a { if let Some(o) = e.as_object_mut() { o.remove("thumbnailDataUrl"); } } }+ }+ if !matches!(r["stage"].as_str(), None | Some("done") | Some("failed")) && r["plan"].is_array() {+ let plan = r["plan"].as_array().cloned().unwrap_or_default();+ let total: f64 = plan.iter().map(|p| p["estimatedSec"].as_f64().unwrap_or(0.0)).sum::<f64>().max(1.0);+ let done: std::collections::HashSet<String> = r["stagesDone"].as_array().map(|a| a.iter().filter_map(|d| d["stage"].as_str().map(str::to_string)).collect()).unwrap_or_default();+ let done_est: f64 = plan.iter().filter(|p| done.contains(p["stage"].as_str().unwrap_or(""))).map(|p| p["estimatedSec"].as_f64().unwrap_or(0.0)).sum();+ let cur = plan.iter().find(|p| !done.contains(p["stage"].as_str().unwrap_or("")));+ let cur_elapsed = (now() - r["stageStartedAt"].as_f64().unwrap_or(now())).max(0.0);+ let cur_est = cur.and_then(|c| c["estimatedSec"].as_f64()).unwrap_or(1.0);+ let cur_credit = cur_elapsed.min(cur_est * 0.95);+ let pct = ((100.0 * (done_est + cur_credit) / total) as i64).min(95);+ let eta = (total - done_est - cur_credit).max(0.0) as i64;+ let confs: Vec<&str> = plan.iter().map(|p| p["confidence"].as_str().unwrap_or("")).collect();+ let conf = if confs.iter().all(|c| *c == "measured") { "measured" } else if confs.iter().all(|c| *c == "unknown") { "unknown" } else { "typical" };+ r["percent"] = json!(pct); r["etaSec"] = json!(eta);+ r["progress"] = json!({"percent": pct, "etaSec": eta, "elapsedSec": cur_elapsed as i64, "stepLabel": r["stage"], "estimatedSec": total as i64, "confidence": conf, "overdue": cur_elapsed > cur_est});+ }+ if !rid.is_empty() { return json!({"success": true, "result": r, "_hint": "Your run, from the per-run store (concurrent chains no longer overwrite each other)."}); }+ json!({"success": true, "result": r, "_hint": "The last completed prove run (survives relay timeouts and respawns)."})+}
rust/bridge/src/demo.rsadded+488@@ -0,0 +1,488 @@+//! `fusion_demo` (the staged first-time-user demo and the async six-content-type tour),+//! `fusion_demo_progress`, `fusion_demo_control`. The tour runs detached on a fresh context,+//! narrates each step with a desktop caption, answers the desktop remote (play, pause, next,+//! back, stop) between steps, and snapshots to disk so the done frame survives a respawn.+use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};+use serde_json::{json, Value};+use std::collections::HashMap;+use std::sync::{Arc, Mutex};+use std::time::{Duration, Instant};++pub static DEMO_TOURS: Mutex<Option<HashMap<String, Value>>> = Mutex::new(None);+const TOUR_PANEL_ID: &str = "fusion-tour";+const VIEW_STEPS: &[&str] = &["symbol", "footprint", "device", "schematic", "2d-board", "3d-board", "library"];+const TOUR_PRESET_ELECTRONICS: &[&str] = &["launch", "stage", "library", "symbol", "footprint", "3d-package", "device", "make-project", "open-design", "schematic", "2d-board", "3d-board"];++fn now() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }+fn sleep_s(s: f64) { std::thread::sleep(Duration::from_secs_f64(s)); }+fn ab_call(caller: &fusion_core::ab::Caller, verb: &str, args: Value, timeout: u64) -> Value {+ match Loopback::from_env() { Some(ab) => match ab.call(verb, args, caller, timeout) { Ok(v) => v, Err(e) => json!({"_adCallError": format!("{e:?}")}) }, None => json!({"_adCallError": "ab loopback unavailable"}) }+}+fn tour_explain(key: &str) -> Option<&'static str> {+ Some(match key {+ "launch" => "Starting Fusion in the background. The bridge launches it detached and minimized, takes the one Autodesk seat if another machine holds it, and never steals your focus - and the AdomBridge add-in loads itself inside Fusion and opens a local command port the bridge drives everything through.",+ "stage" => "Staging the sample part. This library was built from open KiCad source by adom dash l b r and lands on disk as a real EAGLE library file Fusion can open natively.",+ "library" => "Opening the electronics library. The bridge drives Fusion's own Electronics Library editor through the add-in - no clicks, no foreground, the same path a human would take.",+ "symbol" => "The symbol editor: the part's full pinout, converted pin for pin from KiCad source. The add-in switches views with Fusion's own edit commands.",+ "footprint" => "The footprint editor: real pads and layers, exactly the copper a board would get - never a placeholder.",+ "device" => "The component view: every symbol pin mapped to its footprint pad. The bridge verifies this mapping offline before Fusion ever opens the file, so dead wiring cannot photograph well.",+ "3d-package" => "The 3D chip - the showpiece. The bridge imports the part's STEP model onto the footprint in Fusion's 3D package generator, orients it flat automatically, checks pin one against pad one, saves it into your Autodesk cloud hub, and binds the resulting cloud model into the library, so every future board carries the real body. This takes a couple of minutes; it is real work, not a preview.",+ "datahub" => "Your Fusion Data Hub, through the add-in: hubs, projects and folders listed over Fusion's own cloud API - the same storage the 3D chip was just saved into.",+ "aps" => "APS cloud search: the bridge's own server-indexed search over your Fusion cloud files, about two seconds per query after a single sign-in, and never charged.",+ "mcp" => "Autodesk's official MCP server, answering over the bridge. Useful - but its toolset is small and read-mostly. The bridge's one hundred plus fusion verbs go far beyond it: electronics editors, text commands, cloud files, evidence captures and a dozen export formats.",+ "make-project" => "Generating a complete test project around the part: schematic and board authored from EAGLE source and instantiated by Fusion, entirely in the background.",+ "open-design" => "Opening the cloud electronics design from your hub - the bridge resolves it by name and opens the parent design, never a child view.",+ "schematic" => "The schematic view of the generated project - the part wired into a real sheet.",+ "2d-board" => "The 2D board layout - the same footprint you saw in the library, now placed on copper.",+ "3d-board" => "The 3D board - and the chip body on it is the exact cloud model the bridge minted and bound earlier in this tour.",+ "3d-board-unbound" => "The 3D board. The 3D bind did not complete on this run, so the chip shows Fusion's placeholder outline here; the library import chain binds the real body.",+ "exports" => "Exports: STEP and STL written straight from the add-in. The same pipeline covers F 3 D, G L B, 3 M F, IGES, SAT, D X F, gerbers and BOM - every format a downstream tool could ask for.",+ _ => return None,+ })+}++fn tours_dir(state_dir: &std::path::Path) -> std::path::PathBuf { let d = state_dir.join("tours"); let _ = std::fs::create_dir_all(&d); d }+/// `_demo_tour_persist`: snapshot a FINISHED tour so its done frame is pollable across respawns; keep the last 8.+fn persist(state_dir: &std::path::Path, state: &Value) {+ let d = tours_dir(state_dir);+ let mut snap = state.clone(); if let Some(o) = snap.as_object_mut() { o.remove("started"); }+ let _ = std::fs::write(d.join(format!("{}.json", state["demoId"].as_str().unwrap_or("tour"))), snap.to_string());+ let mut old: Vec<(std::time::SystemTime, std::path::PathBuf)> = std::fs::read_dir(&d).map(|rd| rd.flatten().filter(|e| e.file_name().to_string_lossy().starts_with("tour-") && e.path().extension().map(|x| x == "json").unwrap_or(false)).filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path()))).collect()).unwrap_or_default();+ old.sort();+ if old.len() > 8 { for (_, p) in &old[..old.len() - 8] { let _ = std::fs::remove_file(p); } }+}+fn load(state_dir: &std::path::Path, id: &str) -> Option<Value> { std::fs::read_to_string(tours_dir(state_dir).join(format!("{id}.json"))).ok().and_then(|t| serde_json::from_str(&t).ok()) }+fn get_state(id: &str) -> Option<Value> {+ let g = DEMO_TOURS.lock().unwrap(); let m = g.as_ref()?;+ if let Some(s) = m.get(id) { return Some(s.clone()); }+ if id.is_empty() && m.len() == 1 { return m.values().next().cloned(); }+ None+}+fn set_state(id: &str, s: Value) { let mut g = DEMO_TOURS.lock().unwrap(); g.get_or_insert_with(HashMap::new).insert(id.to_string(), s); }+fn update_state(id: &str, f: impl FnOnce(&mut Value)) { let mut g = DEMO_TOURS.lock().unwrap(); if let Some(s) = g.get_or_insert_with(HashMap::new).get_mut(id) { f(s); } }+fn push_to(s: &mut Value, key: &str, v: Value) { let mut a = s[key].as_array().cloned().unwrap_or_default(); a.push(v); s[key] = json!(a); }+/// `_shot_b64_small`: an evidence frame small enough for the relay envelope (the raw file when it is).+pub fn shot_b64_small(path: &str) -> Option<String> {+ let raw = std::fs::read(path).ok()?;+ if raw.len() <= 1_500_000 { Some(crate::etiquette::b64_pub(&raw)) } else { None }+}+fn sample_lbr() -> Option<String> {+ let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()))?;+ let src = dir.join("resources").join("samples").join("ADS8588SIPM.fusion.lbr");+ if !src.is_file() { return None; }+ let d = std::env::temp_dir().join("adom-fusion-wrap"); let _ = std::fs::create_dir_all(&d);+ let step = src.with_file_name("ADS8588SIPM.step"); if step.is_file() { let _ = std::fs::copy(&step, d.join("AdomDemoSample.step")); }+ let dst = d.join("AdomDemoSample.lbr");+ std::fs::copy(&src, &dst).ok()?;+ Some(dst.to_string_lossy().replace('\\', "/"))+}+fn path_sibling(path: &str, ext: &str) -> Option<String> { let p = std::path::Path::new(path).with_extension(ext.trim_start_matches('.')); if p.is_file() { Some(p.to_string_lossy().replace('\\', "/")) } else { None } }++/// `fusion_demo_progress`.+pub fn demo_progress(state_dir: &std::path::Path, args: &Value) -> Value {+ let id = args["demoId"].as_str().unwrap_or("").to_string();+ let mut persisted = false;+ let state = match get_state(&id) { Some(s) => Some(s), None if !id.is_empty() => { let l = load(state_dir, &id); persisted = l.is_some(); l } None => None };+ let Some(state) = state else { return json!({"success": false, "error": format!("unknown demoId {id}"), "_hint": "Start a tour first: fusion_demo {\"mode\": \"tour\"}. Its response carries the demoId. Finished tours stay pollable (disk snapshot) even across bridge respawns - an unknown id here means it never ran on this box."}) };+ let mut out = json!({"success": true});+ if let Some(o) = state.as_object() { for (k, v) in o { if k != "started" { out[k] = v.clone(); } } }+ if persisted { out["fromSnapshot"] = json!(true); }+ if !persisted && args["sinceShot"].is_null() {+ let sent = state["stepShotSent"].as_u64().unwrap_or(0) as usize;+ let all = state["shots"].as_array().cloned().unwrap_or_default();+ if sent < all.len() {+ let s = &all[sent];+ let img = shot_b64_small(s["path"].as_str().unwrap_or(""));+ update_state(&id, |st| st["stepShotSent"] = json!(sent + 1));+ if let Some(img) = img { out["stepShot"] = json!({"step": s["label"], "title": if s["title"].is_null() { s["label"].clone() } else { s["title"].clone() }, "image": img}); }+ }+ }+ let mut shots = out["shots"].as_array().cloned().unwrap_or_default();+ out["shotCount"] = json!(shots.len());+ let since = args["sinceShot"].as_u64().unwrap_or(0) as usize;+ if since > 0 { shots = shots.into_iter().skip(since).collect(); }+ let imgs = args["imagesAs"].as_str().map(str::to_string).unwrap_or_else(|| if state["done"].as_bool() == Some(true) { "base64".into() } else { "paths".into() });+ if imgs == "base64" || imgs == "both" {+ shots = shots.into_iter().map(|mut s| { if let Some(raw) = s["path"].as_str().and_then(|p| std::fs::read(p).ok()) { if raw.len() <= 4 * 1024 * 1024 { s["image"] = json!(crate::etiquette::b64_pub(&raw)); } else { s["imageNote"] = json!(format!("capture is {}MB; pull_file the path for full size", raw.len() / (1024 * 1024))); } } s }).collect();+ }+ out["shots"] = json!(shots);+ if state["done"].as_bool() != Some(true) { out["_hint"] = json!(format!("Tour running ({}). shots[] carries evidence for every COMPLETED step already - render it live; pass sinceShot={} on the next poll to receive only new shots.", state["stepLabel"].as_str().unwrap_or(""), out["shotCount"])); }+ out+}++/// `fusion_demo_control`: pause | resume | next | prev | exit, at the next step boundary.+pub fn demo_control(args: &Value) -> Value {+ let id = args["demoId"].as_str().unwrap_or("").to_string();+ let action = args["action"].as_str().unwrap_or("").to_lowercase();+ let Some(state) = get_state(&id).filter(|s| s["done"].as_bool() != Some(true)) else { return json!({"success": false, "error": format!("no running tour{}", if id.is_empty() { String::new() } else { format!(" with id {id}") }), "_hint": "Controls only apply while a tour is running."}) };+ let real_id = state["demoId"].as_str().unwrap_or("").to_string();+ let mut paused = false;+ let ok = match action.as_str() {+ "pause" => { update_state(&real_id, |s| s["paused"] = json!(true)); paused = true; true }+ "resume" => { update_state(&real_id, |s| s["paused"] = json!(false)); true }+ "next" => { update_state(&real_id, |s| { s["skipNext"] = json!(true); s["paused"] = json!(false); }); true }+ "prev" => { update_state(&real_id, |s| { s["replayPrev"] = json!(true); s["paused"] = json!(false); }); true }+ "exit" => { update_state(&real_id, |s| { s["abort"] = json!(true); s["paused"] = json!(false); }); true }+ _ => false,+ };+ if !ok { return json!({"success": false, "error": "action must be pause|resume|next|prev|exit"}); }+ json!({"success": true, "action": action, "paused": paused, "_hint": "Takes effect at the next step boundary - a running Fusion verb finishes first."})+}++/// What the tour thread needs from the bridge around it (owned; the thread outlives the request).+pub struct TourEnv { pub readiness: Arc<dyn Fn() -> Value + Send + Sync>, pub aps_state: Arc<dyn Fn() -> (bool, bool) + Send + Sync>, pub version: &'static str }++fn panel_show(caller: &fusion_core::ab::Caller, id: &str, stops: &[String], stage: usize, playing: bool) {+ let _ = ab_call(caller, "desktop_demo_panel", json!({"action": "show", "id": TOUR_PANEL_ID, "title": "Fusion bridge tour", "stops": stops, "stage": stage, "state": if playing { "playing" } else { "paused" }, "position": "bottom-right", "reason": "Guided Fusion tour: this remote pauses, skips, or replays the tour steps"}), 10);+ update_state(id, |s| s["remote"] = json!({"shown": true, "stage": stage}));+}+fn panel_pump(caller: &fusion_core::ab::Caller, id: &str) -> Vec<String> {+ let r = ab_call(caller, "desktop_demo_panel", json!({"action": "poll", "id": TOUR_PANEL_ID}), 8);+ let events = crate::managed::find_key(&r, "events", 0).and_then(|e| e.as_array().cloned()).unwrap_or_default();+ let seen: Vec<String> = events.iter().map(|e| e["button"].as_str().unwrap_or("").to_lowercase()).collect();+ if !seen.is_empty() { update_state(id, |s| { for b in &seen { match b.as_str() { "pause" => s["paused"] = json!(true), "play" => s["paused"] = json!(false), "next" => { s["skipNext"] = json!(true); s["paused"] = json!(false); } "back" => { s["replayPrev"] = json!(true); s["paused"] = json!(false); } "stop" => { s["abort"] = json!(true); s["paused"] = json!(false); } _ => {} } } let mut p = s["remotePresses"].as_array().cloned().unwrap_or_default(); p.extend(seen.iter().rev().take(8).rev().map(|b| json!(b))); s["remotePresses"] = json!(p); }); }+ seen+}+fn tour_caption(caller: &fusion_core::ab::Caller, text: &str, seconds: u64) { let _ = ab_call(caller, "desktop_caption", json!({"text": text, "id": TOUR_PANEL_ID, "duration": (seconds * 2000).clamp(15000, 240000)}), 10); }+fn take_flag(id: &str, key: &str) -> bool { let mut got = false; update_state(id, |s| { if s[key].as_bool() == Some(true) { got = true; s.as_object_mut().map(|o| o.remove(key)); } }); got }++/// `fusion_demo {mode:"tour"}`: start the async tour; poll fusion_demo_progress {demoId}.+pub fn demo_tour(ctx: &Ctx, args: &Value, env: TourEnv, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut idb = [0u8; 4]; let _ = getrandom::getrandom(&mut idb);+ let demo_id = format!("tour-{:08x}", u32::from_le_bytes(idb));+ let include_design = args["includeDesign"].as_bool().unwrap_or(true);+ let query = args["query"].as_str().unwrap_or("").to_string();+ let cold = pass("get_app_state", json!({}))["success"].as_bool() != Some(true);+ let sd = ctx.state_dir.clone();+ let est = |phase: &str, seeded: f64| -> u64 { crate::chains::estimate(&sd, phase, seeded).0.round() as u64 };+ let mut steps: Vec<(String, String, u64)> = Vec::new();+ if cold { steps.push(("launch".into(), "Starting Fusion (cold start)".into(), est("launch", 120.0))); }+ for (k, l, s) in [("stage", "Staging the sample library", 3.0), ("library", "Opening the electronics library", 35.0), ("symbol", "Symbol: full pinout in the symbol editor", 8.0), ("footprint", "Footprint: pads + layers in the footprint editor", 8.0), ("device", "Component view: symbol + package mapping", 8.0), ("3d-package", "3D chip: minted from the STEP, saved to your hub, bound into the library", 150.0), ("datahub", "Data Hub: your cloud projects through the add-in", 10.0), ("aps", "APS cloud search: ~2s indexed lookups", 8.0), ("mcp", "MCP: Autodesk's own AI server (and its limits)", 10.0)] { steps.push((k.into(), l.into(), est(k, s))); }+ if include_design {+ if !query.is_empty() { steps.push(("open-design".into(), "Opening the cloud electronics design".into(), est("open-design", 150.0))); } else { steps.push(("make-project".into(), "Generating + importing a test project around the sample part".into(), est("make-project", 60.0))); }+ for (k, l, s) in [("schematic", "Schematic view", 12.0), ("2d-board", "2D board layout", 12.0), ("3d-board", "3D board", 20.0), ("exports", "Exporting: STEP + STL (of a dozen formats)", 25.0)] { steps.push((k.into(), l.into(), est(k, s))); }+ }+ if args["preset"].as_str().map(|p| p.to_lowercase()) == Some("electronics".into()) {+ let keep = TOUR_PRESET_ELECTRONICS;+ let mut kept: Vec<_> = steps.into_iter().filter(|s| keep.contains(&s.0.as_str())).collect();+ kept.sort_by_key(|s| keep.iter().position(|k| *k == s.0).unwrap_or(99));+ steps = kept;+ }+ let remote = args["remote"].as_bool().unwrap_or(false);+ let total_est: u64 = steps.iter().map(|s| s.2).sum();+ let state = json!({"demoId": demo_id, "percent": 0, "elapsedSec": 0, "estimatedSec": total_est, "step": steps[0].0, "stepLabel": steps[0].1, "done": false, "failed": false, "steps": steps.iter().map(|s| s.0.clone()).collect::<Vec<_>>(), "shots": [], "started": now()});+ set_state(&demo_id, state);+ let (id, caller, args2, steps2) = (demo_id.clone(), ctx.caller.clone(), args.clone(), steps.clone());+ let fg = args["foreground"].as_bool().unwrap_or(false);+ let captions = args["captions"].as_bool().unwrap_or(true);+ let sd2 = sd.clone();+ std::thread::spawn(move || {+ let host = crate::host_for_diagnosis();+ let side_url = crate::launch::SIDECAR_URL.lock().unwrap().clone();+ let side = move |verb: &str, a: Value, t: u64| -> Value { match &side_url { Some(u) => ureq::post(u).timeout(Duration::from_secs(t)).send_json(json!({"command": verb, "args": a})).ok().and_then(|r| r.into_json().ok()).unwrap_or(json!({"success": false, "error": "sidecar unreachable"})), None => json!({"success": false, "error": "no sidecar"}) } };+ let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx2 = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd2.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg };+ let pass2 = |verb: &str, a: Value| crate::addin::pass_through(verb, &a, &ctx2);+ let started = now();+ if !fg { host.background_envelope(total_est as f64 + 60.0, false); }+ let docs_before = crate::orchestrations::open_doc_names(&pass2);+ let stop_labels: Vec<String> = steps2.iter().map(|s| s.1.clone()).collect();+ let pump_stop = Arc::new(std::sync::atomic::AtomicBool::new(false));+ let step_ref = Arc::new(std::sync::atomic::AtomicUsize::new(0));+ if remote {+ panel_show(&caller, &id, &stop_labels, 0, true);+ let (ps, sr, c2, id2, labels) = (pump_stop.clone(), step_ref.clone(), caller.clone(), id.clone(), stop_labels.clone());+ std::thread::spawn(move || { let mut last: Option<bool> = None; while !ps.load(std::sync::atomic::Ordering::Relaxed) { let seen = panel_pump(&c2, &id2); let face = get_state(&id2).map(|s| s["paused"].as_bool() == Some(true)).unwrap_or(false); if !seen.is_empty() || Some(face) != last { panel_show(&c2, &id2, &labels, sr.load(std::sync::atomic::Ordering::Relaxed).saturating_sub(1), !face); last = Some(face); } sleep_s(2.0); } });+ }+ let snap = |label: &str| { let r = crate::small_verbs::screenshot_fusion(&json!({}), &caller); if let Some(p) = r["savedTo"].as_str().or(r["localSafePath"].as_str()).filter(|_| r["success"].as_bool() == Some(true)) { let p = p.to_string(); update_state(&id, |s| push_to(s, "shots", json!({"title": label, "label": label, "path": p}))); } };+ let wait_idle = |max_s: f64| -> bool { let deadline = Instant::now() + Duration::from_secs_f64(max_s); while Instant::now() < deadline { if pass2("get_app_state", json!({}))["success"].as_bool() == Some(true) { return true; } sleep_s(5.0); } false };+ let mut done_est: u64 = 0;+ let mut last_view: Option<(String, String, u64)> = None;+ let mut i = 0usize;+ let mut sample_lbr = String::new(); let mut deviceset = String::new(); let mut bound3d = false; let mut design_open_failed = false;+ let result: Result<(), String> = (|| {+ while i < steps2.len() {+ let (mut key, mut label, mut dur) = steps2[i].clone();+ i += 1;+ if remote { step_ref.store(i, std::sync::atomic::Ordering::Relaxed); let paused = get_state(&id).map(|s| s["paused"].as_bool() == Some(true)).unwrap_or(false); panel_show(&caller, &id, &stop_labels, i - 1, !paused); }+ if take_flag(&id, "abort") { return Err("tour exited by the user (transport control)".into()); }+ while get_state(&id).map(|s| s["paused"].as_bool() == Some(true) && s["abort"].as_bool() != Some(true)).unwrap_or(false) { update_state(&id, |s| { s["stepLabel"] = json!("Paused - press play to continue"); s["stepCaption"] = json!("Paused."); }); sleep_s(1.5); }+ if take_flag(&id, "abort") { return Err("tour exited by the user (transport control)".into()); }+ if take_flag(&id, "skipNext") { update_state(&id, |s| push_to(s, "skippedSteps", json!(key))); done_est += dur; continue; }+ if take_flag(&id, "replayPrev") { if let Some(lv) = last_view.clone() { i -= 1; key = lv.0; label = lv.1; dur = lv.2; } }+ if VIEW_STEPS.contains(&key.as_str()) { last_view = Some((key.clone(), label.clone(), dur)); }+ if !fg { host.background_envelope(dur as f64 * 3.0 + 240.0, false); }+ let step_started = now();+ let explain = if key == "3d-board" && !bound3d { tour_explain("3d-board-unbound").unwrap() } else { tour_explain(&key).unwrap_or(label.as_str()) };+ if captions { tour_caption(&caller, explain, dur); }+ let (conf, _) = (crate::chains::estimate(&sd2, &key, dur as f64).1, 0);+ let pct = (100 * done_est / total_est.max(1)) as u64; let el = (now() - started) as u64; let eta = total_est.saturating_sub(el);+ update_state(&id, |s| { s["step"] = json!(key); s["stepLabel"] = json!(label); s["stepCaption"] = json!(explain); s["percent"] = json!(pct); s["elapsedSec"] = json!(el); s["estimatedSec"] = json!(total_est); s["etaSec"] = json!(eta); s["confidence"] = json!(conf); s["startKind"] = json!(if cold { "cold" } else { "warm" });+ s["progress"] = json!({"phase": key, "stepLabel": label, "percent": pct, "elapsedSec": el, "estimatedSec": total_est, "etaSec": eta, "confidence": conf, "startKind": if cold { "cold" } else { "warm" }}); });+ match key.as_str() {+ "launch" => {+ let _ = crate::launch::start(&ctx2, &json!({}));+ let mut ready = false; let mut seat_attempts = 0;+ for _ in 0..30 {+ sleep_s(5.0);+ if pass2("get_app_state", json!({}))["success"].as_bool() == Some(true) { ready = true; break; }+ let _ = crate::etiquette::dismiss_save_prompt_if_bridge_doc(&sd2, &caller, &pids, "");+ let lic = Loopback::from_env().map(|ab| fusion_core::windows_facts::facts_from_ab(&ab, &caller).licensing_dialog.is_some()).unwrap_or(false);+ if lic {+ let auto = crate::small_verbs::get_pref(&sd2, "autoTakeOverSeat").map(|v| v != json!(false)).unwrap_or(true);+ if auto {+ update_state(&id, |s| s["stepLabel"] = json!("Taking over the Autodesk seat (another machine holds it)"));+ let r = crate::readiness_exec::resolve_seat_dialog(&ctx2, 3); seat_attempts += 1;+ if r["clicks"].as_u64().unwrap_or(0) > 0 { update_state(&id, |s| { s["seatTakeover"] = json!(true); push_to(s, "warnings", json!("license_sessions_exceeded: took the seat over in the background (the other machine's Fusion suspended resumably)")); }); }+ let still = Loopback::from_env().map(|ab| fusion_core::windows_facts::facts_from_ab(&ab, &caller).licensing_dialog.is_some()).unwrap_or(false);+ if seat_attempts >= 3 && still { return Err("license_sessions_exceeded: the Autodesk seat is held on another machine and background takeover did not land. Free it there (fusion_stop on that box) and re-run the tour.".into()); }+ } else { return Err("license_sessions_exceeded: seat held on another machine and auto-takeover is opted out (fusion_set_seat_takeover). Resolve the 'Active Sessions Exceeded' dialog or re-enable takeover, then re-run.".into()); }+ }+ }+ if !ready {+ let rd = (env.readiness)();+ if rd["needsSignin"].as_bool() == Some(true) {+ update_state(&id, |s| { s["step"] = json!("signin"); s["stepLabel"] = json!("Signing in (one-time, driving it for you)"); });+ let readiness = || (env.readiness)(); let start = || crate::launch::start(&ctx2, &json!({})); let aps_state = || (env.aps_state)(); let aps_signin = |p: &str| crate::aps_signin::signin(&ctx2, &json!({"profile": p}));+ let si = crate::signin::signin(&ctx2, &json!({"auto": true}), &crate::signin::Env { readiness: &readiness, start: &start, aps_state: &aps_state, aps_signin: &aps_signin });+ let wall = si["signinWall"].as_str().unwrap_or("credentials").to_string();+ update_state(&id, |s| push_to(s, "warnings", json!(format!("sign-in required: drove to wall {}; user toasted; {}", fusion_lbr::py_repr_str(&wall), si["stage"].as_str().unwrap_or("None")))));+ return Err(format!("One-time Autodesk sign-in needed on this machine. I drove it as far as possible and toasted the user at the {} step - after they finish, re-run the tour and it completes.", fusion_lbr::py_repr_str(&wall)));+ }+ return Err("Fusion launched but its add-in never became ready (150s)".into());+ }+ }+ "stage" => {+ let custom = args2["sampleLbr"].as_str().unwrap_or("").replace('\\', "/");+ if !custom.is_empty() {+ if !std::path::Path::new(&custom).is_file() { return Err(format!("sampleLbr not found on this machine: {custom}")); }+ sample_lbr = custom.clone();+ deviceset = std::fs::read_to_string(&custom).ok().and_then(|t| regex::Regex::new(r#"<deviceset name="([^"]+)""#).ok().and_then(|re| re.captures(&t).map(|c| c[1].to_string()))).unwrap_or_else(|| "UNKNOWN".into());+ } else {+ sample_lbr = sample_lbr_or(|| Err("bundled sample library missing from the bridge install".to_string()))?;+ deviceset = "ADS8588SIPM".into();+ }+ let (sl, ds) = (sample_lbr.clone(), deviceset.clone()); update_state(&id, |s| { s["sampleLbr"] = json!(sl); s["deviceset"] = json!(ds); });+ }+ "library" => { let r = crate::orchestrations::open_lbr(&ctx2, &json!({"filePath": sample_lbr}), &pass2); if r["success"].as_bool() != Some(true) { return Err(format!("open_lbr: {}", r["error"].as_str().unwrap_or("None"))); } snap(&label); }+ "symbol" | "footprint" | "device" => {+ let suffix = match key.as_str() { "symbol" => "sym", "footprint" => "pac", _ => "dev" };+ let r = crate::orchestrations::show_library_view(&ctx2, if deviceset.is_empty() { "ADS8588SIPM" } else { &deviceset }, suffix, Some(&sample_lbr), &pass2);+ if r["success"].as_bool() != Some(true) { return Err(r["error"].as_str().unwrap_or("None").to_string()); }+ if let Some(p) = r["screenshot"].as_str() { let p = p.to_string(); let l = label.clone(); update_state(&id, |s| push_to(s, "shots", json!({"title": l, "label": l, "path": p}))); }+ }+ "3d-package" => {+ let step_path = path_sibling(&sample_lbr, ".step");+ let pac = std::fs::read_to_string(&sample_lbr).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()).and_then(|n| n.pac.first().cloned());+ let mut bound = false;+ if let (Some(sp), Some(pac)) = (step_path, pac) {+ wait_idle(180.0);+ let br = crate::mint::build_library_3d_core(&ctx2, &json!({"lbrPath": sample_lbr, "parts": [{"package": pac, "lbrPath": sample_lbr, "modelPath": sp}], "outLbrPath": sample_lbr, "capture": true}), &pass2, &|_, _, _| {});+ bound = br["success"].as_bool() == Some(true) && br["partsBound"].as_u64().unwrap_or(0) >= 1;+ let l = label.clone(); let shot = br["screenshots"].as_array().and_then(|a| a.iter().find(|s| matches!(s["stage"].as_str(), Some("after") | Some("3d")) && s["path"].is_string()).cloned());+ update_state(&id, |s| { s["bind"] = json!({"success": bound, "partsBound": br["partsBound"], "error": br["error"]}); if let Some(sh) = shot { push_to(s, "shots", json!({"title": l, "label": l, "path": sh["path"]})); } });+ }+ bound3d = bound; update_state(&id, |s| s["bound3d"] = json!(bound));+ if bound {+ wait_idle(180.0);+ let _ = crate::orchestrations::open_lbr(&ctx2, &json!({"filePath": sample_lbr}), &pass2);+ if !get_state(&id).map(|s| s["shots"].as_array().map(|a| a.iter().any(|x| x["title"].as_str() == Some(&label))).unwrap_or(false)).unwrap_or(false) { snap(&label); }+ } else {+ let why = get_state(&id).and_then(|s| s["bind"]["error"].as_str().map(str::to_string)).unwrap_or_else(|| "no STEP staged".into());+ update_state(&id, |s| push_to(s, "warnings", json!(format!("3D bind did not complete ({why}); showing the package generator instead"))));+ let cp = crate::orchestrations::create_3d_package_pub(&ctx2, &sample_lbr, &pass2);+ if cp["success"].as_bool() == Some(true) {+ sleep_s(3.0); snap(&label);+ let _ = pass2("run_modeling_script", json!({"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"}));+ sleep_s(2.0);+ let _ = crate::small_verbs::dismiss_blocking_dialogs(&sd2, &json!({}), &caller, &pids, &|_| {});+ let _ = pass2("close_document", json!({"save": false})); sleep_s(1.0);+ } else { update_state(&id, |s| push_to(s, "failedSteps", json!(key))); }+ }+ }+ "make-project" => {+ wait_idle(180.0);+ let r = crate::testproject::make_test_project(&ctx2, &json!({"libraryPath": sample_lbr}), &pass2);+ if r["success"].as_bool() == Some(true) { sleep_s(3.0); snap(&label); } else { design_open_failed = true; update_state(&id, |s| { push_to(s, "failedSteps", json!(key)); push_to(s, "warnings", json!(format!("make-project: {}", r["error"].to_string().chars().take(120).collect::<String>()))); s["designOpenFailed"] = json!(true); }); }+ }+ "open-design" => {+ wait_idle(180.0);+ let _ = crate::orchestrations::open_cloud_file(&ctx2, &json!({"fileName": query, "wait": false}), &pass2);+ let deadline = Instant::now() + Duration::from_secs(170); let mut open = false;+ while Instant::now() < deadline { let st = pass2("get_app_state", json!({})); if st["data"]["activeDocument"].as_str().unwrap_or("").to_lowercase().contains(&query.to_lowercase()) { open = true; break; } sleep_s(5.0); }+ if open { sleep_s(3.0); snap(&label); } else { design_open_failed = true; update_state(&id, |s| { push_to(s, "failedSteps", json!(key)); push_to(s, "warnings", json!(format!("open-design: {} never became the active document within 170s", fusion_lbr::py_repr_str(&query)))); s["designOpenFailed"] = json!(true); }); }+ }+ "datahub" => {+ wait_idle(180.0);+ let r = pass2("list_cloud_projects", json!({}));+ if r["success"].as_bool() == Some(true) { let mut projs = r["data"]["projects"].as_array().or(r["data"]["dataProjects"].as_array()).cloned().unwrap_or_default(); if projs.is_empty() { for hub in r["data"]["hubs"].as_array().cloned().unwrap_or_default() { projs.extend(hub["projects"].as_array().cloned().unwrap_or_default()); } } let names: Vec<String> = projs.iter().take(6).map(|p| p["name"].as_str().map(str::to_string).unwrap_or_else(|| p.to_string()).chars().take(40).collect()).collect(); update_state(&id, |s| s["highlights"]["datahub"] = json!({"projects": projs.len(), "names": names})); }+ else { update_state(&id, |s| push_to(s, "warnings", json!(format!("datahub: {}", r["error"].to_string().chars().take(100).collect::<String>())))); }+ }+ "aps" => {+ let q: String = deviceset.rsplit('/').next().unwrap_or("ADS8588").chars().take(20).collect();+ let t0 = now();+ let r = crate::native_aps_search(&json!({"query": q, "limit": 5}));+ if r["success"].as_bool() == Some(true) { let hits = r["results"].as_array().or(r["items"].as_array()).or(r["data"]["results"].as_array()).map(|a| a.len()).unwrap_or(0); update_state(&id, |s| s["highlights"]["aps"] = json!({"query": q, "hits": hits, "seconds": ((now() - t0) * 10.0).round() / 10.0})); }+ else { update_state(&id, |s| push_to(s, "warnings", json!(format!("aps: {} (sign in once with fusion_aps_signin and this step lights up)", r["error"].to_string().chars().take(80).collect::<String>())))); }+ }+ "mcp" => {+ let r = crate::mcp::tools(env.version, (env.aps_state)());+ if r["success"].as_bool() == Some(true) { let tools = r["tools"].as_array().cloned().unwrap_or_default(); let names: Vec<String> = tools.iter().take(20).map(|t| t["name"].as_str().map(str::to_string).unwrap_or_else(|| t.to_string()).chars().take(32).collect()).collect(); update_state(&id, |s| s["highlights"]["mcp"] = json!({"toolCount": tools.len(), "tools": names, "beyond": "The bridge's fusion_* surface is ~120 verbs: electronics editors, EAGLE text commands, cloud files, APS search, evidence captures, and 14 export formats - none of which the MCP toolset offers."})); }+ else { update_state(&id, |s| push_to(s, "warnings", json!(format!("mcp: {} (fusion_mcp_enable turns it on in ~10s)", r["error"].to_string().chars().take(80).collect::<String>())))); }+ }+ "exports" => {+ wait_idle(180.0); // the 3D board step leaves a design active; that is what the exports read+ let dir = std::env::temp_dir().join("fusion-tour-exports").to_string_lossy().replace('\\', "/");+ let mut got: Vec<String> = Vec::new();+ for (fmt, verb) in [("step", "export_step"), ("stl", "export_stl")] {+ let p = format!("{dir}/{}.{fmt}", if deviceset.is_empty() { "tour" } else { &deviceset });+ let r = pass2(verb, json!({"outputPath": p}));+ if r["success"].as_bool() == Some(true) { got.push(fmt.to_uppercase()); } else { update_state(&id, |s| push_to(s, "warnings", json!(format!("{verb}: {}", r["error"].to_string().chars().take(80).collect::<String>())))); }+ }+ if !got.is_empty() { update_state(&id, |s| s["highlights"]["exports"] = json!({"formats": got, "dir": dir, "also": ["F3D", "GLB", "3MF", "IGES", "SAT", "DXF", "DWG", "OBJ", "FBX", "SKP", "USDZ", "gerbers", "BOM", "CPL"]})); snap(&label); }+ }+ "schematic" | "2d-board" | "3d-board" => {+ if design_open_failed { update_state(&id, |s| push_to(s, "failedSteps", json!(key))); continue; }+ let cmd = match key.as_str() { "schematic" => "show_schematic", "2d-board" => "show_2d_board", _ => "show_3d_board" };+ // the generated test project opens as two documents and leaves the SCHEMATIC one+ // active; the board switches need the BOARD document in front+ if key != "schematic" && query.is_empty() && crate::testproject::test_project(&sd2).is_some() { let _ = crate::testproject::test_project_activate(&ctx2, "brd", &pass2); }+ let r = crate::orchestrations::open_with_screenshot(&ctx2, cmd, &json!({}), &pass2);+ if r["success"].as_bool() == Some(true) { sleep_s(2.0); snap(&label); } else { update_state(&id, |s| { push_to(s, "failedSteps", json!(key)); push_to(s, "warnings", json!(format!("{key}: {}", r["error"].to_string().chars().take(120).collect::<String>()))); }); }+ }+ _ => {}+ }+ crate::timings::record(&sd2, &key, now() - step_started, if key == "launch" && cold { Some("cold") } else { None });+ done_est += dur;+ let pct = (100 * done_est / total_est.max(1)) as u64; let el = (now() - started) as u64;+ update_state(&id, |s| { s["percent"] = json!(pct); s["elapsedSec"] = json!(el); });+ }+ Ok(())+ })();+ match result {+ Ok(()) => {+ let n = get_state(&id).map(|s| s["shots"].as_array().map(|a| a.len()).unwrap_or(0)).unwrap_or(0);+ if captions { tour_caption(&caller, "Tour complete: library, symbol, footprint, 3D chip, Data Hub, APS search, MCP, a generated project in schematic, 2D and 3D, and real exports - all driven in the background by the bridge.", 20); }+ update_state(&id, |s| { s["done"] = json!(true); s["percent"] = json!(100); s["step"] = json!("done"); s["stepLabel"] = json!("Tour complete"); s["narrate"] = json!(format!("Fusion tour complete: {n} views captured across the electronics library{}.", if include_design && !query.is_empty() { " and the board design" } else { "" })); s["elapsedSec"] = json!((now() - started) as u64); });+ }+ Err(e) => { update_state(&id, |s| { let st = s["step"].clone(); push_to(s, "failedSteps", st); s["failed"] = json!(true); s["done"] = json!(true); s["error"] = json!(e.chars().take(300).collect::<String>()); s["narrate"] = json!(format!("Fusion tour stopped at '{}': {}", s["stepLabel"].as_str().unwrap_or(""), e.chars().take(120).collect::<String>())); s["elapsedSec"] = json!((now() - started) as u64); }); }+ }+ let closed = crate::mint::close_docs_opened_since(&ctx2, &docs_before, &[], &pass2);+ update_state(&id, |s| s["tourDocsClosed"] = json!(closed));+ if captions { let _ = ab_call(&caller, "desktop_caption", json!({"action": "hide", "id": TOUR_PANEL_ID}), 8); }+ if remote { pump_stop.store(true, std::sync::atomic::Ordering::Relaxed); let _ = ab_call(&caller, "desktop_demo_panel", json!({"action": "hide", "id": TOUR_PANEL_ID}), 8); }+ if let Some(s) = get_state(&id) { persist(&sd2, &s); }+ });+ json!({"success": true, "demoId": demo_id, "estimatedSec": total_est, "steps": steps.iter().map(|s| json!({"step": s.0, "label": s.1, "estimatedSec": s.2, "caption": tour_explain(&s.0).unwrap_or(&s.1)})).collect::<Vec<_>>(), "statusVerb": "fusion_demo_progress",+ "_hint": format!("Tour started ASYNC (background by default - pass foreground:true only if the user wants to WATCH). Poll fusion_demo_progress {{\"demoId\": \"{demo_id}\"}} about once a second and render percent/stepLabel as a real progress bar. The final poll (done:true) carries the screenshot array.")})+}+fn sample_lbr_or(err: impl FnOnce() -> Result<String, String>) -> Result<String, String> { match sample_lbr() { Some(p) => Ok(p), None => err() } }++/// What the staged demo needs around it.+pub struct DemoEnv<'a> { pub readiness: &'a dyn Fn() -> Value, pub signin: &'a dyn Fn(&Value) -> Value, pub aps_state: &'a dyn Fn() -> Value, pub aps_search: &'a dyn Fn(&Value) -> Value }++/// `fusion_demo` (staged, resumable): from nothing to project -> schematic -> 2D -> 3D plus a live APS search.+pub fn demo(ctx: &Ctx, args: &Value, env: &DemoEnv, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let stage = args["stage"].as_str().unwrap_or("auto").trim().to_string();+ let demo_query = args["query"].as_str().unwrap_or("").to_string();+ let mut steps: Vec<String> = Vec::new();+ let mut shots: Vec<Value> = Vec::new();+ let shot = |label: &str, shots: &mut Vec<Value>| { let r = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller); if let Some(p) = r["savedTo"].as_str().or(r["localSafePath"].as_str()).filter(|_| r["success"].as_bool() == Some(true)) { shots.push(json!({"title": label, "label": label, "path": p})); } };+ let out = |stage: &str, narrate: String, hint: String, done: bool, steps: &Vec<String>, shots: &Vec<Value>, extra: Value| { let mut o = json!({"success": true, "demo": true, "stage": stage, "done": done, "steps": steps, "narrate": narrate, "screenshots": shots, "statusVerb": "fusion_demo", "_hint": hint}); if let Some(e) = extra.as_object() { for (k, v) in e { o[k] = v.clone(); } } o };+ let mut rd = (env.readiness)();+ if rd["installed"].as_bool() != Some(true) {+ return out("not_installed", "Fusion 360 isn't installed yet - want me to install it? It's a free 30-day trial with everything switched on, so the whole tour works: real boards, schematics, 3D, Gerber and BOM exports, cloud search. After the trial, Fusion for Personal Use stays free for non-commercial work (hobby PCBs up to 2 layers), and even an expired install can still OPEN and VIEW your designs.".into(),+ "OFFER the install - do not just report it is missing. Say what they GET: the free 30-day trial is FULL-FEATURED (every step of this demo works: electronics, schematic, 2D/3D board, Gerbers/BOM/CPL, APS cloud search). Be honest about after: Fusion for Personal Use is free for non-commercial use but LIMITS electronics (about 2 layers / 2 schematic sheets / small board area) and some exports; an expired or read-only install can still OPEN + VIEW + browse designs (it only blocks save/export/modify), so their work is never locked away. Then call fusion_install_fusion (no shell approval needed; streams the installer, 10-30 min), poll fusion_readiness until installed:true, and call fusion_demo again to continue the tour. Full flow: the fusion-onboarding skill.".into(), false, &steps, &shots, json!({}));+ }+ if rd["running"].as_bool() != Some(true) { steps.push("launched Fusion".into()); let _ = crate::launch::start(ctx, &json!({})); rd = (env.readiness)(); }+ if rd["needsSignin"].as_bool() == Some(true) {+ let mut si = (env.signin)(&json!({"profile": args["signinProfile"]}));+ si["demo"] = json!(true);+ if matches!(si["stage"].as_str(), Some("opened") | Some("click_signin") | Some("ask_profile") | Some("open_failed")) {+ if si["done"].is_null() { si["done"] = json!(false); }+ si["_hint"] = json!(format!("{} (This is the fusion_demo sign-in stage - after the user is signed in and fusion_readiness is ready:true, call fusion_demo again to continue the tour: APS -> project -> schematic -> 2D -> 3D.)", si["_hint"].as_str().unwrap_or("")));+ return si;+ }+ let nb = ab_call(&ctx.caller, "nbrowser_readiness", json!({}), 40);+ let nb_data = if nb["data"].is_object() { nb["data"].clone() } else { nb.clone() };+ let nb_state = nb_data["state"].as_str().map(str::to_string);+ let (mut opened, mut where_, mut profile_used, mut pick_reason, mut ambiguous) = (false, String::new(), String::new(), String::new(), false);+ let mut choices: Vec<Value> = Vec::new();+ if nb_state.as_deref() == Some("ready") {+ let profs = ab_call(&ctx.caller, "nbrowser_profiles", json!({}), 40);+ let pdata = if profs["data"].is_object() { profs["data"].clone() } else { profs.clone() };+ for p in pdata["profiles"].as_array().cloned().unwrap_or_default() {+ if !p.is_object() || p["blocked"].as_bool() == Some(true) || p["unresolved"].as_bool() == Some(true) || p["profile"].as_str().map(|s| s.is_empty()).unwrap_or(true) { continue; }+ choices.push(json!({"profile": p["profile"], "browser": p["browser"].as_str().unwrap_or("").to_lowercase(), "email": p["email"].as_str().unwrap_or(""), "displayName": p["displayName"].as_str().unwrap_or(""), "profileDir": p["profileDir"].as_str().unwrap_or(""), "active": p["active"].as_bool().unwrap_or(false), "live": p["live"].as_bool().unwrap_or(false), "asleep": p["asleep"].as_bool().unwrap_or(false), "extensionInstalled": p.get("extensionInstalled").cloned().unwrap_or(json!(true)), "describe": crate::signin::describe_profile(&p)}));+ }+ for c in choices.iter_mut() {+ if c["live"].as_bool() != Some(true) { c["autodesk"] = json!("unprobed(asleep)"); continue; }+ let ls = ab_call(&ctx.caller, "nbrowser_login_state", json!({"profile": c["profile"], "url": "https://accounts.autodesk.com/"}), 45);+ let lsd = if ls["data"].is_object() { ls["data"].clone() } else { ls.clone() };+ c["autodesk"] = json!(if lsd["ok"].is_null() && lsd["loggedIn"].is_null() { "unprobed" } else if lsd["loggedIn"].as_bool() == Some(true) { "signed-in" } else { "signed-out" });+ c["autodeskConfidence"] = lsd["confidence"].clone(); c["autodeskCookies"] = lsd["cookieCount"].clone();+ }+ let conf = |c: &Value| match c["autodeskConfidence"].as_str() { Some("high") => 3, Some("medium") => 2, Some("low") => 1, _ => 0 };+ let mut signed: Vec<Value> = choices.iter().filter(|c| c["autodesk"].as_str() == Some("signed-in")).cloned().collect();+ signed.sort_by_key(|c| std::cmp::Reverse((conf(c), c["autodeskCookies"].as_i64().unwrap_or(0))));+ let pick = if let Some(p) = signed.first() { pick_reason = format!("it is the profile actually signed into Autodesk ({} auth cookies, {} confidence)", p["autodeskCookies"], p["autodeskConfidence"].as_str().unwrap_or("None")); Some(p.clone()) } else {+ let consumer = ["gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com"];+ let mut ranked: Vec<Value> = choices.iter().filter(|c| c["live"].as_bool() == Some(true)).cloned().collect(); if ranked.is_empty() { ranked = choices.clone(); }+ ranked.sort_by_key(|c| { let em = c["email"].as_str().unwrap_or("").to_lowercase(); (if !em.is_empty() && !consumer.iter().any(|d| em.ends_with(&format!("@{d}"))) { 0 } else { 1 }, if c["live"].as_bool() == Some(true) { 0 } else { 1 }) });+ pick_reason = "no profile has a live Autodesk session, so I picked the most likely one (work/corporate identity first) - CONFIRM WITH THE USER".into(); ranked.first().cloned() };+ ambiguous = signed.is_empty() || signed.len() > 1;+ if let Some(p) = &pick { profile_used = p["profile"].as_str().unwrap_or("").to_string(); where_ = p["describe"].as_str().unwrap_or("").to_string(); }+ let mut oa = json!({"sessionId": "fusion-signin", "url": "https://accounts.autodesk.com/", "background": true, "thread": "fusion-demo", "purpose": "Autodesk sign-in for Fusion"});+ if !profile_used.is_empty() { oa["profile"] = json!(profile_used); }+ let r = ab_call(&ctx.caller, "nbrowser_open_window", oa, 60);+ let rd_open = if r["data"].is_object() { r["data"].clone() } else { r.clone() };+ opened = !rd_open["sessionId"].is_null() || r["ok"].as_bool() == Some(true) || r["success"].as_bool() == Some(true);+ if where_.is_empty() { where_ = "your browser".into(); }+ }+ steps.push(if opened { format!("opened Autodesk sign-in in {} (background)", if where_.is_empty() { "browser" } else { &where_ }) } else { format!("could not auto-open the browser sign-in: {}", nb["_adCallError"].as_str().map(str::to_string).or(nb_state.clone()).unwrap_or_else(|| "unknown".into())) });+ let narrate = if opened { format!("I've opened the Autodesk sign-in in {where_} - I chose that profile because {pick_reason}. It's sitting in the background so it won't interrupt what you're doing. Finish it whenever you like, tell me to bring it up front, or I can complete it for you. If that's the wrong account, say which profile to use.") } else { "Fusion needs your Autodesk sign-in. I'd like to use your own browser - you're probably already signed in there, so it should take one click.".to_string() };+ let hint = format!("FINISH THE SIGN-IN - never leave a first-time user parked here. {}The profile was CHOSEN BY EVIDENCE, not guessed: each live profile was probed with nbrowser_login_state against accounts.autodesk.com, and the one holding a real Autodesk session wins (see signinProfileReason + per-profile autodesk/autodeskConfidence/autodeskCookies in signinProfileChoices). RELAY THAT REASON to the user. If signinProfileAmbiguous is true (nothing signed in, or several are), ASK them which account their Autodesk login belongs to instead of assuming. NAME THE EXACT PROFILE - never say just 'your browser' or 'your Chrome'. Power users run several profiles (personal / work / media), so say the browser AND the identity from `signinWhere` (e.g. 'Chrome - John Personal ([email protected])'). `signinProfileChoices` lists every profile with describe/email/displayName/active - if there is more than one, say which you used and OFFER TO SWITCH (re-run with a different profile). If the one you want shows extensionInstalled:false it needs the one-time extension install in THAT profile. TELL THE USER WHERE IT IS WAITING and OFFER ALL THREE: (a) they finish it themselves whenever they want, (b) you foreground that window for them (nbrowser_switch_window / browser window state), or (c) THEY LET YOU DRIVE IT - ask first (AskUserQuestion), then click through 'Continue with Google/Apple/Microsoft' using their warm session. Use the NATIVE browser (ABE), NEVER pup: pup is anonymous so Autodesk demands a full fresh login, while their real profile is usually already signed in. NEVER type their password or 2FA - if a secret is demanded, fusion_notify_owner toasts them to type it. Then click Fusion's own 'Sign In' (the webview exposes no UIA control, so use an image-space desktop_click on the button, or foreground + click) to fire the OAuth handoff; accept the 'Autodesk Identity Manager' overlay ('Always allow' + 'Open'). Codes expire in ~2 min - if it expires, click Sign In again. Poll fusion_readiness until ready:true, then call fusion_demo again. Playbook: fusion-autodesk-signin.",+ if opened { format!("A background sign-in tab is ALREADY OPEN in {where_} (session 'fusion-signin'). ") } else { "Auto-open failed - open it yourself: nbrowser_open_window {sessionId:'fusion-signin', url:'https://accounts.autodesk.com/', background:true, profile:'<chrome:their-account>'}. ".to_string() });+ return out("signin", narrate, hint, false, &steps, &shots, json!({"needsSignin": true, "signinOpened": opened, "signinWhere": where_, "signinProfile": profile_used, "signinSession": if opened { json!("fusion-signin") } else { Value::Null }, "signinProfileChoices": choices, "signinProfileReason": pick_reason, "signinProfileAmbiguous": ambiguous, "nbState": nb_state}));+ }+ if rd["ready"].as_bool() != Some(true) {+ return out("waiting", "Fusion is starting up - one moment.".into(), format!("Not ready yet (add-in still loading, or a licensing dialog). Poll fusion_readiness; it auto-resolves seat dialogs. When ready:true, call fusion_demo again. Reason: {}", rd["_hint"].as_str().unwrap_or("").chars().take(220).collect::<String>()), false, &steps, &shots, json!({}));+ }+ steps.push("Fusion ready".into());+ let ap = (env.aps_state)();+ let aps_ready = ap["configured"].as_bool() == Some(true) && ap["signedIn"].as_bool() == Some(true) && ap["tokenLive"].as_bool() == Some(true);+ if !aps_ready && stage != "skip_aps" {+ return out("aps_setup", "Next I'll turn on cloud search - it finds any design in your Autodesk cloud in about two seconds instead of a 30-minute folder crawl. It's a one-time sign-in.".into(),+ "SET APS UP FOR THEM - do not merely mention it. (1) fusion_aps_status for exact state. (2) If not configured, an admin registers a PKCE app once, then fusion_aps_set_client_id. (3) fusion_aps_signin - drive it in their NATIVE browser (ABE), same reasoning as the Fusion sign-in: they're already logged into Autodesk there. (4) Poll fusion_aps_status until tokenLive:true, then call fusion_demo again - the demo then runs a LIVE sample search so they SEE the speed. If they decline setup, call fusion_demo {stage:'skip_aps'} and TELL them what they're missing (2s server-indexed search across the whole team hub; the old in-app search took 30+ min and crashed Fusion, so it is disabled). Skills: fusion-aps-search, fusion-aps-signin.".into(),+ false, &steps, &shots, json!({"apsConfigured": ap["configured"].as_bool().unwrap_or(false), "apsSignedIn": ap["signedIn"].as_bool().unwrap_or(false)}));+ }+ let mut aps_hits: Vec<Value> = Vec::new();+ if aps_ready {+ let t0 = now();+ let sr = (env.aps_search)(&json!({"query": if demo_query.is_empty() { "board" } else { &demo_query }, "limit": 5}));+ let res = sr["data"]["results"].as_array().or(sr["results"].as_array()).cloned().unwrap_or_default();+ if sr["success"].as_bool() == Some(false) { steps.push(format!("APS sample search failed: {}", sr["error"].as_str().unwrap_or(""))); } else { aps_hits = res.iter().take(5).map(|r| json!({"name": r["name"], "project": r["projectName"]})).collect(); steps.push(format!("APS sample search: {} hits in {:.1}s", aps_hits.len(), now() - t0)); }+ }+ let st = pass("get_app_state", json!({}));+ let d = if st["data"].is_object() { st["data"].clone() } else { st.clone() };+ let doc = d["activeDocument"].as_str().unwrap_or("").to_string();+ if d["isElectronics"].as_bool() != Some(true) {+ let target = if !demo_query.is_empty() { demo_query.clone() } else { aps_hits.first().and_then(|h| h["name"].as_str()).unwrap_or("").to_string() };+ if target.is_empty() {+ return out("need_design", "I need an electronics design to show off. Which board should I open?".into(), "No electronics design open and nothing to pick. If APS is live, fusion_aps_search {query:'<board>'} then fusion_aps_open {query:'<name>'}; else ask the user for a design name / open one via fusion_open_cloud_file. ALWAYS open the PROJECT (EcadDesignProductType), never a .brd/.sch/3D child. Then call fusion_demo again.".into(), false, &steps, &shots, json!({}));+ }+ return out("open_design", format!("Opening {target} so you can see a real board end to end."), format!("OPEN THE PROJECT then re-call fusion_demo: fusion_aps_open {{query:'{target}'}} (APS finds it at any folder depth in seconds). CRITICAL: open the electronics PROJECT file, not the schematic/.brd/3D child - a child opens an isolated, often empty view. Poll fusion_get_app_state until isElectronics:true, then call fusion_demo again."), false, &steps, &shots, json!({"target": target}));+ }+ steps.push(format!("electronics project open: {doc}"));+ shot("project", &mut shots);+ for (verb, label, name) in [("show_schematic", "schematic", "schematic"), ("show_2d_board", "board_2d", "2D board"), ("show_3d_board", "board_3d", "3D board")] {+ let r = pass(verb, json!({}));+ if r["success"].as_bool() == Some(false) { steps.push(format!("{name} failed: {}", r["error"].as_str().unwrap_or(""))); } else { steps.push(format!("showed {name}")); shot(label, &mut shots); }+ }+ let aps_line = if aps_hits.is_empty() { String::new() } else { format!("Cloud search is live - I searched your whole Autodesk hub in about two seconds and found: {}. ", aps_hits.iter().take(3).filter_map(|h| h["name"].as_str()).collect::<Vec<_>>().join(", ")) };+ out("done", format!("That's the tour: your {doc} project, its schematic, the 2D board layout, and the real 3D board. {aps_line}From here just ask - export Gerbers/BOM/CPL for the fab, generate a laser-etched IPC package, load JLCPCB design rules, or open any design in your cloud."),+ "DEMO COMPLETE. SHOW the user the screenshots in order (project, schematic, board_2d, board_3d) - they are in `screenshots` with labels. Narrate the `narrate` line. Then offer concrete next steps in THEIR words: 'export the Gerbers', 'make me a SOIC-8 with my part number etched on it' (fusion_generate_package), 'check this against JLCPCB rules' (the Adom DRU rules ship in resources/ - apply them in the Fusion DRC dialog), 'find my other boards' (fusion_aps_search). Full catalog: fusion_describe. Demo playbook: the fusion-demo skill.".into(),+ true, &steps, &shots, json!({"document": doc, "apsSampleSearch": aps_hits}))+}
rust/bridge/src/epg.rsadded+110@@ -0,0 +1,110 @@+//! `fusion_generate_package`: an IPC package from Fusion's built-in ElectronicsPackageGenerator,+//! optionally marked and exported, in one headless add-in script. The script is the Python+//! bridge's f-string, byte for byte (tests/golden/epg_script.json).+use serde_json::{json, Value};++const TEMPLATE: &str = include_str!("epg_template.py");+const TYPES_JSON: &str = include_str!("epg_types.json");++fn types() -> (Vec<String>, Vec<String>) {+ let v: Value = serde_json::from_str(TYPES_JSON).unwrap_or(json!({}));+ let s = |k: &str| v[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();+ (s("types"), s("nonDimension"))+}++/// Python `repr(float)`: shortest round-trip digits, exponent form outside 1e-4..1e16.+pub fn py_float_repr(f: f64) -> String {+ if f.is_nan() { return "nan".into(); }+ if f.is_infinite() { return if f > 0.0 { "inf".into() } else { "-inf".into() }; }+ if f == 0.0 { return if f.is_sign_negative() { "-0.0".into() } else { "0.0".into() }; }+ let e = format!("{:e}", f);+ let (mant, exp) = e.split_once('e').unwrap_or((&e, "0"));+ let exp: i32 = exp.parse().unwrap_or(0);+ if (-4..16).contains(&exp) {+ let s = format!("{}", f);+ if s.contains('.') { s } else { format!("{s}.0") }+ } else {+ format!("{}e{}{:02}", mant, if exp < 0 { "-" } else { "+" }, exp.abs())+ }+}++/// Python `repr()` of a JSON value (dicts keep insertion order, like Python's).+pub fn py_repr(v: &Value) -> String {+ match v {+ Value::Null => "None".into(),+ Value::Bool(b) => if *b { "True".into() } else { "False".into() },+ Value::Number(n) => if let Some(i) = n.as_i64() { i.to_string() } else if let Some(u) = n.as_u64() { u.to_string() } else { py_float_repr(n.as_f64().unwrap_or(0.0)) },+ Value::String(s) => fusion_lbr::py_repr_str(s),+ Value::Array(a) => format!("[{}]", a.iter().map(py_repr).collect::<Vec<_>>().join(", ")),+ Value::Object(o) => format!("{{{}}}", o.iter().map(|(k, v)| format!("{}: {}", fusion_lbr::py_repr_str(k), py_repr(v))).collect::<Vec<_>>().join(", ")),+ }+}++/// The add-in script for one request, or the refusal for an unknown type.+pub fn script_for(args: &Value, bridge_version: &str) -> Result<String, Value> {+ let (all, non_dim) = types();+ let pkg_type = args["type"].as_str().unwrap_or("").trim().to_lowercase();+ if !all.contains(&pkg_type) {+ return Err(json!({"success": false, "error": format!("Unknown package type: {}", fusion_lbr::py_repr_str(&pkg_type)), "errorCode": "unknown_package_type", "supportedTypes": all,+ "_hint": "Pass type as one of supportedTypes (EPG Scripts3d module names). Common: chip (0402/0603/0805 passives), soic, qfn, qfp, bga, sot23, dfn2, melf, ecap, crystal, header_straight."}));+ }+ let units_cm = args["unitsCm"].as_bool().unwrap_or(false);+ let mut params = serde_json::Map::new();+ if let Some(o) = args["params"].as_object() {+ for (k, v) in o {+ let conv = !units_cm && v.is_number() && !v.is_boolean() && !non_dim.contains(k);+ params.insert(k.clone(), if conv { json!(v.as_f64().unwrap_or(0.0) / 10.0) } else { v.clone() });+ }+ }+ let etch = args["etch"].as_str().unwrap_or("").to_string();+ let etch_style = args["etchStyle"].as_str().filter(|s| !s.is_empty()).unwrap_or("raised").trim().to_lowercase();+ let etch_depth_cm = args["etchDepthMm"].as_f64().filter(|d| *d != 0.0).unwrap_or(0.03) / 10.0;+ let etch_height = args["etchHeightMm"].clone();+ let output_step = args["outputStep"].as_str().unwrap_or("").to_string();+ Ok(TEMPLATE.replace("@@RAW_PKG_TYPE@@", &pkg_type).replace("@@PKG_TYPE@@", &fusion_lbr::py_repr_str(&pkg_type))+ .replace("@@PARAMS@@", &py_repr(&Value::Object(params))).replace("@@ETCH_HEIGHT_MM@@", &py_repr(&etch_height))+ .replace("@@ETCH_DEPTH_CM@@", &py_float_repr(etch_depth_cm)).replace("@@ETCH_STYLE@@", &fusion_lbr::py_repr_str(&etch_style))+ .replace("@@ETCH@@", &fusion_lbr::py_repr_str(&etch)).replace("@@OUTPUT_STEP@@", &fusion_lbr::py_repr_str(&output_step))+ .replace("@@BRIDGE_VERSION@@", &fusion_lbr::py_repr_str(bridge_version)))+}++pub fn generate_package(args: &Value, bridge_version: &str, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let script = match script_for(args, bridge_version) { Ok(s) => s, Err(e) => return e };+ let mut res = pass("run_modeling_script", json!({"script": script}));+ if res["success"].as_bool() != Some(true) {+ if res["error"].as_str().unwrap_or("").contains("EPG_NOT_FOUND") {+ res["errorCode"] = json!("epg_not_found");+ res["_hint"] = json!("Fusion's built-in ElectronicsPackageGenerator add-in was not found in this install (ships with 2024+ Fusion under webdeploy .../Api/InternalAddins). Update Fusion, or build the part with fusion_make_3d_package from a STEP instead.");+ }+ return res;+ }+ let inner = res["data"]["result"].clone();+ let etch = args["etch"].as_str().unwrap_or("");+ let etch_style = args["etchStyle"].as_str().filter(|s| !s.is_empty()).unwrap_or("raised").trim().to_lowercase();+ let output_step = args["outputStep"].as_str().unwrap_or("");+ let mut out = json!({"success": true, "output": inner.to_string()});+ if let Some(o) = inner.as_object() { for (k, v) in o { out[k] = v.clone(); } }+ out["_hint"] = json!(format!("Generated an IPC-compliant parametric package via Fusion's built-in EPG{}{}. NEXT: fusion_screenshot_fusion for a visual check; pull_file the STEP for KiCad. HOW THE MARKING WORKS: the top surface is found from the WHOLE-chip bounding box - only upward planar faces in the top 5% height band count, largest area wins - and the text is laid out with a 10% margin inside that face, auto-fit width-aware (override with etchHeightMm). Default style is raised+white for CONTRAST (humans can't read a dark-on-dark emboss); pass etchStyle:'engraved' for a sunken laser cut. The text ALWAYS runs along the LONGEST axis of the top face (rotated 90deg when needed) for maximum room; the 10% margin is ENFORCED by measuring the placed text bbox and shrink-to-fitting (see fitIterations in the manifest). Multi-line markings work - join lines with a newline (e.g. 'LM358' + newline + 'ADOM-A' for MPN + variant; per-line auto-fit). SIDECAR MANIFEST: when outputStep is set, <step>.manifest.json records every setting picked + WHY + the calculated bboxes (chip bbox, top band, face extents, text box, height, rotation) - to refresh a marking (add a variant line), re-call this verb with the manifest's type + paramsCm (unitsCm:true) + the new etch text; generation is deterministic so placement reproduces exactly. The manifest is also returned inline as 'manifest'. PITFALLS: params are mm by default (unitsCm:true for EPG-native cm); dimension names follow each EPG generator (chip: D/E/A/L/L1; soic: A/A1/b/D/E/E1/e/L/DPins); raised markings are separate 'Marking' bodies (white in Fusion + colored STEP).",+ if etch.is_empty() { String::new() } else { format!(", marked '{}' on the chip top as {}", etch, if etch_style == "engraved" { "an engraved cut" } else { "RAISED WHITE text (silkscreen-style - high contrast on the dark body)" }) },+ if output_step.is_empty() { String::new() } else { format!(", exported STEP to {output_step}") }));+ out+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn epg_script_matches_python() {+ let cases: Vec<Value> = serde_json::from_str(include_str!("../tests/golden/epg_script.json")).unwrap();+ for c in cases {+ match script_for(&c["args"], "TESTVER") {+ Ok(s) => { let want = c["script"].as_str().unwrap(); if s != want { let i = s.chars().zip(want.chars()).position(|(a, b)| a != b).unwrap_or(0); panic!("case {} differs at {}: ours {:?} vs python {:?}", c["args"]["type"], i, &s[i.saturating_sub(40)..(i + 80).min(s.len())], &want[i.saturating_sub(40)..(i + 80).min(want.len())]); } }+ Err(e) => assert_eq!(e["errorCode"], c["errorCode"], "case {}", c["args"]["type"]),+ }+ }+ }+ #[test]+ fn float_repr() {+ for (f, s) in [(0.3, "0.3"), (3.0, "3.0"), (1e-5 / 10.0, "1.0000000000000002e-06"), (1e16, "1e+16"), (0.0001, "0.0001"), (123.456, "123.456")] { assert_eq!(py_float_repr(f), s); }+ }+}
rust/bridge/src/etiquette.rsadded+322@@ -0,0 +1,322 @@+//! The window etiquette around an add-in verb, native. Ported from server.py's+//! `_window_etiquette_pre` / `_window_etiquette_post` / `_SavePromptSentinel` and their+//! helpers: the document registry (what we open is ours to clean up), the foreground rule+//! (a reason and a 3s heads-up, else the background envelope), the save-prompt sentinel that+//! discards ONLY bridge documents, the post-verb chokepoint sweep, and the etiquette note.+//! Desktop-shaped work goes through ab verbs (desktop_find_control, desktop_ui_click,+//! desktop_close_window, desktop_taskbar); the Win32 envelope is `HostProcess::background_envelope`.++use serde_json::{json, Value};+use std::path::Path;+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Arc;+use std::time::{Duration, Instant};++use fusion_core::ab::{AbClient, Caller, Loopback};++static BADGED: AtomicBool = AtomicBool::new(false);+static BADGED_HWND: std::sync::Mutex<i64> = std::sync::Mutex::new(0);+/// `set_driving_badge`: the Adom mark on Fusion's taskbar button while Adom drives it.+pub fn set_badge(caller: &Caller, hwnd: i64) -> bool {+ let ok = Loopback::from_env().map(|ab| ab.call("desktop_taskbar", json!({"hwnd": hwnd, "overlay": {"badge": "adom", "tooltip": "Adom is driving Fusion"}}), caller, 10).is_ok()).unwrap_or(false);+ if ok { BADGED.store(true, Ordering::Relaxed); *BADGED_HWND.lock().unwrap() = hwnd; }+ ok+}+/// `clear_driving_badge`: remove the taskbar badge from whichever window we badged last.+pub fn clear_badge(caller: &Caller) -> bool {+ let h = *BADGED_HWND.lock().unwrap();+ if h == 0 { return false; }+ let ok = Loopback::from_env().map(|ab| ab.call("desktop_taskbar", json!({"hwnd": h, "overlay": {"badge": "none"}}), caller, 10).is_ok()).unwrap_or(false);+ if ok { BADGED.store(false, Ordering::Relaxed); *BADGED_HWND.lock().unwrap() = 0; }+ ok+}+static LAST_DRIVE_AT: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);+pub fn badge_active() -> bool { BADGED.load(Ordering::Relaxed) }+pub fn last_drive_at() -> Option<f64> { let v = *LAST_DRIVE_AT.lock().unwrap(); if v > 0.0 { Some(v) } else { None } }+pub fn b64_pub(b: &[u8]) -> String { b64(b) }++pub const SHOW_SURFACE_VERBS: &[&str] = &["show_symbol", "show_footprint", "show_3d_package", "show_library", "show_project", "show_schematic", "show_2d_board", "show_3d_board"];+const DONT_SAVE_LABELS: &[&str] = &["Don't Save", "Don\u{2019}t Save"];+const BRIDGE_DOC_BUILTINS: &[&str] = &["adom-test-", "adomdemosample", "adom-fusion-wrap", "ads8588sipm", "0603waf"];++pub struct Pre { pub args: Value, pub ran_backgrounded: bool, pub refusal: Option<Value>, pub docs_before: Option<Vec<String>>, pub fg: bool }+++/// `_note_focus_event`: the quietness audit log, mirrored to focus-events.jsonl.+pub fn note_focus_event(state_dir: &Path, verb: &str, outcome: &str, reason: &str) {+ let v = if verb.starts_with("fusion_") { verb.to_string() } else { format!("fusion_{verb}") };+ let rec = json!({"at": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(), "verb": v, "outcome": outcome, "reason": reason.chars().take(200).collect::<String>()});+ use std::io::Write;+ let _ = std::fs::create_dir_all(state_dir);+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_dir.join("focus-events.jsonl")) { let _ = writeln!(f, "{rec}"); }+}++/// `_snapshot_open_docs`: lowercase names of the documents open right now (empty on failure).+pub fn snapshot_open_docs(call: &dyn Fn(&str, Value, u64) -> Value) -> Vec<String> {+ let r = call("document_info", json!({}), 5);+ if r["success"].as_bool() != Some(true) { return vec![]; }+ let mut v: Vec<String> = r["data"]["openDocuments"].as_array().map(|a| a.iter().filter_map(|d| d["name"].as_str()).filter(|n| !n.is_empty()).map(|n| n.trim_end_matches('*').trim().to_lowercase()).collect()).unwrap_or_default();+ v.sort(); v.dedup(); v+}++fn bridge_docs(state_dir: &Path) -> Vec<String> {+ crate::small_verbs::get_pref(state_dir, "bridgeDocs").and_then(|v| v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).filter(|s| s.len() >= 3).collect())).unwrap_or_default()+}++/// `_is_bridge_doc_text`: the bridge-doc name the text mentions, if any.+pub fn bridge_doc_in(state_dir: &Path, text: &str) -> Option<String> {+ let t = text.to_lowercase();+ bridge_docs(state_dir).into_iter().find(|n| t.contains(n.as_str())).or_else(|| BRIDGE_DOC_BUILTINS.iter().find(|p| t.contains(*p)).map(|s| s.to_string()))+}++/// `_find_controls_any`: {best, controls} from anywhere in ab's answer.+pub fn find_controls_any(v: &Value, depth: u8) -> Value {+ if depth > 5 { return json!({}); }+ if let Some(s) = v.as_str() { if s.starts_with('{') || s.starts_with('[') { if let Ok(p) = serde_json::from_str::<Value>(s) { return find_controls_any(&p, depth + 1); } } return json!({}); }+ if let Some(o) = v.as_object() {+ if o.get("best").map(|b| b.is_object()).unwrap_or(false) || o.get("controls").map(|c| c.is_array()).unwrap_or(false) {+ return json!({"best": o.get("best").cloned().unwrap_or(json!({})), "controls": o.get("controls").cloned().unwrap_or(json!([]))});+ }+ for (_, x) in o { let r = find_controls_any(x, depth + 1); if r.is_object() && !r.as_object().unwrap().is_empty() { return r; } }+ }+ if let Some(a) = v.as_array() { for x in a { let r = find_controls_any(x, depth + 1); if r.is_object() && !r.as_object().unwrap().is_empty() { return r; } } }+ json!({})+}++/// `_find_save_prompt`: is the unsaved-doc Save prompt up? {found, hwnd, button, text}.+pub fn find_save_prompt(ab: &dyn AbClient, caller: &Caller, pids: &[u32]) -> Value {+ let dialogs = {+ let mut a = json!({}); if !pids.is_empty() { a["pids"] = json!(pids); }+ match ab.call("desktop_enum_windows", a, caller, 10) { Ok(v) => fusion_core::dialogs::qt_dialog_rows(&v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default()), Err(_) => return json!({"found": false}) }+ };+ for d in dialogs {+ let Some(h) = d["hwnd"].as_i64().filter(|h| *h != 0) else { continue };+ let title = d["title"].as_str().unwrap_or("").trim().to_lowercase();+ for label in DONT_SAVE_LABELS {+ let r = ab.call("desktop_find_control", json!({"hwnd": h, "name": label}), caller, 10).map(|v| find_controls_any(&v, 0)).unwrap_or(json!({}));+ let best = &r["best"];+ if best["name"].as_str().map(|n| !n.is_empty()).unwrap_or(false) && best["invokable"].as_bool().unwrap_or(false) {+ let mut text = String::new();+ if let Ok(t) = ab.call("desktop_find_control", json!({"hwnd": h, "name": "save"}), caller, 10) {+ let t = find_controls_any(&t, 0);+ let mut cands = vec![t["best"].clone()]; cands.extend(t["controls"].as_array().cloned().unwrap_or_default());+ for c in cands { let nm = c["name"].as_str().unwrap_or(""); if nm.len() > text.len() { text = nm.to_string(); } }+ }+ return json!({"found": true, "hwnd": h, "button": best["name"], "text": text});+ }+ }+ if ["save", "save changes", "unsaved changes"].contains(&title.as_str()) { return json!({"found": true, "hwnd": h, "button": Value::Null, "text": ""}); }+ }+ json!({"found": false})+}++/// `_dismiss_save_prompt_if_bridge_doc`: Don't Save for OUR documents only; a user's prompt is never touched.+pub fn dismiss_save_prompt_if_bridge_doc(state_dir: &Path, caller: &Caller, pids: &[u32], main_title: &str) -> Value {+ let Some(ab) = Loopback::from_env() else { return json!({"handled": false, "why": "no ab loopback"}) };+ let prompt = find_save_prompt(&ab, caller, pids);+ if prompt["found"].as_bool() != Some(true) { return json!({"handled": false, "why": "no save prompt"}); }+ let hwnd = prompt["hwnd"].as_i64().unwrap_or(0);+ let text = prompt["text"].as_str().unwrap_or("").to_string();+ let matched = bridge_doc_in(state_dir, &text).or_else(|| bridge_doc_in(state_dir, main_title));+ let Some(matched) = matched else {+ // an unattributed prompt: only if the main thread is actually blocked, and only after+ // two sightings, answer Cancel (WM_CLOSE: non-destructive, the doc stays open unsaved)+ let st = crate::addin::addin_status_probe(Duration::from_secs(2));+ let blocked = st.as_ref().map(|s| s["busy"].as_bool().unwrap_or(false)).unwrap_or(true);+ if !blocked { return json!({"handled": false, "why": "unattributed dialog but the main thread is responsive - zombie window, leaving it alone", "hwnd": hwnd}); }+ let n = { let mut g = STRIKES.lock().unwrap(); let e = g.entry(hwnd).or_insert(0); *e += 1; *e };+ if n >= 2 {+ if ab.call("desktop_close_window", json!({"hwnd": hwnd}), caller, 15).is_ok() {+ eprintln!("[fusion-bridge] save-prompt sentinel: cancelled an unattributed prompt after {n} sightings (WM_CLOSE - non-destructive, main thread freed)");+ STRIKES.lock().unwrap().remove(&hwnd);+ return json!({"handled": true, "why": format!("escaped unattributed prompt (Cancel) after {n} sightings")});+ }+ }+ return json!({"handled": false, "why": format!("unattributed save prompt - strike {n} of 2 before Escape"), "hwnd": hwnd});+ };+ if let Some(b) = prompt["button"].as_str() {+ if let Ok(r) = ab.call("desktop_ui_click", json!({"hwnd": hwnd, "name": b}), caller, 15) {+ if r["invoked"].as_bool().unwrap_or(false) || r["success"].as_bool().unwrap_or(false) {+ eprintln!("[fusion-bridge] save-prompt sentinel: clicked {b:?} for bridge doc {matched:?} (background)");+ return json!({"handled": true, "why": format!("clicked '{b}' for bridge doc '{matched}'")});+ }+ }+ }+ if let Ok(r) = ab.call("desktop_ui_click", json!({"hwnd": hwnd, "name": "Don't Save"}), caller, 15) {+ if r["invoked"].as_bool().unwrap_or(false) || r["success"].as_bool().unwrap_or(false) {+ crate::fusion_prefs::action_ledger(state_dir, "save_prompt_dismiss", json!({"doc": matched, "how": "dont_save"}));+ return json!({"handled": true, "why": format!("clicked Don't Save for bridge doc '{matched}'")});+ }+ }+ if ab.call("desktop_close_window", json!({"hwnd": hwnd}), caller, 15).is_ok() {+ crate::fusion_prefs::action_ledger(state_dir, "save_prompt_dismiss", json!({"doc": matched, "how": "escape"}));+ return json!({"handled": true, "why": format!("escaped (Cancel) for bridge doc '{matched}'")});+ }+ json!({"handled": false, "why": "dismiss attempts did not land", "hwnd": hwnd})+}++static STRIKES: std::sync::Mutex<std::collections::BTreeMap<i64, u32>> = std::sync::Mutex::new(std::collections::BTreeMap::new());++/// `_SavePromptSentinel`: poll for the Save prompt while a doc-switching call runs.+pub struct Sentinel { stop: Arc<AtomicBool>, deadline: Arc<std::sync::Mutex<Instant>> }+impl Sentinel {+ pub fn start(state_dir: std::path::PathBuf, caller: Caller, pids: Vec<u32>, max_seconds: f64) -> Sentinel {+ let stop = Arc::new(AtomicBool::new(false));+ let deadline = Arc::new(std::sync::Mutex::new(Instant::now() + Duration::from_secs_f64(max_seconds.max(5.0))));+ let (s2, d2) = (stop.clone(), deadline.clone());+ std::thread::spawn(move || {+ while !s2.load(Ordering::Relaxed) && Instant::now() < *d2.lock().unwrap() {+ let _ = dismiss_save_prompt_if_bridge_doc(&state_dir, &caller, &pids, "");+ std::thread::sleep(Duration::from_secs(1));+ }+ });+ Sentinel { stop, deadline }+ }+ /// keep covering the just-returned call for a short grace (the post-timeout prompt is the wedge)+ pub fn soft_stop(&self, grace: f64) { let mut d = self.deadline.lock().unwrap(); let g = Instant::now() + Duration::from_secs_f64(grace); if g < *d { *d = g; } }+ #[allow(dead_code)]+ pub fn hard_stop(&self) { self.stop.store(true, Ordering::Relaxed); }+}++const BACKGROUND_NOTE: &str = "Window opened/changed in the BACKGROUND - the user's focus was NOT touched; do NOT foreground anything to compensate. Render results via fusion_state. If the USER asks to watch, re-call with {\"foreground\": true, \"foregroundReason\": \"<why, shown to the user as a 3s caption>\"}.";++/// `_window_etiquette_pre` plus the document snapshot and the long-running pre-dismiss.+pub fn pre(host: &dyn fusion_core::HostProcess, state_dir: &Path, caller: &Caller, cmd: &str, args: &Value, pids: &[u32], call: &dyn Fn(&str, Value, u64) -> Value) -> Pre {+ let window_raising = crate::addin::WINDOW_RAISING.contains(&cmd);+ let docs_before = if window_raising { Some(snapshot_open_docs(call)) } else { None };+ let fg = args["foreground"].as_bool().unwrap_or(false) || (args["foreground"].is_string() && !args["foreground"].as_str().unwrap_or("").is_empty());+ let mut out = Pre { args: args.clone(), ran_backgrounded: false, refusal: None, docs_before, fg };+ if window_raising {+ if fg {+ let reason = args["foregroundReason"].as_str().unwrap_or("").trim().to_string();+ if reason.is_empty() {+ note_focus_event(state_dir, cmd, "refused", "");+ out.refusal = Some(json!({"success": false, "errorCode": "foreground_reason_required",+ "error": "foreground:true needs foregroundReason (a short user-facing sentence).",+ "_hint": "Fusion verbs ALWAYS run in the background so the user is never interrupted mid-typing. If the user genuinely wants to WATCH, re-call with foreground:true AND foregroundReason:\"<why, in words the user will read>\" - it is shown as an ab caption for 3 seconds before the window comes forward, so they can yield."}));+ return out;+ }+ note_focus_event(state_dir, cmd, "foreground", &reason);+ if let Some(ab) = Loopback::from_env() {+ let _ = ab.call("notify_user", json!({"title": "Fusion is coming to the foreground", "body": format!("{reason} (Requested by your AI - Fusion verbs otherwise always run in the background.)"), "level": "info", "target": "all"}), caller, 10);+ std::thread::sleep(Duration::from_secs(3));+ }+ if let Some(o) = out.args.as_object_mut() { o.remove("foreground"); o.remove("foregroundReason"); }+ } else {+ let secs = crate::addin::timeout_secs_or(cmd, 60) as f64 + 20.0;+ host.background_envelope(secs, cmd == "start");+ if crate::small_verbs::get_pref(state_dir, "showDrivingBadge").map(|v| v != json!(false)).unwrap_or(true) {+ if let Some(ab) = Loopback::from_env() { if let Some(h) = fusion_core::windows_facts::facts_from_ab(&ab, caller).main_hwnd { if ab.call("desktop_taskbar", json!({"hwnd": h, "overlay": {"badge": "adom", "tooltip": "Adom is driving Fusion"}}), caller, 10).is_ok() { BADGED.store(true, Ordering::Relaxed); *BADGED_HWND.lock().unwrap() = h; } } }+ *LAST_DRIVE_AT.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);+ }+ out.ran_backgrounded = true;+ }+ }+ if ["walk_cloud_tree", "search_cloud_files"].contains(&cmd) {+ if let Some(ab) = Loopback::from_env() {+ let dialogs = crate::small_verbs::classify_blocking_dialogs(&ab, caller, pids);+ for d in &dialogs { if let Some(h) = d["hwnd"].as_i64() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), caller, 10); } }+ if !dialogs.is_empty() { std::thread::sleep(Duration::from_millis(500)); }+ }+ }+ out+}++/// `_window_etiquette_post` plus the recent-actions ledger on a failed slow verb.+pub fn post(state_dir: &Path, caller: &Caller, cmd: &str, args: &Value, mut res: Value, pre: &Pre, since: f64, pids: &[u32], call: &dyn Fn(&str, Value, u64) -> Value) -> Value {+ let window_raising = crate::addin::WINDOW_RAISING.contains(&cmd);+ let failed = res["success"].as_bool() == Some(false);+ if failed && (window_raising || ["walk_cloud_tree", "search_cloud_files"].contains(&cmd)) {+ let acts = ledger_entries_since(state_dir, since, 12);+ if !acts.is_empty() && res.is_object() {+ if res.get("recentActions").is_none() { res["recentActions"] = json!(acts); }+ if res.get("_ledgerHint").is_none() { res["_ledgerHint"] = json!("recentActions = every background action the bridge took on the host app while this verb ran (newest last); fusion_action_ledger {sinceSeconds} for more."); }+ }+ }+ if let Some(before) = &pre.docs_before {+ for name in snapshot_open_docs(call) { if !before.contains(&name) && !name.is_empty() && name != "untitled" { crate::chains::note_bridge_doc_pub(state_dir, &name); eprintln!("[fusion-bridge] registered {name:?} as a bridge doc (it appeared while we drove)"); } }+ }+ if SHOW_SURFACE_VERBS.contains(&cmd) {+ for k in ["name", "deviceset", "libraryPath", "fileName", "query"] { if let Some(v) = args[k].as_str().filter(|s| !s.is_empty()) { crate::chains::note_bridge_doc_pub(state_dir, v); } }+ if args["capture"].as_bool() == Some(true) && res["success"].as_bool() != Some(false) { res["shot"] = capture_surface_shot(caller, cmd, args, call); }+ }+ if window_raising {+ let main_title = Loopback::from_env().and_then(|ab| fusion_core::windows_facts::facts_from_ab(&ab, caller).main_title).unwrap_or_default();+ let sw = dismiss_save_prompt_if_bridge_doc(state_dir, caller, pids, &main_title);+ if sw["handled"].as_bool() == Some(true) && res.is_object() { res["savePromptCleared"] = sw["why"].clone(); }+ if let Some(ab) = Loopback::from_env() {+ for d in crate::small_verbs::classify_blocking_dialogs(&ab, caller, pids) {+ let (cat, h) = (d["category"].as_str().unwrap_or(""), d["hwnd"].as_i64().unwrap_or(0));+ match cat {+ "upload_in_progress" => { let _ = ab.call("desktop_ui_click", json!({"hwnd": h, "name": "No"}), caller, 12); if res.is_object() { res["uploadCloseDeclined"] = json!(true); } eprintln!("[fusion-bridge] answered the Hub upload-close confirm with No (work-preserving; close retried after jobs drain)"); }+ "create_confirm" => { let _ = ab.call("desktop_ui_click", json!({"hwnd": h, "name": "No"}), caller, 12); if res.is_object() { res["createPromptDeclined"] = json!(true); } eprintln!("[fusion-bridge] declined a standing Create-new prompt at the chokepoint (raised by a raw EDIT; Yes would fabricate an empty artifact)"); }+ "launch_error" | "lbr_open_error" => { let _ = ab.call("desktop_ui_click", json!({"hwnd": h, "name": "OK"}), caller, 12); eprintln!("[fusion-bridge] dismissed a standing {cat} dialog at the chokepoint"); }+ _ => {}+ }+ }+ }+ if res.is_object() {+ if pre.fg { if res.get("windowEtiquette").is_none() { res["windowEtiquette"] = json!("foreground"); } }+ else {+ if res.get("windowEtiquette").is_none() { res["windowEtiquette"] = json!("background"); }+ let h = res["_hint"].as_str().map(str::to_string);+ if !h.as_deref().map(|h| h.contains("focus was NOT touched")).unwrap_or(false) { res["_hint"] = json!(match h { Some(h) => format!("{h} {BACKGROUND_NOTE}"), None => BACKGROUND_NOTE.to_string() }); }+ }+ }+ }+ res+}++/// `_ledger_entries_since`.+pub fn ledger_entries_since(state_dir: &Path, t0: f64, limit: usize) -> Vec<Value> {+ let Ok(t) = std::fs::read_to_string(state_dir.join("actions.jsonl")) else { return vec![] };+ let lines: Vec<&str> = t.lines().collect();+ let tail = &lines[lines.len().saturating_sub(400)..];+ let out: Vec<Value> = tail.iter().filter_map(|l| serde_json::from_str::<Value>(l).ok()).filter(|e| e["epoch"].as_f64().unwrap_or(0.0) >= t0 - 1.0).collect();+ out[out.len().saturating_sub(limit)..].to_vec()+}++/// `_capture_surface_shot`: settle on the add-in going idle, then the frame ab already resized.+fn capture_surface_shot(caller: &Caller, verb: &str, args: &Value, call: &dyn Fn(&str, Value, u64) -> Value) -> Value {+ let label = match verb { "show_symbol" => "Symbol", "show_footprint" => "Footprint", "show_3d_package" => "3D package", "show_library" => "Library", "show_project" => "Project", "show_schematic" => "Schematic", "show_2d_board" => "2D board", "show_3d_board" => "3D board", v => v };+ let deadline = Instant::now() + Duration::from_secs_f64(args["captureTimeoutSeconds"].as_f64().unwrap_or(120.0));+ while Instant::now() < deadline { if call("get_app_state", json!({}), 6)["success"].as_bool() == Some(true) { break; } std::thread::sleep(Duration::from_secs(3)); }+ std::thread::sleep(Duration::from_secs_f64(args["capturePaintDelay"].as_f64().unwrap_or(2.5)));+ let shot = crate::small_verbs::screenshot_fusion(&json!({}), caller);+ let Some(path) = shot["savedTo"].as_str().filter(|_| shot["success"].as_bool() == Some(true)).map(str::to_string) else { return json!({"title": label, "image": Value::Null, "error": shot["error"].as_str().unwrap_or("capture failed")}) };+ let max_w = (args["captureMaxWidth"].as_i64().unwrap_or(640)).clamp(120, 1600);+ // the ~640px frame Python resamples is served here as ab's own safe-size copy (<=1400px)+ let small = shot["safePath"].as_str().map(str::to_string).unwrap_or_else(|| path.clone());+ let img = std::fs::read(&small).ok().map(|b| b64(&b));+ json!({"title": label, "image": img, "path": path, "maxWidth": max_w, "thumbnailDataUrl": img.as_ref().map(|i| format!("data:image/png;base64,{i}"))})+}++fn b64(bytes: &[u8]) -> String {+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);+ for c in bytes.chunks(3) {+ let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;+ out.push(T[(n >> 18) as usize & 63] as char); out.push(T[(n >> 12) as usize & 63] as char);+ out.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' }); out.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });+ }+ out+}++#[cfg(test)]+mod tests {+ #[test]+ fn bridge_doc_text_and_ledger_window() {+ let d = std::env::temp_dir().join(format!("fb-etq-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ crate::small_verbs::set_pref(&d, "bridgeDocs", serde_json::json!(["demo_board", "lan7800"]));+ assert_eq!(super::bridge_doc_in(&d, "Save changes to Demo_Board?").as_deref(), Some("demo_board"));+ assert_eq!(super::bridge_doc_in(&d, "ADOM-TEST-42 has unsaved changes").as_deref(), Some("adom-test-"));+ assert_eq!(super::bridge_doc_in(&d, "MyRobotArm v3"), None);+ crate::fusion_prefs::action_ledger(&d, "x", serde_json::json!({"k": 1}));+ assert_eq!(super::ledger_entries_since(&d, 0.0, 12).len(), 1);+ assert_eq!(super::ledger_entries_since(&d, 4e9, 12).len(), 0);+ let _ = std::fs::remove_dir_all(&d);+ }+ #[test]+ fn b64_matches_python() { assert_eq!(super::b64(b"hello"), "aGVsbG8="); assert_eq!(super::b64(b""), ""); assert_eq!(super::b64(b"ab"), "YWI="); }+}
rust/bridge/src/fusion_prefs.rsadded+153@@ -0,0 +1,153 @@+//! Fusion's own preferences (theme, orbit scheme, units...) and the workspace reset, served by+//! the exe over the add-in pass-through. The preference script is byte-identical to server.py's+//! `_PREF_SCRIPT` (a test pins that), so both shells drive Fusion the same way.++use serde_json::{json, Map, Value};++pub const PREF_SCRIPT: &str = include_str!("pref_script.py");++fn py_json_string_literal(v: &Value) -> String {+ // json.dumps(json.dumps(reqs)): the request dict as a JSON string, then that string as a+ // JSON literal, which Python's json.loads(__REQS__) unwraps+ let inner = py_dumps(v);+ serde_json::to_string(&inner).unwrap_or_else(|_| "\"{}\"".into())+}++/// `_run_pref_script`: one retry on a transient add-in connection error.+fn run_pref_script(script: String, timeout: u64, call: &dyn Fn(&str, Value, u64) -> Value) -> Value {+ let res = call("run_modeling_script", json!({"script": script}), timeout);+ if res["success"].as_bool() == Some(true) { return res; }+ let err = res["error"].as_str().unwrap_or("").to_lowercase();+ if ["not running", "not responding", "add-in", "connection", "timed out", "refused"].iter().any(|s| err.contains(s)) {+ std::thread::sleep(std::time::Duration::from_millis(1500));+ let res2 = call("run_modeling_script", json!({"script": script}), timeout);+ if !res2.is_null() { return res2; }+ }+ res+}++fn shape(res: &Value, wrote: bool) -> Value {+ if res["success"].as_bool() != Some(true) {+ return json!({"success": false, "error": res["error"].as_str().unwrap_or("preferences script did not run"),+ "_hint": "fusion_set_preference/get_preferences need Fusion running + signed in (fusion_readiness -> ready:true). If needsSignin, drive the sign-in first."});+ }+ let data = &res["data"]["result"];+ let applied = data["applied"].as_object().cloned().unwrap_or_default();+ let current = data["current"].clone();+ let mut out = json!({"success": true, "applied": applied, "current": if current.is_null() { json!({}) } else { current }});+ if wrote {+ let oks: Vec<&str> = applied.iter().filter(|(_, v)| v["ok"].as_bool() == Some(true)).map(|(k, _)| k.as_str()).collect();+ let fails: Vec<(String, String)> = applied.iter().filter(|(_, v)| v["ok"].as_bool() != Some(true)).map(|(k, v)| (k.clone(), py_str(&v["error"]))).collect();+ if !fails.is_empty() {+ out["partial"] = json!(true);+ out["_hint"] = json!(format!("Applied live: {}. FAILED: {}. Note: some themes (Dark Gray/Classic) aren't shipped in every Fusion build - use 'darkblue' or 'device'. See `current` for the resulting state.",+ if oks.is_empty() { "(none)".to_string() } else { oks.join(", ") }, fails.iter().map(|(k, e)| format!("{k}={e}")).collect::<Vec<_>>().join("; ")));+ } else {+ out["_hint"] = json!(format!("Applied LIVE (no restart needed): {}. See `current` for resulting state.", oks.join(", ")));+ }+ } else {+ out["_hint"] = json!("Current Fusion preferences. Change any with fusion_set_preference, keys: theme (light/darkblue/darkgray/classic/device/dark), invertScrollZoom (bool), orbitScheme (fusion360/alias/inventor/solidworks/tinkercad/powermill), modelingOrientation (yup/zup), gestureNav (bool), cameraPivot (bool), lengthUnit (mm/cm/m/in/ft - applies to the active design).");+ }+ out+}++pub fn get_preferences(call: &dyn Fn(&str, Value, u64) -> Value) -> Value {+ let script = PREF_SCRIPT.replace("__REQS__", &py_json_string_literal(&json!({})));+ shape(&run_pref_script(script, 20, call), false)+}++pub fn set_preference(args: &Value, call: &dyn Fn(&str, Value, u64) -> Value) -> Value {+ let reqs: Map<String, Value> = args.as_object().map(|o| o.iter().filter(|(k, _)| k.as_str() != "settle").map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default();+ if reqs.is_empty() {+ return json!({"success": false, "error": "No preferences given.",+ "_hint": "Pass e.g. {\"theme\":\"dark\"} or {\"invertScrollZoom\":true,\"orbitScheme\":\"solidworks\"}. Call fusion_get_preferences to see current values + keys."});+ }+ let script = PREF_SCRIPT.replace("__REQS__", &py_json_string_literal(&Value::Object(reqs)));+ shape(&run_pref_script(script, 30, call), true)+}++/// `_bridge_action_ledger`: one append-only record in `<state>/actions.jsonl`.+pub fn action_ledger(state_dir: &std::path::Path, action: &str, detail: Value) {+ let epoch = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);+ let mut rec = json!({"at": chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), "epoch": (epoch * 10.0).round() / 10.0, "action": action, "pid": std::process::id()});+ if let (Some(r), Some(d)) = (rec.as_object_mut(), detail.as_object()) { for (k, v) in d { r.insert(k.clone(), v.clone()); } }+ let _ = std::fs::create_dir_all(state_dir);+ use std::io::Write;+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(state_dir.join("actions.jsonl")) { let _ = writeln!(f, "{}", rec); }+}++/// `fusion_reset_workspace`: close everything, then sweep the unmodified Untitled scratch.+pub fn reset_workspace(state_dir: &std::path::Path, args: &Value, pass: &dyn Fn(&str, Value) -> Value, call: &dyn Fn(&str, Value, u64) -> Value) -> Value {+ let force = args["force"].as_bool().unwrap_or(false);+ let r = pass("close_all_documents", json!({"save": false, "closeUntitled": true, "force": force}));+ let mut closed: Vec<String> = Vec::new();+ let mut prev_total: Option<usize> = None;+ for _ in 0..24 {+ let st = call("document_info", json!({}), 8);+ let docs = st["data"]["openDocuments"].as_array().cloned().unwrap_or_default();+ let untitled: Vec<&Value> = docs.iter().filter(|d| d["name"].as_str().unwrap_or("").to_lowercase().starts_with("untitled") && !(d["isModified"].as_bool().unwrap_or(false) || d["modified"].as_bool().unwrap_or(false))).collect();+ if (untitled.len() <= 1 && docs.len() == untitled.len()) || untitled.is_empty() { break; }+ if let Some(p) = prev_total { if docs.len() >= p { break; } }+ prev_total = Some(docs.len());+ let victim = untitled[0]["name"].clone();+ let c = call("close_document", json!({"name": victim, "save": false}), 20);+ if c["success"].as_bool() != Some(true) { break; }+ closed.push(py_str(&victim));+ }+ if !closed.is_empty() {+ eprintln!("[fusion-bridge] swept {} Untitled scratch doc(s): {}", closed.len(), closed.iter().take(6).cloned().collect::<Vec<_>>().join(", "));+ action_ledger(state_dir, "untitled_scratch_swept", json!({"count": closed.len()}));+ }+ let st = call("document_info", json!({}), 8);+ let left: Vec<Value> = st["data"]["openDocuments"].as_array().map(|a| a.iter().map(|d| d["name"].clone()).collect()).unwrap_or_default();+ json!({"success": true, "closedByAddin": r["data"]["closed"], "skipped": r["data"]["skipped"], "untitledScratchClosed": closed.len(), "remaining": left,+ "_hint": "Fusion always keeps ONE blank document alive, so a single Untitled remaining is correct, not a failure. A modified Untitled is left untouched unless you pass force:true - it may hold unsaved work."})+}++#[cfg(test)]+mod tests {+ #[test]+ fn pref_script_is_byte_identical_to_server_py() {+ let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../server.py")).expect("server.py beside the workspace");+ let i = src.find("_PREF_SCRIPT = r'''").unwrap() + "_PREF_SCRIPT = r'''".len();+ let j = src[i..].find("'''").unwrap() + i;+ assert_eq!(&src[i..j], super::PREF_SCRIPT, "rust/bridge/src/pref_script.py drifted from server.py's _PREF_SCRIPT");+ }+ #[test]+ fn py_dumps_matches_python() {+ assert_eq!(super::py_dumps(&serde_json::json!({"theme": "dark", "invertScrollZoom": true, "n": null})), r#"{"theme": "dark", "invertScrollZoom": true, "n": null}"#);+ assert_eq!(super::py_dumps(&serde_json::json!("é\"x")), r#""\u00e9\"x""#);+ }++}++/// Python's default `json.dumps` (separators ", " / ": ", ensure_ascii, insertion order).+pub fn py_dumps(v: &Value) -> String {+ fn esc(s: &str, out: &mut String) {+ out.push('"');+ for c in s.chars() {+ match c {+ '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"),+ '\u{08}' => out.push_str("\\b"), '\u{0c}' => out.push_str("\\f"),+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),+ c if (c as u32) > 0x7e => { let mut b = [0u16; 2]; for u in c.encode_utf16(&mut b) { out.push_str(&format!("\\u{:04x}", u)); } }+ c => out.push(c),+ }+ }+ out.push('"');+ }+ fn walk(v: &Value, out: &mut String) {+ match v {+ Value::Null => out.push_str("null"), Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),+ Value::Number(n) => out.push_str(&n.to_string()), Value::String(s) => esc(s, out),+ Value::Array(a) => { out.push('['); for (i, x) in a.iter().enumerate() { if i > 0 { out.push_str(", "); } walk(x, out); } out.push(']'); }+ Value::Object(o) => { out.push('{'); for (i, (k, x)) in o.iter().enumerate() { if i > 0 { out.push_str(", "); } esc(k, out); out.push_str(": "); walk(x, out); } out.push('}'); }+ }+ }+ let mut out = String::new(); walk(v, &mut out); out+}++/// Python's `str()` of a JSON value.+pub fn py_str(v: &Value) -> String {+ match v { Value::Null => "None".into(), Value::Bool(b) => if *b { "True".into() } else { "False".into() }, Value::String(s) => s.clone(), other => py_dumps(other) }+}
rust/bridge/src/janitor.rsadded+132@@ -0,0 +1,132 @@+//! The sidecar's janitor tick, native: every 20 s take the Autodesk seat if a mid-session+//! conflict popped, grow a squint-sized Fusion window (six tries, then leave it alone), keep+//! the driving badge honest, answer a bridge document's Unsaved-changes prompt; every 5 min+//! click Fusion's update button in the background when Fusion is verifiably idle.+use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::Duration;++const BADGE_IDLE_SECONDS: f64 = 300.0;+static SIZE_ATTEMPTS: Mutex<Vec<(i64, u32)>> = Mutex::new(Vec::new());+static LAST_SEAT_NOTICE: Mutex<f64> = Mutex::new(0.0);+static LAST_UPDATE_SWEEP: Mutex<f64> = Mutex::new(0.0);++fn now() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }+fn pref_on(sd: &std::path::Path, key: &str) -> bool { crate::small_verbs::get_pref(sd, key).map(|v| v != json!(false)).unwrap_or(true) }++/// `ensure_reasonable_window`: grow a small Fusion window to 92% of its monitor's work area,+/// centered, without activating it (ab moves it; Win32 only answers the two facts).+fn ensure_reasonable_window(ctx: &Ctx, hwnd: i64, rect: &Value) -> Value {+ if ctx.host.window_is_maximized(hwnd) { return json!({"sized": false, "why": "already maximized (user's choice)"}); }+ let Some((wl, wt, ww, wh)) = ctx.host.window_work_area(hwnd) else { return json!({"sized": false, "why": "GetMonitorInfo failed"}) };+ let (cw, ch) = (rect["width"].as_i64().unwrap_or(0), rect["height"].as_i64().unwrap_or(0));+ if (cw * ch) as f64 >= 0.60 * (ww as f64) * (wh as f64) { return json!({"sized": false, "why": "already large enough", "current": [cw, ch], "workArea": [ww, wh]}); }+ let (nw, nh) = ((ww as f64 * 0.92) as i64, (wh as f64 * 0.92) as i64);+ let (x, y) = (wl as i64 + (ww as i64 - nw) / 2, wt as i64 + (wh as i64 - nh) / 2);+ let ok = Loopback::from_env().map(|ab| ab.call("desktop_set_window_state", json!({"hwnd": hwnd, "left": x, "top": y, "width": nw, "height": nh, "reason": "Fusion's window was small enough to squint at; grown to 92% of the work area without activating it"}), &ctx.caller, 20).is_ok()).unwrap_or(false);+ json!({"sized": ok, "from": [cw, ch], "to": [nw, nh], "workArea": [ww, wh], "hwnd": hwnd, "why": "grew to 92% of the work area without activating it"})+}++fn tick(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value) {+ let sd = ctx.state_dir.clone();+ let running = !ctx.host.fusion_processes().is_empty();+ let facts = Loopback::from_env().map(|ab| fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller)).unwrap_or_default();+ // the seat+ if running && pref_on(&sd, "autoTakeOverSeat") && facts.licensing_dialog.is_some() {+ let r = crate::readiness_exec::resolve_seat_dialog(ctx, 3);+ if r["clicks"].as_u64().unwrap_or(0) > 0 {+ let mut last = LAST_SEAT_NOTICE.lock().unwrap();+ if now() - *last > 120.0 { *last = now(); let _ = crate::small_verbs::notify_owner(&sd, &json!({"title": "Fusion seat taken over on this machine", "body": "Autodesk flagged your account's session limit, so I clicked through the 'Active Sessions Exceeded' dialogs in the background and took the seat here. Fusion on your other machine is SUSPENDED (resumable, nothing lost) - using Fusion there will simply grab the seat back the same way.", "level": "info"}), &ctx.caller); }+ eprintln!("[fusion-bridge] seat sweep: took over the session (clicks={}, verified={})", r["clicks"], r["verified"]);+ crate::fusion_prefs::action_ledger(&sd, "seat_takeover", json!({"clicks": r["clicks"], "verified": r["verified"]}));+ }+ }+ // the window size (six sweeps, then it is the user's)+ if running && pref_on(&sd, "sizeFusionWindow") { if let Some(h) = facts.main_hwnd {+ let n = SIZE_ATTEMPTS.lock().unwrap().iter().find(|(k, _)| *k == h).map(|(_, n)| *n).unwrap_or(0);+ if n < 6 {+ let rect = facts.family.iter().find(|w| w["hwnd"].as_i64() == Some(h)).map(|w| w["rect"].clone()).unwrap_or(json!({}));+ let r = ensure_reasonable_window(ctx, h, &rect);+ let why = r["why"].as_str().unwrap_or("");+ let next = if why.contains("already") { 99 } else { n + 1 };+ { let mut v = SIZE_ATTEMPTS.lock().unwrap(); v.retain(|(k, _)| *k != h); v.push((h, next)); }+ if r["sized"].as_bool() == Some(true) { eprintln!("[fusion-bridge] window sized on sweep (attempt {}): {why}", n + 1); }+ }+ } }+ // the badge: on while driving, shed after five idle minutes+ if running && pref_on(&sd, "showDrivingBadge") {+ let idle = crate::etiquette::last_drive_at().map(|t| now() - t);+ match (crate::etiquette::badge_active(), idle) {+ (false, Some(i)) if i <= BADGE_IDLE_SECONDS => { if let Some(h) = facts.main_hwnd { crate::etiquette::set_badge(&ctx.caller, h); } }+ (true, Some(i)) if i > BADGE_IDLE_SECONDS => { if crate::etiquette::clear_badge(&ctx.caller) { eprintln!("[fusion-bridge] driving badge shed after {BADGE_IDLE_SECONDS}s idle"); } }+ _ => {}+ }+ }+ // a bridge document's Unsaved-changes prompt+ if running { let _ = crate::etiquette::dismiss_save_prompt_if_bridge_doc(&sd, &ctx.caller, &ctx.pids, ""); }+ // dirty bridge documents left open while nothing drives: ours, never the user's+ if running && crate::etiquette::last_drive_at().map(|t| now() - t >= 180.0).unwrap_or(true) {+ let di = pass("document_info", json!({}));+ for d in di["data"]["openDocuments"].as_array().cloned().unwrap_or_default() {+ let name = d["name"].as_str().unwrap_or("").to_string();+ let dirty = d["isModified"].as_bool().unwrap_or(false) || d["modified"].as_bool().unwrap_or(false) || name.ends_with('*');+ if name.is_empty() || !dirty || crate::etiquette::bridge_doc_in(&sd, &name.to_lowercase()).is_none() { continue; }+ if crate::orchestrations::close_bridge_doc_verified(ctx, &name, 60, pass)["closed"].as_bool() == Some(true) {+ eprintln!("[fusion-bridge] janitor: closed dirty bridge doc {} (verified gone)", fusion_lbr::py_repr_str(&name));+ crate::fusion_prefs::action_ledger(&sd, "idle_doc_close", json!({"doc": name}));+ }+ }+ }+ // a stray Create-new prompt, and Fusion's crash reporter (never Send Report for the user)+ if let Some(ab) = Loopback::from_env() {+ for d in crate::small_verbs::classify_blocking_dialogs(&ab, &ctx.caller, &ctx.pids) {+ if d["category"].as_str() == Some("create_confirm") {+ let _ = ab.call("desktop_ui_click", json!({"hwnd": d["hwnd"], "name": "No"}), &ctx.caller, 12);+ eprintln!("[fusion-bridge] janitor: declined a stray Create-new prompt ({})", d["matchedOn"].as_str().unwrap_or("title"));+ crate::fusion_prefs::action_ledger(&sd, "create_confirm_declined", json!({"matchedOn": d["matchedOn"]}));+ }+ }+ let busy = crate::long_op_marker().is_some() || crate::etiquette::last_drive_at().map(|t| now() - t < 180.0).unwrap_or(false);+ if !busy { if let Ok(v) = ab.call("desktop_list_windows", json!({}), &ctx.caller, 20) {+ let wins = v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default();+ if let Some(w) = wins.iter().find(|w| w["title"].as_str().unwrap_or("").contains("Fusion Error Report")) {+ let h = w["hwnd"].clone();+ crate::fusion_prefs::action_ledger(&sd, "crash_reporter_seen", json!({"hwnd": h, "pid": w["pid"]}));+ let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 15);+ std::thread::sleep(Duration::from_secs(2));+ let still = ab.call("desktop_list_windows", json!({}), &ctx.caller, 20).ok().map(|v2| v2["windows"].as_array().or_else(|| v2["data"]["windows"].as_array()).cloned().unwrap_or_default().iter().any(|x| x["hwnd"] == h)).unwrap_or(false);+ crate::fusion_prefs::action_ledger(&sd, if still { "crash_reporter_wedged" } else { "crash_reporter_dismissed" }, json!({"hwnd": h, "closed": !still}));+ eprintln!("[fusion-bridge] janitor: Fusion crash reporter handled (ledgered; never Send Report on the user's behalf)");+ }+ } }+ }+ // the silent update, every five minutes, only when nothing is driving Fusion+ if running && pref_on(&sd, "autoUpdateFusion") {+ let mut last = LAST_UPDATE_SWEEP.lock().unwrap();+ if now() - *last > 300.0 {+ *last = now();+ let busy = crate::long_op_marker().is_some() || crate::etiquette::last_drive_at().map(|t| now() - t < 180.0).unwrap_or(false);+ if !busy {+ let _ = crate::maintenance::apply_fusion_update(ctx, pass);+ let _ = crate::readiness_exec::dismiss_recovery_dialog(ctx, 0.0, true);+ }+ }+ }+}++/// Start the tick on its own thread; a fresh host each pass (the borrowed one cannot cross).+pub fn start() {+ std::thread::spawn(|| loop {+ std::thread::sleep(Duration::from_secs(20));+ let host = crate::host_for_diagnosis();+ let sd = crate::host().bridge_state_dir();+ let side = |_v: &str, _a: Value, _t: u64| -> Value { json!({"success": false, "error": "no sidecar"}) };+ let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd, caller: fusion_core::ab::Caller::default(), pids, sidecar: &side, dialogs: &dlg };+ let pass = |verb: &str, a: Value| crate::addin::addin_http(verb, &a, 30);+ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tick(&ctx, &pass)));+ });+}
rust/bridge/src/launch.rsadded+329@@ -0,0 +1,329 @@+//! `fusion_start` and the add-in preamble, native. Ported from server.py's `_handle_launch`+//! and `_addin_preamble`: the warm path (busy guard, the user's restart hold, the persistence+//! gate), the breakaway launch through `HostProcess::launch_fusion`, the launch-dialog sweep,+//! the seat resolver and recovery dismissal (sidecar executors, UIA and Win32 work the exe+//! does not do itself), window sizing through ab, the add-in wait with measured progress, and+//! the launch timings. `gate` is the preamble every add-in verb passes before it is sent.++use serde_json::{json, Value};+use std::time::{Duration, Instant};++use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};++fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++fn health_probe(timeout: Duration) -> bool { ureq::get("http://127.0.0.1:8774/health").timeout(timeout).call().is_ok() }++/// `wait_for_addin(timeout)`: the health endpoint, once a second.+pub fn wait_for_addin(seconds: u64) -> bool {+ for _ in 0..seconds { if health_probe(Duration::from_secs(1)) { return true; } std::thread::sleep(Duration::from_secs(1)); }+ false+}++/// `_addin_round_trip_ready(tries)`: a real get_app_state, `tries` times in a row.+pub fn round_trip_ready(tries: u32) -> bool { round_trip_ready_gap(tries, 1.5) }+pub fn round_trip_ready_gap(tries: u32, gap_secs: f64) -> bool {+ for i in 0..tries {+ let ok = ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(6)).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);+ if !ok { return false; }+ if i + 1 < tries { std::thread::sleep(Duration::from_secs_f64(gap_secs)); }+ }+ true+}++fn running(ctx: &Ctx) -> bool { !ctx.host.fusion_processes().is_empty() }++/// `_user_restart_hold_active`.+pub fn user_restart_hold_active(state_dir: &std::path::Path) -> bool {+ let f = state_dir.join("user_restart_hold.json");+ let Ok(t) = std::fs::read_to_string(&f) else { return false };+ let at = serde_json::from_str::<Value>(&t).ok().and_then(|v| v["at"].as_f64()).unwrap_or(0.0);+ if now_f() - at > 4.0 * 3600.0 { let _ = std::fs::remove_file(&f); return false; }+ if round_trip_ready(1) { let _ = std::fs::remove_file(&f); return false; }+ true+}++fn timings_launch(state_dir: &std::path::Path) -> Value { fusion_core::readiness::timings_summary(&crate::timings::load(state_dir))["launch"].clone() }++/// `timings.sentence()`.+pub fn timings_sentence(state_dir: &std::path::Path) -> Option<String> {+ let s = timings_launch(state_dir);+ let bits: Vec<String> = ["cold", "warm"].iter().filter_map(|k| s[k]["p50"].as_f64().map(|p| format!("{k} {}s", p as i64))).collect();+ if bits.is_empty() { None } else { Some(format!("on this machine Fusion starts in {} (measured)", bits.join(", "))) }+}++/// `timings.terminal_block`.+fn terminal_block(started: f64, label: &str, start_kind: &str, failed: bool) -> Value {+ let elapsed = (now_f() - started).max(0.0);+ json!({"phase": "launch", "stepLabel": label, "percent": if failed { Value::Null } else { json!(100) }, "elapsedSec": elapsed.round() as i64, "estimatedSec": elapsed.round() as i64,+ "etaSec": if failed { Value::Null } else { json!(0) }, "confidence": "measured", "startKind": start_kind, "failed": failed,+ "measured": {"phase": "launch", "seconds": (elapsed * 10.0).round() / 10.0, "startKind": start_kind}})+}++fn busy_status() -> Option<Value> { crate::addin::addin_status_probe(Duration::from_secs(3)).filter(|s| s["busy"].as_bool() == Some(true)) }++fn busy_return(st: &Value, hint: &str, error: &str) -> Value {+ json!({"success": false, "error": error, "errorCode": "fusion_addin_busy", "busyCommand": st["busyCommand"], "elapsedSeconds": st["elapsedSeconds"], "alreadyRunning": true, "startKind": "warm", "_hint": hint})+}++/// `ensure_reasonable_window`: grow a small Fusion window to 92% of its monitor's work area,+/// centred, without activating it (ab's desktop_set_window_bounds with restore:false).+fn ensure_reasonable_window(ctx: &Ctx) -> Value {+ let Some(ab) = Loopback::from_env() else { return json!({"sized": false, "why": "no ab loopback"}) };+ let facts = fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller);+ let Some(h) = facts.main_hwnd else { return json!({"sized": false, "why": "no fusion window"}) };+ let main = facts.family.iter().find(|w| w["hwnd"].as_i64() == Some(h)).cloned().unwrap_or(json!({}));+ if main["maximized"].as_bool() == Some(true) || main["state"].as_str() == Some("maximized") { return json!({"sized": false, "why": "already maximized (user's choice)"}); }+ let rect = fusion_core::dialogs::rect_of(&main["rect"]);+ let (cur_w, cur_h, left, top) = (rect["width"].as_i64().unwrap_or(0), rect["height"].as_i64().unwrap_or(0), rect["left"].as_i64().unwrap_or(0), rect["top"].as_i64().unwrap_or(0));+ let Ok(disp) = ab.call("display", json!({}), &ctx.caller, 10) else { return json!({"sized": false, "why": "display query failed"}) };+ let mons = disp["monitors"].as_array().cloned().unwrap_or_default();+ let cx = left + cur_w / 2; let cy = top + cur_h / 2;+ let mon = mons.iter().find(|m| { let b = &m["bounds"]; let (x, y, w, hh) = (b["x"].as_i64().unwrap_or(0), b["y"].as_i64().unwrap_or(0), b["width"].as_i64().unwrap_or(0), b["height"].as_i64().unwrap_or(0)); cx >= x && cx < x + w && cy >= y && cy < y + hh }).or_else(|| mons.iter().find(|m| m["primary"].as_bool() == Some(true))).cloned().unwrap_or(json!({}));+ let wa = if mon["workArea"].is_object() { mon["workArea"].clone() } else { mon["bounds"].clone() };+ let (wa_x, wa_y, wa_w, wa_h) = (wa["x"].as_i64().unwrap_or(0), wa["y"].as_i64().unwrap_or(0), wa["width"].as_i64().unwrap_or(0), wa["height"].as_i64().unwrap_or(0));+ if wa_w <= 0 || wa_h <= 0 { return json!({"sized": false, "why": "GetMonitorInfo failed"}); }+ if (cur_w * cur_h) as f64 >= 0.60 * (wa_w * wa_h) as f64 { return json!({"sized": false, "why": "already large enough", "current": [cur_w, cur_h], "workArea": [wa_w, wa_h]}); }+ let (new_w, new_h) = ((wa_w as f64 * 0.92) as i64, (wa_h as f64 * 0.92) as i64);+ let (x, y) = (wa_x + (wa_w - new_w) / 2, wa_y + (wa_h - new_h) / 2);+ let ok = ab.call("desktop_set_window_bounds", json!({"hwnd": h, "x": x, "y": y, "w": new_w, "h": new_h, "restore": false}), &ctx.caller, 10).is_ok();+ json!({"sized": ok, "from": [cur_w, cur_h], "to": [new_w, new_h], "workArea": [wa_w, wa_h], "hwnd": h, "why": "grew to 92% of the work area without activating it"})+}++/// The taskbar launch bar after a stillLaunching answer (`_start_launch_progress_watch`).+fn start_launch_progress_watch(ctx: &Ctx) {+ let mode = crate::small_verbs::get_pref(&ctx.state_dir, "activityIndicator").and_then(|v| v.as_str().map(|s| s.to_lowercase())).unwrap_or_else(|| "progress".into());+ if mode != "progress" && mode != "both" { return; }+ static ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);+ if ACTIVE.swap(true, std::sync::atomic::Ordering::Relaxed) { return; }+ let (sd, caller) = (ctx.state_dir.clone(), ctx.caller.clone());+ std::thread::spawn(move || {+ let started = crate::small_verbs::get_pref(&sd, "launchStartedAt").and_then(|v| v.as_f64()).unwrap_or_else(now_f);+ let (est, _) = { let st = fusion_core::readiness::timings_stats(&crate::timings::load(&sd), "launch", "cold"); match (st["count"].as_u64().unwrap_or(0), st["p50"].as_f64()) { (c, Some(p)) if c >= 2 && p > 0.0 => (p, "measured"), (1, Some(p)) if p > 0.0 => (p, "typical"), _ => (150.0, "typical") } };+ let give_up = started + 2.0 * est + 120.0;+ while now_f() < give_up {+ if round_trip_ready(1) { crate::timings::taskbar_progress(&caller, "normal", Some(100.0)); std::thread::sleep(Duration::from_secs(1)); crate::timings::taskbar_progress(&caller, "none", None); ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed); return; }+ crate::timings::taskbar_progress(&caller, "normal", Some((95.0 * (now_f() - started) / est).min(95.0)));+ std::thread::sleep(Duration::from_secs(2));+ }+ crate::timings::taskbar_progress(&caller, "error", Some(100.0)); std::thread::sleep(Duration::from_millis(1500)); crate::timings::taskbar_progress(&caller, "none", None);+ ACTIVE.store(false, std::sync::atomic::Ordering::Relaxed);+ });+}++fn not_installed() -> Value {+ json!({"success": false, "error": "Fusion 360 is not installed on this machine.", "errorCode": "fusion_not_installed",+ "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in. (For a plain 'what EDA tools are installed' check, ab's bridge_readiness reports this cleanly without erroring.)"})+}++/// `fusion_start`.+pub fn start(ctx: &Ctx, _args: &Value) -> Value {+ 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('\\', "/");+ let warm_start = now_f();+ if running(ctx) {+ let mut addin_ok = wait_for_addin(5);+ if addin_ok { crate::timings::record(&sd, "launch", now_f() - warm_start, Some("warm")); }+ if !addin_ok {+ if let Some(st) = busy_status() {+ return busy_return(&st, "Do NOT call fusion_start again on this - it is exactly how a healthy run gets killed. Poll fusion_readiness (it answers while the add-in is busy) and compare elapsedSeconds against fusion_state.timings for this machine; only past roughly 2x p90 is a restart justified.",+ "Fusion is running and the add-in is BUSY with a long command - NOT restarting it. Waiting is the remedy here, not a relaunch.");+ }+ if user_restart_hold_active(&sd) {+ return json!({"success": false, "errorCode": "user_owns_restart", "alreadyRunning": true, "startKind": "warm",+ "error": "Fusion is running without its add-in, and the user chose 'I'll handle it' on the restart notice - so the bridge will NOT restart Fusion. The user owns that restart now.",+ "_hint": "Do not call fusion_start or fusion_restart_for_addin. Wait for the user to restart Fusion themselves (the hold releases the moment the add-in answers), or ask them again with fusion_restart_for_addin only if THEY change their mind."});+ }+ crate::fusion_prefs::action_ledger(&sd, "fusion_restart_for_silent_addin_considered", json!({"reason": "running but add-in not answering; entering persistence gate"}));+ for _ in 0..3 {+ std::thread::sleep(Duration::from_secs(8));+ if wait_for_addin(5) { addin_ok = true; break; }+ if let Some(st) = busy_status() {+ return busy_return(&st, "Do NOT call fusion_start again - poll fusion_readiness and wait; a busy add-in answers again when its command finishes.",+ "Fusion is running and the add-in reported BUSY during the persistence re-probe - NOT restarting it.");+ }+ }+ }+ if !addin_ok {+ eprintln!("[fusion-bridge] warm start found NO add-in after a ~30s persistence window (never busy, never answered) - restarting Fusion so the add-in loads (issue #39, #41 busy guard, + the 2026-08-22 library-matrix persistence gate)");+ let _ = crate::lifecycle::kill(ctx, &json!({}));+ std::thread::sleep(Duration::from_secs(3));+ } else {+ return json!({"success": true, "output": "Fusion 360 is already running.", "addinConnected": true, "alreadyRunning": true, "startKind": "warm",+ "progress": terminal_block(warm_start, "Fusion is already running - reusing it", "warm", false), "timings": timings_launch(&sd), "resolvedPath": exe_path});+ }+ }+ let reloc = crate::readiness_exec::relocate_recovery_files();+ let reloc_moved = reloc["moved"].as_i64().unwrap_or(0);+ let start = warm_start;+ crate::small_verbs::set_pref(&sd, "launchStartedAt", json!(start));+ // ensure_fusion_running (wait_addin false): the breakaway launch, then the process must appear+ if !running(ctx) {+ if let Err(e) = ctx.host.launch_fusion() { return json!({"success": false, "error": format!("Failed to launch Fusion 360: {e:?}")}); }+ ctx.host.background_envelope(150.0, true);+ let mut appeared = false;+ for _ in 0..60 { std::thread::sleep(Duration::from_millis(500)); if running(ctx) { appeared = true; break; } }+ if !appeared {+ let mut launch_err: Option<Value> = None;+ if let Some(ab) = Loopback::from_env() {+ for d in crate::small_verbs::classify_blocking_dialogs(&ab, &ctx.caller, &[]) {+ if d["category"].as_str() == Some("launch_error") || d["title"].as_str().unwrap_or("").to_lowercase().contains("streamed application") {+ if let Some(h) = d["hwnd"].as_i64() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10); }+ launch_err = Some(d);+ }+ }+ }+ let complete = { #[cfg(windows)] { fusion_os_win::any_app_complete() } #[cfg(not(windows))] { false } };+ if complete {+ return json!({"success": false, "error": format!("Fusion is INSTALLED (Fusion360.exe present) but did not start within 30s{}.", if launch_err.is_some() { " - a launch-error dialog was dismissed" } else { "" }),+ "errorCode": "fusion_launch_failed", "installed": true, "dialog": launch_err.as_ref().map(|d| d["title"].clone()).unwrap_or(Value::Null),+ "_hint": "The install is COMPLETE - do NOT re-stream. Retry fusion_start (it now picks the FusionLauncher.exe that has its .ini beside it; a stale lock from a just-killed instance also clears on a retry). If it keeps failing, fusion_kill then wait a few seconds before fusion_start."});+ }+ let removed = json!(crate::host().clean_incomplete_webdeploy());+ return json!({"success": false, "error": "Fusion launch failed: the streamed install is INCOMPLETE (no Fusion360.exe yet).", "errorCode": "fusion_incomplete_install",+ "cleanedWebdeployDirs": removed, "dialog": launch_err.as_ref().map(|d| d["title"].clone()).unwrap_or(Value::Null),+ "_hint": "AUTO-RECOVERED: dismissed the launch-error dialog and removed the partial webdeploy dir(s). Re-run fusion_install_fusion, then WAIT for fusion_readiness to report installed:true BEFORE fusion_start - starting mid-stream is what corrupts the install."});+ }+ std::thread::sleep(Duration::from_secs(5));+ }+ // launch dialogs: the benign acks are closed+ if let Some(ab) = Loopback::from_env() {+ let auto = fusion_core::dialogs::category_set("AUTO_DISMISS");+ for d in crate::small_verbs::classify_blocking_dialogs(&ab, &ctx.caller, &[]) {+ if d["category"].as_str().map(|c| auto.iter().any(|a| a == c)).unwrap_or(false) { if let Some(h) = d["hwnd"].as_i64() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10); } }+ }+ }+ let deadline = Instant::now() + Duration::from_secs(((50.0 - (now_f() - start)).max(6.0)) as u64);+ let mut seat_clicks: i64 = 0;+ let mut addin_ok = false;+ while Instant::now() < deadline {+ let lic = Loopback::from_env().map(|ab| fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller).licensing_dialog.is_some()).unwrap_or(false);+ if lic {+ if seat_clicks < 10 { seat_clicks += crate::readiness_exec::resolve_seat_dialog(ctx, 3)["clicks"].as_i64().unwrap_or(0); }+ std::thread::sleep(Duration::from_secs(1));+ continue;+ }+ if health_probe(Duration::from_millis(1500)) && round_trip_ready(2) {+ addin_ok = true;+ if crate::small_verbs::get_pref(&sd, "sizeFusionWindow").map(|v| v != json!(false)).unwrap_or(true) {+ let sz = ensure_reasonable_window(ctx);+ if sz["sized"].as_bool() == Some(true) { eprintln!("[fusion-bridge] window sized: {}", sz["why"].as_str().unwrap_or("")); }+ }+ break;+ }+ let _ = crate::readiness_exec::dismiss_recovery_dialog(ctx, 5.0, true);+ std::thread::sleep(Duration::from_millis(2500));+ }+ if !addin_ok {+ let stats = fusion_core::readiness::timings_stats(&crate::timings::load(&sd), "launch", "cold");+ let prog = fusion_core::readiness::progress_block(&stats, start, now_f(), "Fusion is starting - waiting for the Adom add-in to serve", 150.0);+ start_launch_progress_watch(ctx);+ let sentence = timings_sentence(&sd).map(|s| format!("{s}. ")).unwrap_or_default();+ let eta_bit = match prog["etaSec"].as_i64() { Some(e) => format!("The add-in isn't up yet - about {e}s remaining by this machine's own measured history (read progress.percent / progress.etaSec on this response; the estimate is revised as the launch proceeds). This is "), None => "The add-in isn't up yet and this machine has no measured launch history yet, so progress.confidence is 'typical' until it learns. This is ".to_string() };+ return json!({"success": true, "stillLaunching": true, "addinConnected": false, "startKind": "cold", "progress": prog, "timings": timings_launch(&sd), "statusVerb": "fusion_readiness",+ "recoveryFilesRelocated": reloc_moved, "resolvedPath": exe_path, "seatDialogResolved": seat_clicks > 0, "backgroundLaunch": true,+ "_hint": format!("Fusion was launched IN THE BACKGROUND (minimized, foreground-lock on) so it does not steal the user's focus - do NOT foreground it. {}{sentence}{eta_bit}NOT a failure and NOT a retry-able timeout: do NOT re-call fusion_start. WHAT HAPPENS NEXT: POLL fusion_readiness every ~10s; it returns needsSignin/licensingDialog/updating if something is mid-flight (the bridge handles the seat dialog itself), and ready:true when it is drivable - then run your fusion_* verbs. NEVER poll blind past ~2 min: desktop_screenshot_window the Fusion hwnd and READ the response's screenshots[] array - every OWNED POPUP rides back as its own child shot (this is how you SEE a blocking dialog; never desktop_screenshot_screen, the user is doing other things).",+ if seat_clicks > 0 { "A seat 'Active Sessions Exceeded' dialog appeared and was AUTO-RESOLVED in the background via UIA (no foreground). " } else { "" })});+ }+ let recovery_dismissed = crate::readiness_exec::dismiss_recovery_dialog(ctx, 5.0, true).0;+ let dialog_blocking = !round_trip_ready(1);+ crate::timings::record(&sd, "launch", now_f() - start, Some("cold"));+ crate::small_verbs::set_pref(&sd, "launchStartedAt", Value::Null);+ let mut output = "Fusion 360 launched successfully.".to_string();+ if reloc_moved > 0 { output.push_str(&format!(" Relocated {reloc_moved} recovery file(s) to {} (preserved for manual recovery if needed).", reloc["dest"].as_str().unwrap_or("None"))); }+ if recovery_dismissed { output.push_str(" Dismissed 'Recovered Documents' dialog."); }+ if dialog_blocking { output = "Fusion 360 launched but a MODAL DIALOG is blocking the UI. This is likely the 'Recovered Documents' dialog from a previous crash. Take a screenshot with desktop_screenshot_window to see what's showing, then dismiss it manually or use fusion_dismiss_recovery.".into(); }+ json!({"success": true, "output": output, "addinConnected": true, "dialogBlocking": dialog_blocking, "recoveryDialogDismissed": recovery_dismissed, "recoveryFilesRelocated": reloc_moved, "recoveryFilesPath": reloc["dest"],+ "startKind": "cold", "progress": terminal_block(start, "Fusion is up and the add-in is serving", "cold", false), "timings": timings_launch(&sd), "resolvedPath": exe_path})+}++static AUTO_RESTART_AT: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);++/// `_begin_auto_addin_restart`: once per four minutes, in the background, saving what can be saved.+fn begin_auto_addin_restart(_ctx: &Ctx) -> bool {+ let mut at = AUTO_RESTART_AT.lock().unwrap();+ if now_f() - *at < 240.0 { return false; }+ *at = now_f();+ // the restart itself (save, relaunch, wait) is a thread that outlives this call, so it is+ // sent to this exe's own /command rather than run on the borrowed context+ let url = crate::launch::SELF_URL.lock().unwrap().clone().or_else(|| SIDECAR_URL.lock().unwrap().clone());+ std::thread::spawn(move || {+ if let Some(u) = url {+ let r = ureq::post(&u).timeout(Duration::from_secs(900)).send_json(json!({"command": "fusion_restart_for_addin", "args": {"save": true, "auto": true, "wait": true}})).ok().and_then(|r| r.into_json::<Value>().ok()).unwrap_or(json!({}));+ eprintln!("[fusion-bridge] auto add-in restart: {{success: {}, errorCode: {}}}", r["success"], r["errorCode"]);+ }+ });+ true+}++/// The sidecar's /command URL, published by the shell so background threads can reach it.+pub static SIDECAR_URL: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);+/// 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);++/// `_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();+ let mut fusion_running = running(ctx);+ if !installed && !fusion_running {+ return Some(json!({"success": false, "error": "Fusion 360 is not installed on this machine.", "errorCode": "fusion_not_installed",+ "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in."}));+ }+ if !fusion_running {+ for _ in 0..2 {+ std::thread::sleep(Duration::from_millis(1500));+ let hwnd = Loopback::from_env().and_then(|ab| fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller).main_hwnd).is_some();+ if running(ctx) || health_probe(Duration::from_millis(1500)) || hwnd { fusion_running = true; break; }+ }+ }+ if !fusion_running && args["noAutoStart"].as_bool() != Some(true) {+ 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 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; }+ 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>()),+ "autoStartAttempted": true, "autoStartSec": took,+ "_hint": "This is a launch failure, NOT a bad request - the command itself was never run. Check fusion_readiness (needsSignin true means Autodesk sign-in is blocking the launch, which is a human step). Pass noAutoStart:true to get the old refuse-immediately behaviour."}));+ }+ }+ if !fusion_running {+ return Some(json!({"success": false, "error": "Fusion 360 is installed but not running.", "errorCode": "fusion_not_running",+ "_hint": "Call fusion_start to start Fusion 360, then wait for the add-in to become ready (fusion_start blocks until ready). Then retry this command."}));+ }+ if busy_status().is_some() { return None; } // the busy gate answers this one+ if wait_for_addin(5) { return None; }+ let h = crate::readiness_exec::confirmed_hung(ctx);+ if h["hung"].as_bool() == Some(true) {+ crate::fusion_prefs::action_ledger(&ctx.state_dir, "hang_gate_kill", json!({"heldSeconds": h["heldSeconds"], "signals": h["signals"]}));+ let _ = crate::lifecycle::kill(ctx, &json!({}));+ std::thread::sleep(Duration::from_secs(6));+ let _ = start(ctx, &json!({}));+ return Some(json!({"success": false, "error": format!("Fusion 360 itself is NOT RESPONDING (hung host process, held {}s with the add-in refusing a 20s probe); the add-in cannot answer while it is.", h["heldSeconds"]),+ "errorCode": "fusion_host_hung", "hungSignals": h["signals"], "restartAttempted": true,+ "_hint": "Do NOT hunt for a blocking dialog - there is not one. Nothing reaches Fusion over IPC while the host is hung. The bridge force-restarted it; poll fusion_readiness."}));+ }+ if matches!(h["phase"].as_str(), Some("suspect") | Some("busy-not-hung")) {+ return Some(json!({"success": false, "error": "Fusion's main thread is not answering right now - most likely WORKING on something heavy (a STEP import or save starves the add-in's probes), not hung.",+ "errorCode": "fusion_addin_busy", "hostResponding": h["phase"].as_str() == Some("busy-not-hung"), "hungHeldSeconds": h["heldSeconds"],+ "_hint": "WAIT and re-poll; do not restart anything. The bridge only declares a real hang after 180s of continuous silence including a 20s probe - if that happens it restarts Fusion itself and says so."}));+ }+ let ri = crate::chainexec::addin_restart_info(ctx, &json!({"fireToast": true}), &|v, a| crate::addin::addin_http(v, &a, 8));+ if ri["autoRestart"].as_bool() == Some(true) {+ let started = begin_auto_addin_restart(ctx);+ return Some(json!({"success": false, "errorCode": "addin_restarting",+ "error": "Fusion is running without the Adom add-in and no unsaved document belongs to the user, so the bridge is restarting Fusion itself (documents are saved first). No toast, no ask.",+ "restartStarted": started, "userUnsaved": ri["userUnsaved"].as_array().cloned().unwrap_or_default(),+ "_hint": "Poll fusion_readiness; ready flips true in ~60-90s, then retry this verb."}));+ }+ Some(json!({"success": false, "error": "Fusion 360 is running but the AdomBridge add-in is not responding - it was installed or updated while Fusion was already open, and Fusion only loads add-ins at launch.",+ "errorCode": "addin_restart_required", "hostResponding": true, "restartRequired": true, "userMessage": ri["userMessage"], "openDocuments": ri["openDocuments"], "unsavedDocuments": ri["unsavedDocuments"],+ "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.)"}))+}
rust/bridge/src/lifecycle.rsadded+281@@ -0,0 +1,281 @@+//! Stop, kill and restart-for-add-in, native. Ported from handlers/close_fusion.py and+//! server.py's `_handle_restart_for_addin`. Process operations go through ab's `process_list`+//! and `process_kill` (verb for OPERATIONS, the Toolhelp snapshot for POLLS), with a hidden+//! taskkill only as the degraded path; windows are closed through `desktop_close_window`.++use serde_json::{json, Value};+use std::time::{Duration, Instant};++use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};++pub const FUSION_PROCESS_NAMES: &[&str] = &["Fusion360.exe", "FusionLauncher.exe", "Fusion.exe", "FusionService.exe", "FusionCEF.exe", "Fusion360Helper.exe"];+const BRIDGE_MARKS: &[&str] = &["adom-test-", "adomdemosample", "adom-fusion-wrap", "ads8588sipm", "0603waf"];++fn addin_post(cmd: &str, args: Value, timeout: u64) -> Option<Value> {+ ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(timeout)).send_json(json!({"command": cmd, "args": args})).ok().and_then(|r| r.into_json().ok())+}++/// `_close_all_documents_via_addin`: close every open document without saving, so the next+/// launch does not raise Recovered Documents. Waits ~40s for a slow add-in, then gives up.+pub fn close_all_documents_via_addin() -> Value {+ let mut out = json!({"closed": [], "errors": [], "count": 0});+ let mut info = addin_post("document_info", json!({}), 5);+ if info.is_none() {+ for w in [3u64, 6, 10, 10, 10] { std::thread::sleep(Duration::from_secs(w)); info = addin_post("document_info", json!({}), 10); if info.is_some() { break; } }+ }+ let Some(info) = info else { return out };+ if info["success"].as_bool() != Some(true) { return out; }+ let docs = info["data"]["openDocuments"].as_array().cloned().unwrap_or_default();+ out["count"] = json!(docs.len());+ for d in docs.iter().rev() {+ let name = d["name"].as_str().unwrap_or("").to_string();+ if name.is_empty() { continue; }+ match addin_post("close_document", json!({"name": name, "save": false}), 10) {+ Some(r) if r["success"].as_bool() == Some(true) => out["closed"].as_array_mut().unwrap().push(json!(name)),+ Some(r) => out["errors"].as_array_mut().unwrap().push(json!(format!("{name}: {}", r["error"].as_str().unwrap_or("?")))),+ None => out["errors"].as_array_mut().unwrap().push(json!(format!("{name}: no answer"))),+ }+ }+ out+}++/// ab's process rows for the Fusion family, or None when ab is unreachable.+fn ab_process_rows(ctx: &Ctx) -> Option<Vec<Value>> {+ let ab = Loopback::from_env()?;+ let r = ab.call("process_list", json!({"name": "Fusion"}), &ctx.caller, 10).ok()?;+ r["processes"].as_array().cloned()+}++fn family_pids(ctx: &Ctx) -> Vec<u32> {+ if let Some(rows) = ab_process_rows(ctx) {+ return rows.iter().filter(|r| r["name"].as_str().map(|n| FUSION_PROCESS_NAMES.iter().any(|f| f.eq_ignore_ascii_case(n))).unwrap_or(false)).filter_map(|r| r["pid"].as_u64().map(|p| p as u32)).collect();+ }+ ctx.host.fusion_processes().iter().map(|p| p.pid).collect()+}++fn has_fusion_process(ctx: &Ctx) -> bool { !family_pids(ctx).is_empty() }++/// `_force_kill_fusion`: ab's process_kill per row, then the hidden taskkill sweep, then per pid /T.+fn force_kill_fusion(ctx: &Ctx) -> u32 {+ let mut killed = 0u32;+ if let (Some(ab), Some(rows)) = (Loopback::from_env(), ab_process_rows(ctx)) {+ for r in rows {+ let name = r["name"].as_str().unwrap_or("");+ if let Some(pid) = r["pid"].as_u64() {+ if FUSION_PROCESS_NAMES.iter().any(|f| f.eq_ignore_ascii_case(name)) && ab.call("process_kill", json!({"pid": pid, "tree": true, "reason": format!("close Fusion: force-kill surviving {name}")}), &ctx.caller, 15).is_ok() { killed += 1; }+ }+ }+ }+ #[cfg(windows)]+ {+ use std::os::windows::process::CommandExt;+ for name in FUSION_PROCESS_NAMES {+ if std::process::Command::new("taskkill").args(["/F", "/IM", name]).creation_flags(0x0800_0000).output().map(|o| o.status.success()).unwrap_or(false) { killed += 1; }+ }+ std::thread::sleep(Duration::from_secs(1));+ for pid in family_pids(ctx) {+ if std::process::Command::new("taskkill").args(["/F", "/T", "/PID", &pid.to_string()]).creation_flags(0x0800_0000).output().map(|o| o.status.success()).unwrap_or(false) { killed += 1; }+ }+ }+ killed+}++/// Visible windows whose title contains "Autodesk Fusion", as (hwnd, title).+fn fusion_windows(ctx: &Ctx) -> Vec<(i64, String)> {+ let Some(ab) = Loopback::from_env() else { return vec![] };+ let Ok(v) = ab.call("desktop_enum_windows", json!({}), &ctx.caller, 10) else { return vec![] };+ v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default().iter()+ .filter(|w| w["visible"].as_bool() != Some(false) && w["title"].as_str().map(|t| t.contains("Autodesk Fusion")).unwrap_or(false))+ .filter_map(|w| w["hwnd"].as_i64().map(|h| (h, w["title"].as_str().unwrap_or("").to_string()))).collect()+}++/// `_close_and_wait`: WM_CLOSE, then up to 15s for the window to go.+fn close_and_wait(ctx: &Ctx, hwnd: i64) -> bool {+ let Some(ab) = Loopback::from_env() else { return false };+ let _ = ab.call("desktop_close_window", json!({"hwnd": hwnd}), &ctx.caller, 15);+ let deadline = Instant::now() + Duration::from_secs(15);+ while Instant::now() < deadline {+ std::thread::sleep(Duration::from_millis(300));+ if !fusion_windows(ctx).iter().any(|(h, _)| *h == hwnd) { return true; }+ }+ false+}++fn survivors(ctx: &Ctx) -> Value {+ json!({"success": false, "error": "Some Fusion processes survived force-kill.",+ "_hint": "The bridge tried taskkill /F /IM for every Fusion process and then taskkill /F /T per surviving PID, and something still holds on. Do NOT hand this to the user as a Task Manager chore: check whether the survivor is a crash-report host (fusion_readiness reports fusionCrashed) or is pinned by a modal, and re-call fusion_kill after clearing that.",+ "survivingPids": family_pids(ctx)})+}++/// `fusion_stop`: documents closed cleanly, then WM_CLOSE on every Fusion window; never a kill.+pub fn stop(ctx: &Ctx, args: &Value) -> Value {+ let mut report = json!({"closed": [], "errors": [], "count": 0});+ if args["skipCleanClose"].as_bool() != Some(true) { report = close_all_documents_via_addin(); if !report["closed"].as_array().map(|a| a.is_empty()).unwrap_or(true) { std::thread::sleep(Duration::from_millis(500)); } }+ let windows = fusion_windows(ctx);+ if windows.is_empty() {+ if has_fusion_process(ctx) { return json!({"success": false, "error": "No Fusion windows found, but Fusion processes are still running.", "_hint": "A graceful stop cannot reach them - use fusion_kill to force-kill."}); }+ return json!({"success": true, "output": "Fusion 360 is not running."});+ }+ let (mut closed, mut failed) = (Vec::new(), Vec::new());+ for (h, t) in &windows { if close_and_wait(ctx, *h) { closed.push(t.clone()); } else { failed.push(t.clone()); } }+ if !failed.is_empty() {+ let bridge_only = failed.iter().all(|t| { let l = t.to_lowercase(); BRIDGE_MARKS.iter().any(|m| l.contains(m)) });+ return json!({"success": false, "error": format!("Graceful stop failed for: {}", failed.join(", ")), "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) }, "bridgeDocsOnly": bridge_only,+ "_hint": if bridge_only { "The unsaved document(s) blocking the close are BRIDGE-created throwaways (sample/test docs) - fusion_kill is SAFE here, nothing of the user's is at risk. Kill, then retry." } else { "A window would not close gracefully (a modal dialog often blocks WM_CLOSE). Use fusion_kill to force-kill, then retry." }});+ }+ for _ in 0..10 { if !has_fusion_process(ctx) { break; } std::thread::sleep(Duration::from_millis(500)); }+ let n = closed.len(); let dc = report["closed"].as_array().map(|a| a.len()).unwrap_or(0);+ json!({"success": true, "output": format!("Fusion 360 stopped gracefully ({n} window{}); {dc} document(s) closed cleanly first.", if n != 1 { "s" } else { "" }), "data": {"documentsClosed": report["closed"]}})+}++/// `fusion_kill`: force-kill (documents closed cleanly first when the add-in is reachable).+pub fn kill(ctx: &Ctx, args: &Value) -> Value {+ let mut report = json!({"closed": [], "errors": [], "count": 0});+ if args["skipCleanClose"].as_bool() != Some(true) { report = close_all_documents_via_addin(); if !report["closed"].as_array().map(|a| a.is_empty()).unwrap_or(true) { std::thread::sleep(Duration::from_millis(500)); } }+ let killed = force_kill_fusion(ctx);+ std::thread::sleep(Duration::from_secs(1));+ if has_fusion_process(ctx) { return survivors(ctx); }+ let dc = report["closed"].as_array().map(|a| a.len()).unwrap_or(0);+ json!({"success": true, "output": format!("Fusion 360 force-killed ({killed} process group{}); {dc} document(s) closed cleanly first.", if killed != 1 { "s" } else { "" }), "data": {"documentsClosed": report["closed"]}})+}++/// `fusion_restart_for_addin`: save, stop (or kill when only bridge scratch is unsaved), start, wait.+pub fn restart_for_addin(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let sd = ctx.state_dir.clone();+ if args["decline"].as_bool() == Some(true) {+ let nid = args["notificationId"].as_str().unwrap_or("page");+ let _ = std::fs::create_dir_all(&sd);+ let _ = std::fs::write(sd.join("user_restart_hold.json"), json!({"at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0), "notification": nid}).to_string());+ crate::fusion_prefs::action_ledger(&sd, "restart_choice", json!({"choice": "declined-from-page"}));+ return json!({"success": true, "declined": true, "hold": true, "_hint": "Recorded: the user owns the restart. Any chain waiting on the notice stops with user_owns_restart; nothing restarts Fusion."});+ }+ if args["wait"].as_bool() != Some(true) && args["_sync"].as_bool() != Some(true) {+ // the background form: the sidecar's own handler carries it (a thread that outlives this call)+ let url = crate::launch::SELF_URL.lock().unwrap().clone().or_else(|| crate::launch::SIDECAR_URL.lock().unwrap().clone());+ let a = args.clone();+ std::thread::spawn(move || {+ if let Some(u) = url {+ let mut a2 = a; a2["wait"] = json!(true);+ let r = ureq::post(&u).timeout(Duration::from_secs(900)).send_json(json!({"command": "fusion_restart_for_addin", "args": a2})).ok().and_then(|r| r.into_json::<Value>().ok()).unwrap_or(json!({}));+ eprintln!("[fusion-bridge] async restart_for_addin finished: {}", json!({"success": r["success"], "errorCode": r["errorCode"], "error": r["error"]}));+ }+ });+ return json!({"success": true, "started": true, "async": true, "statusVerb": "fusion_readiness",+ "_hint": "Restart under way in the background (a Fusion restart takes 60-120s, longer than the relay budget, so this returns immediately). Poll fusion_readiness until ready:true and addinVersion matches; the outcome is also stored in the lastRestartResult preference. A timeout on this verb no longer exists and never meant failure anyway."});+ }+ let t0 = Instant::now();+ let save = args["save"].as_bool().unwrap_or(true);+ let mut steps: Vec<Value> = Vec::new();+ let mut hard = false;+ if save {+ let sv = pass("close_all_documents", json!({"save": true}));+ steps.push(json!({"step": "save", "ok": sv["success"].as_bool().unwrap_or(false), "closed": sv["data"]["closed"], "wouldPrompt": sv["data"]["wouldPrompt"]}));+ let wp: Vec<String> = sv["data"]["wouldPrompt"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+ if !wp.is_empty() {+ let users: Vec<&String> = wp.iter().filter(|n| { let bare = n.replace('*', "").split(" (").next().unwrap_or("").trim().to_lowercase(); !bare.is_empty() && crate::etiquette::bridge_doc_in(&sd, &bare).is_none() && !bare.starts_with("untitled") && !bare.ends_with("_3d") }).collect();+ if users.is_empty() { steps.push(json!({"step": "discard-bridge-scratch", "docs": wp})); hard = true; }+ else {+ return json!({"success": false, "errorCode": "unsaved_named_document", "error": format!("Cannot restart safely: {} has never been saved, so Fusion would raise its Save box and block the restart.", users.iter().map(|n| fusion_lbr::py_repr_str(n)).collect::<Vec<_>>().join(", ")), "steps": steps,+ "_hint": "Ask the user where to save it (fusion_save_to_cloud {name, projectName}), then retry. Nothing was closed and no dialog was raised."});+ }+ }+ if sv["success"].as_bool() != Some(true) && sv["errorCode"].is_string() && sv["error"].as_str().map(|e| e.contains("Add-in request failed")).unwrap_or(false) {+ return json!({"success": false, "errorCode": "save_failed", "steps": steps, "error": format!("Refusing to restart because saving failed: {}", sv["error"].as_str().unwrap_or("").chars().take(200).collect::<String>()), "_hint": "Nothing was restarted; the user's documents are untouched."});+ }+ }+ if hard || !save { let st = kill(ctx, &json!({"reason": "restart for add-in; only bridge scratch unsaved"})); steps.push(json!({"step": "kill", "ok": st["success"].as_bool().unwrap_or(true)})); }+ else { let st = stop(ctx, &json!({})); steps.push(json!({"step": "stop", "ok": st["success"].as_bool().unwrap_or(true)})); }+ std::thread::sleep(Duration::from_secs(3));+ let lr = crate::launch::start(ctx, &json!({"waitForAddin": true}));+ steps.push(json!({"step": "start", "ok": lr["success"].as_bool().unwrap_or(false)}));+ let ready = crate::launch::round_trip_ready_gap(6, 5.0);+ json!({"success": ready, "addinReady": ready, "tookSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "steps": steps, "errorCode": if ready { Value::Null } else { json!("addin_still_not_responding") },+ "_hint": if ready { "Fusion restarted and the add-in answered - re-run the verb that failed." } else { "Fusion restarted but the add-in still is not answering. That is a real defect, not a user problem: check fusion_addin_status and file it." }})+}++/// `_diagnose_addin_timeout`: after a command timed out, say WHY (busy, blocked by a modal,+/// hung host, or a dead add-in), with screenshots of what is up.+/// `_post_open_screenshot`: settle, then the main window and every dialog, plus the blocked flag.+pub fn post_open_screenshot(ctx: &Ctx, settle_secs: f64) -> (Value, bool) {+ std::thread::sleep(Duration::from_secs_f64(settle_secs));+ let blocked = !crate::launch::round_trip_ready(1);+ let mut shots = json!({"screenshots": []});+ let main = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+ if main["success"].as_bool() == Some(true) { shots["screenshots"].as_array_mut().unwrap().push(json!({"type": "main_window", "savedTo": main["savedTo"], "sizeKB": main["sizeKB"]})); }+ else { shots["error"] = json!(main["error"].as_str().unwrap_or("Screenshot failed")); }+ if let Some(ab) = Loopback::from_env() {+ let rows = ab.call("desktop_enum_windows", json!({"pids": ctx.pids}), &ctx.caller, 10).ok().map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default()).unwrap_or_default();+ for d in fusion_core::dialogs::qt_dialog_rows(&rows) {+ let (w, h) = (d["rect"]["width"].as_i64().unwrap_or(0), d["rect"]["height"].as_i64().unwrap_or(0));+ if w < 50 || h < 50 { continue; }+ if let Some(hw) = d["hwnd"].as_i64() {+ if let Ok(r) = ab.call("desktop_screenshot_window", json!({"hwnd": hw}), &ctx.caller, 40) {+ if let Some(p) = r["fullPath"].as_str().or(r["safePath"].as_str()) { shots["screenshots"].as_array_mut().unwrap().push(json!({"type": "dialog", "title": d["title"], "hwnd": hw, "savedTo": p.replace('\\', "/"), "sizeKB": (r["fullBytesHost"].as_f64().unwrap_or(0.0) / 1024.0 * 10.0).round() / 10.0})); }+ }+ }+ }+ }+ let n = shots["screenshots"].as_array().map(|a| a.len()).unwrap_or(0);+ shots["message"] = json!(format!("Auto-captured {n} Fusion window(s) after open. READ EACH screenshot file to check for blocking dialogs ('What to design?', 'PCB out of date', 'Save changes?'). PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE electronics DESIGN. Open the DESIGN document first (fusion_open_cloud_file or fusion_open_by_urn), THEN switch to its schematic and its 2D board (fusion_show_2d_board) and 3D board (fusion_show_3d_board). A Select-Electronics-Design-File picker IS that design list of its schematic + board. An EMPTY 2D board usually means you opened a derivative or the wrong file, or it is out of sync - reopen the parent electronics DESIGN and switch to its board view; do NOT open a .brd or .sch as a standalone file. Dismiss with fusion_send_key {{\"key\": \"escape\"}} if needed."));+ if blocked { shots["dialogBlocking"] = json!(true); shots["message"] = json!(format!("WARNING: A modal dialog is blocking Fusion. {}", shots["message"].as_str().unwrap_or(""))); }+ (shots, blocked)+}++pub fn diagnose_addin_timeout(ctx: &Ctx, command: &str, original: &Value) -> Value {+ let _ = original;+ let (shots, _blocked) = post_open_screenshot(ctx, 2.0);+ let mut blocking: Vec<Value> = Vec::new();+ if let Some(ab) = Loopback::from_env() {+ blocking = crate::small_verbs::classify_blocking_dialogs(&ab, &ctx.caller, &ctx.pids);+ // `_license_classify_extend`: the seat modal by ownership and shape when the title is bare+ if !blocking.iter().any(|d| d["category"].as_str() == Some("license_sessions_exceeded")) {+ if let Some(dlg) = fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller).licensing_dialog {+ let auto = crate::small_verbs::get_pref(&ctx.state_dir, "autoTakeOverSeat").map(|v| v != json!(false)).unwrap_or(true);+ blocking.push(json!({"category": "license_sessions_exceeded", "title": dlg["title"], "hwnd": dlg["hwnd"],+ "resolution": format!("Autodesk seat modal: this account's Fusion is active on another machine. {}", if auto { "Auto-takeover is ON - the bridge clicks Continue through both dialogs in the background (~20s sweep; a fusion_start resolves it immediately). The other machine's Fusion suspends RESUMABLY." } else { "Auto-takeover is OPTED OUT (fusion_set_seat_takeover) - resolve the dialog yourself or re-enable takeover." })}));+ }+ }+ }+ let health: Option<Value> = ureq::get("http://127.0.0.1:8774/health").timeout(Duration::from_secs(2)).call().ok().and_then(|r| r.into_json().ok());+ if let Some(h) = &health {+ if h["status"].as_str() == Some("ok") {+ let main_thread = h["main_thread"].as_str().unwrap_or("unknown").to_string();+ let pending = h["pending_commands"].as_i64().unwrap_or(0);+ if main_thread == "blocked" || pending > 0 {+ if let Some(st) = crate::addin::addin_status_probe(Duration::from_secs(3)).filter(|s| s["busy"].as_bool() == Some(true)) {+ let mut resp = json!({"success": false, "error": format!("Fusion main thread busy — {} running for {}s.", st["busyCommand"].as_str().unwrap_or("unknown"), st["elapsedSeconds"]), "errorCode": "main_thread_busy", "busyCommand": st["busyCommand"], "elapsedSeconds": st["elapsedSeconds"],+ "_hint": "Add-in is busy with a long-running command. Do NOT retry add-in commands. Do NOT press Escape — the add-in is working, not stuck on a dialog. Commands that still work: fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, fusion_send_key, fusion_close_window."});+ if !st["walkProgress"].is_null() { resp["progress"] = st["walkProgress"].clone(); }+ return resp;+ }+ let message = if !blocking.is_empty() {+ let titles = blocking.iter().map(|d| format!("'{}' ({})", d["title"].as_str().unwrap_or(""), d["category"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", ");+ let mut seen = Vec::new(); for d in &blocking { let r = d["resolution"].as_str().unwrap_or("").to_string(); if !seen.contains(&r) { seen.push(r); } }+ format!("The AdomBridge add-in is alive but its main thread is blocked by a modal dialog: {titles}. This is NOT an add-in crash — do not restart Fusion. {}", seen.join(" "))+ } else {+ format!("The AdomBridge add-in is alive but its main thread is not responding (status: {main_thread}, pending: {pending}). Fusion 360 likely has a modal dialog open (Document Recovery, error, or update prompt) that is blocking execution. READ the screenshots to identify the dialog, then dismiss with fusion_send_key {{\"key\": \"escape\"}} or fusion_send_key {{\"key\": \"tab\"}} + {{\"key\": \"enter\"}}.")+ };+ return json!({"success": false, "error": "addin_main_thread_blocked", "message": message, "blockingDialogs": blocking, "data": {"command": command, "health": h, "blockingDialogs": blocking, "postOpenScreenshot": shots}});+ }+ return json!({"success": false, "error": "addin_command_timeout", "message": format!("Command '{command}' timed out but the add-in reports main thread is {main_thread}. The command may be long-running or stuck."), "data": {"command": command, "health": h, "postOpenScreenshot": shots}});+ }+ }+ if let Some(st) = crate::addin::addin_status_probe(Duration::from_secs(3)).filter(|s| s["busy"].as_bool() == Some(true)) {+ return json!({"success": false, "error": "The add-in is BUSY running a long command, not dead - your command is queued behind it.", "errorCode": "fusion_addin_busy", "busyCommand": st["busyCommand"], "elapsedSeconds": st["elapsedSeconds"], "hostResponding": true,+ "_hint": "Do NOT restart Fusion and do not treat this as a crash. Fusion runs one command at a time on its main thread, and some surfaces genuinely take minutes (a staged first-open has measured 546s here). WAIT and re-poll - fusion_state answers while the add-in is busy and reports mainThreadBusy with the running command. Only treat it as broken if elapsedSeconds keeps climbing past what fusion_state.timings says this machine has ever taken."});+ }+ let hung = crate::readiness_exec::confirmed_hung(ctx);+ if matches!(hung["phase"].as_str(), Some("suspect") | Some("busy-not-hung")) {+ return json!({"success": false, "error": "Fusion's main thread is not answering right now - most likely WORKING on something heavy, not hung (the probes it starves are the very ones this gate reads).", "errorCode": "fusion_addin_busy", "hostResponding": hung["phase"].as_str() == Some("busy-not-hung"), "hungHeldSeconds": hung["heldSeconds"],+ "_hint": "WAIT and re-poll. A real hang is only declared after 180s of continuous silence including a 20s probe, and then the bridge restarts Fusion itself."});+ }+ if hung["hung"].as_bool() == Some(true) {+ let _ = kill(ctx, &json!({})); std::thread::sleep(Duration::from_secs(6)); let _ = crate::launch::start(ctx, &json!({}));+ let ghost = hung["signals"].as_array().map(|a| a.iter().any(|s| s.as_str() == Some("GhostWindow"))).unwrap_or(false);+ return json!({"success": false, "error": "Fusion 360 itself is NOT RESPONDING (the host process stopped pumping its message queue), so the add-in cannot answer.", "errorCode": "fusion_host_hung", "hungSignals": hung["signals"], "ghostWindow": ghost, "restartAttempted": true,+ "_hint": "This is NOT a blocking dialog and NOT an add-in crash - do not go looking for a modal, there is not one. Nothing reaches Fusion over IPC while it is hung; only a restart clears it. The bridge already force-restarted it (kill + start) - poll fusion_readiness until ready:true."});+ }+ json!({"success": false, "error": "AdomBridge add-in not responding (it may have crashed).", "errorCode": "fusion_addin_not_responding", "hostResponding": true,+ "_hint": "Fix it YOURSELF - never ask the user: restart Fusion via fusion_stop + fusion_start (the bridge installs the add-in to ALL Fusion add-in dirs incl. %APPDATA%\\Autodesk\\FusionAddins, and runOnStartup reloads it). The host process IS responding - this is the add-in only, so no dialog is implied."})+}
rust/bridge/src/livefeed.rsadded+85@@ -0,0 +1,85 @@+//! `fusion_live_feed`: a ~1 fps look INTO Fusion as a box-side ring buffer (main window plus every+//! owned popup per tick, tagged), drained by a container-side presenter. Native, over ab's+//! desktop_screenshot_window; the health fields tell "live" from "spinning".++use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::Duration;++use fusion_core::ab::{AbClient, Caller, Loopback};++const MAX_FRAMES: usize = 300;++#[derive(Default)]+struct Feed { running: bool, stop: bool, tick: i64, frames: Vec<Value>, started_at: f64, captured: i64, no_hwnd: i64, last_error: Option<String>, last_error_at: Option<f64>, last_capture_at: Option<f64>, last_shot_keys: Option<Vec<String>> }+static FEED: Mutex<Feed> = Mutex::new(Feed { running: false, stop: false, tick: 0, frames: Vec::new(), started_at: 0.0, captured: 0, no_hwnd: 0, last_error: None, last_error_at: None, last_capture_at: None, last_shot_keys: None });++fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++fn health(mut out: Value) -> Value {+ let f = FEED.lock().unwrap();+ out["captured"] = json!(f.captured); out["noHwnd"] = json!(f.no_hwnd); out["lastShotKeys"] = json!(f.last_shot_keys); out["lastShotDataKeys"] = Value::Null;+ out["lastError"] = json!(f.last_error); out["lastErrorAt"] = json!(f.last_error_at); out["lastCaptureAt"] = json!(f.last_capture_at);+ if f.tick >= 5 && f.captured == 0 { out["healthy"] = json!(false); out["_honesty"] = json!(format!("{} capture attempts, ZERO frames. This feed is spinning, not live. lastError={} noHwnd={}. Do not present it as a video.", f.tick, f.last_error.as_deref().unwrap_or("None"), f.no_hwnd)); }+ else if let (Some(t), Some(true)) = (f.last_capture_at, out["running"].as_bool()) { if now_f() - t > 15.0 { out["healthy"] = json!(false); out["_honesty"] = json!(format!("no frame for {:.0}s although running. lastError={}", now_f() - t, f.last_error.as_deref().unwrap_or("None"))); } else { out["healthy"] = json!(f.captured > 0); } }+ else { out["healthy"] = json!(f.captured > 0); }+ out+}++pub fn live_feed(caller: &Caller, pids: Vec<u32>, args: &Value) -> Value {+ let action = args["action"].as_str().unwrap_or("status").to_lowercase();+ let (alive, tick, buffered, started) = { let f = FEED.lock().unwrap(); (f.running, f.tick, f.frames.len(), f.started_at) };+ match action.as_str() {+ "status" => return health(json!({"success": true, "running": alive, "tick": tick, "buffered": buffered, "startedAt": if started > 0.0 { json!(started) } else { Value::Null }})),+ "frames" => {+ let since = args["since"].as_i64().unwrap_or(0);+ let fr: Vec<Value> = FEED.lock().unwrap().frames.iter().filter(|f| f["tick"].as_i64().unwrap_or(0) > since).cloned().collect();+ let n = fr.len();+ return health(json!({"success": true, "running": alive, "tick": tick, "frames": fr[n.saturating_sub(200)..].to_vec(), "count": n, "_hint": "Each frame is a BOX path (this machine); read_file it from the container and publish it. kind is main|popup; proof frames come from fusion_prove_result evidence[] with kind:'proof'. Pass since:<tick> to get only what you have not seen."}));+ }+ "stop" => { FEED.lock().unwrap().stop = true; return json!({"success": true, "stopped": true, "tick": tick}); }+ _ => {}+ }+ if alive { return json!({"success": true, "alreadyRunning": true, "tick": tick}); }+ let interval = args["intervalSec"].as_f64().unwrap_or(1.0).max(0.5);+ let max_sec = args["maxSec"].as_f64().unwrap_or(600.0).min(3600.0);+ { let mut f = FEED.lock().unwrap(); *f = Feed { running: true, started_at: now_f(), ..Default::default() }; }+ let caller = caller.clone();+ std::thread::spawn(move || {+ let t0 = now_f();+ let ab = Loopback::from_env();+ loop {+ { let f = FEED.lock().unwrap(); if f.stop || now_f() - t0 >= max_sec { break; } }+ let tick = { let mut f = FEED.lock().unwrap(); f.tick += 1; f.tick };+ let Some(ab) = ab.as_ref() else { let mut f = FEED.lock().unwrap(); f.last_error = Some("ab loopback unavailable".into()); f.last_error_at = Some(now_f()); std::thread::sleep(Duration::from_secs_f64(interval)); continue; };+ let facts = fusion_core::windows_facts::facts_from_ab(ab, &caller);+ let _ = &pids;+ let Some(hwnd) = facts.main_hwnd else { FEED.lock().unwrap().no_hwnd += 1; std::thread::sleep(Duration::from_secs_f64(interval)); continue; };+ let title = facts.main_title.clone().unwrap_or_else(|| "Fusion".into()).chars().take(60).collect::<String>();+ let rect = facts.family.iter().find(|w| w["hwnd"].as_i64() == Some(hwnd)).map(|w| fusion_core::dialogs::rect_of(&w["rect"])).unwrap_or(json!({}));+ match ab.call("desktop_screenshot_window", json!({"hwnd": hwnd}), &caller, 20) {+ Ok(shot) => {+ let now = now_f();+ let mut f = FEED.lock().unwrap();+ f.last_shot_keys = Some(shot.as_object().map(|o| { let mut k: Vec<String> = o.keys().cloned().collect(); k.sort(); k.into_iter().take(12).collect() }).unwrap_or_default());+ let main = shot["fullPath"].as_str().or(shot["fullPathHost"].as_str()).or(shot["path"].as_str()).map(|s| s.replace('\\', "/"));+ let popups = shot["screenshots"].as_array().cloned().unwrap_or_default();+ match main {+ Some(p) => { f.captured += 1; f.last_capture_at = Some(now); f.frames.push(json!({"tick": tick, "kind": "main", "at": now, "path": p, "title": title, "rect": rect, "popups": popups.len()})); }+ None => { f.last_error = Some(format!("screenshot returned no path: {}", shot["error"].as_str().or(shot["errorCode"].as_str()).unwrap_or("?")).chars().take(200).collect()); f.last_error_at = Some(now); }+ }+ for (k, sp) in popups.iter().enumerate() {+ if let Some(pth) = sp["fullPathHost"].as_str().or(sp["fullPath"].as_str()).or(sp["path"].as_str()) {+ f.frames.push(json!({"tick": tick, "kind": "popup", "at": now, "index": k + 1, "path": pth.replace('\\', "/"), "rect": if sp["rect"].is_null() { sp["bounds"].clone() } else { sp["rect"].clone() }, "title": sp["title"].as_str().unwrap_or("dialog").chars().take(50).collect::<String>()}));+ }+ }+ if f.frames.len() > MAX_FRAMES { let cut = f.frames.len() - MAX_FRAMES; f.frames.drain(..cut); }+ }+ Err(e) => { let mut f = FEED.lock().unwrap(); f.last_error = Some(format!("ab: {e}").chars().take(200).collect()); f.last_error_at = Some(now_f()); }+ }+ std::thread::sleep(Duration::from_secs_f64(interval));+ }+ FEED.lock().unwrap().running = false;+ });+ json!({"success": true, "started": true, "intervalSec": interval, "maxSec": max_sec, "_hint": "Capturing into a box-side ring buffer. Drain it from the container with fusion_live_feed {action:'frames', since:<lastTick>}, read_file each path, and publish (e.g. adom-shotlog inject -c fusion-live -d '[main #n] ...'). tools/live_feed_drain.py does exactly that."})+}
rust/bridge/src/main.rs+193−11@@ -12,6 +12,31 @@ //! tooltip, bridgeVersion, ...}: ab paints the chip from it verbatim //! - /command body {command, args}; the response is the verb's JSON as-is +mod chains;+mod small_verbs;+mod addin;+mod fusion_prefs;+mod timings;+mod etiquette;+mod launch;+mod lifecycle;+mod readiness_exec;+mod orchestrations;+mod mint;+mod prove;+mod services;+mod maintenance;+mod testproject;+mod livefeed;+mod chainexec;+mod epg;+mod mcp;+mod managed;+mod signin;+mod aps_signin;+mod demo;+mod addin_install;+mod janitor; use fusion_core::ab::Caller; use fusion_core::{HostPaths, HostProcess}; use serde_json::{json, Value};@@ -30,6 +55,21 @@ fn host() -> Box<dyn HostBoth> { Box::new(fusion_os_mac::MacHost) } fn host() -> Box<dyn HostBoth> { Box::new(NoHost) } trait HostBoth: HostProcess + HostPaths {}+/// A fresh host for code paths that cannot borrow the request's (the timeout diagnosis).+fn host_for_diagnosis() -> Box<dyn HostProcess> { let h: Box<dyn HostBoth> = host(); Box::new(HostShim(h)) }+fn host_for_diagnosis_paths() -> Option<std::path::PathBuf> { host().fusion_executable() }+struct HostShim(Box<dyn HostBoth>);+impl HostProcess for HostShim {+ fn fusion_processes(&self) -> Vec<fusion_core::host::FusionProcess> { self.0.fusion_processes() }+ fn launch_fusion(&self) -> Result<fusion_core::host::FusionProcess, fusion_core::host::HostError> { self.0.launch_fusion() }+ fn is_elevated(&self) -> bool { self.0.is_elevated() }+ fn image_running(&self, names: &[&str]) -> bool { self.0.image_running(names) }+ fn tie_child_lifetime(&self, pid: u32) -> bool { self.0.tie_child_lifetime(pid) }+ fn background_envelope(&self, seconds: f64, minimize: bool) { self.0.background_envelope(seconds, minimize) }+ fn user_watching(&self) -> bool { self.0.user_watching() }+ fn window_hung(&self, hwnd: i64) -> bool { self.0.window_hung(hwnd) }+ fn family_cpu_ticks(&self) -> u64 { self.0.family_cpu_ticks() }+} impl<T: HostProcess + HostPaths> HostBoth for T {} /// Linux is the CI/build host, never a Fusion host: a stub so the workspace builds here.@@ -196,8 +236,9 @@ fn native_status(h: &dyn HostBoth) -> Value { } else { ("green", "Fusion ready", "Fusion 360 running, AdomBridge add-in connected. All fusion_* verbs available.") };+ let long_op = LONG_OP.lock().unwrap().clone().map(|n| json!(n)).or_else(addin::long_command_active); let body = json!({- "status": "ok", "busy": false, "longOp": Value::Null,+ "status": "ok", "busy": long_op.is_some(), "longOp": long_op, "led": led, "summary": summary, "tooltip": tooltip, "bridgeVersion": VERSION, "fusion": {"installed": installed, "exe_path": exe.map(|p| p.to_string_lossy().to_string()), "addins_dir": addins_dir.map(|p| p.to_string_lossy().to_string()),@@ -254,7 +295,7 @@ fn main() { let bind = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".into()); 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())));+ let sidecar = Arc::new(Mutex::new(if sidecar_wanted(&**h) { Sidecar::spawn(&bind, &h.bridge_state_dir()) } else { eprintln!("[fusion-bridge] sidecar not spawned: every surface is owned by the exe (set an owner pref to \"sidecar\" or ADOM_FUSION_SIDECAR=1 to bring it back)"); None })); // 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. if let Some(sc) = sidecar.lock().unwrap().as_ref() {@@ -263,6 +304,15 @@ fn main() { } } let server = Server::http(format!("{bind}:{port}")).expect("bind loopback");+ *launch::SELF_URL.lock().unwrap() = Some(format!("http://127.0.0.1:{port}/command"));+ addin_install::startup(&h.bridge_state_dir(), &h.addin_dirs(), h.fusion_executable().is_some());+ {+ let (sc1, sc2) = (sidecar.clone(), sidecar.clone());+ let ready: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(move || owned_readiness(&*host(), &sc1, &Caller::default())["ready"].as_bool() == Some(true));+ let aps_signed: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(move || native_aps_status(&sc2, &Caller::default())["data"]["signedIn"].as_bool() == Some(true));+ signin::start_janitor(ready, aps_signed, Caller::default());+ janitor::start();+ } eprintln!("[fusion-bridge] v{VERSION} listening on {bind}:{port}; contract frozen {}; sidecar {}", catalog.frozen, sidecar.lock().unwrap().as_ref().map(|s| format!("python on :{}", s.port)).unwrap_or("absent".into()));@@ -319,6 +369,73 @@ fn main() { // 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_create_library" | "create_library" if chains_owner_is_exe(&**h) => chains::create_library(Arc::new(ExeChainHost { sidecar: sidecar.clone(), caller: caller.clone(), state_dir: h.bridge_state_dir() }), parsed["args"].clone()),+ "fusion_install_library_bundle" | "install_library_bundle" if chains_owner_is_exe(&**h) => chains::install_library_bundle(Arc::new(ExeChainHost { sidecar: sidecar.clone(), caller: caller.clone(), state_dir: h.bridge_state_dir() }), parsed["args"].clone()),+ "fusion_prove_result" | "prove_result" if chains_owner_is_exe(&**h) => { let mut r = chains::prove_result(&parsed["args"]); r["shell"] = json!("rust"); r },+ v if addin_owner_is_exe(&**h) && addin::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {+ let cmd = v.strip_prefix("fusion_").unwrap_or(v).to_string();+ let (sc, ca) = (sidecar.clone(), caller.clone());+ let sd = h.bridge_state_dir();+ let t0 = Instant::now();+ let ind = timings::Indicator::start(&sd, &cmd, &caller);+ let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect();+ let side = |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t);+ let dlg = |res: Value| small_verbs::merge_dialog_array(res, &ca, &pids);+ let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg };+ let mut r = addin::pass_through(&cmd, &parsed["args"], &ctx);+ ind.stop(r["success"].as_bool() == Some(false));+ timings::log_run(&sd, &cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false));+ r["shell"] = json!("rust"); r },+ "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r },+ v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),+ "signin" => { let readiness = || owned_readiness(&**h, &sidecar, &caller); let start = || launch::start(&ctx, &json!({})); let aps_state = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); signin::signin(&ctx, &parsed["args"], &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_state, aps_signin: &aps_signin }) },+ "signin_2fa" => signin::signin_2fa(&ctx, &parsed["args"]), "aps_signin" => aps_signin::signin(&ctx, &parsed["args"]),+ "demo_progress" => demo::demo_progress(&ctx.state_dir, &parsed["args"]), "demo_control" => demo::demo_control(&parsed["args"]),+ "demo" => if parsed["args"]["mode"].as_str() == Some("tour") { let (sc1, sc2) = (sidecar.clone(), sidecar.clone()); let readiness: Arc<dyn Fn() -> Value + Send + Sync> = Arc::new(move || owned_readiness(&*host(), &sc1, &Caller::default())); let aps_state: Arc<dyn Fn() -> (bool, bool) + Send + Sync> = Arc::new(move || { let st = native_aps_status(&sc2, &Caller::default()); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }); demo::demo_tour(&ctx, &parsed["args"], demo::TourEnv { readiness, aps_state, version: VERSION }, &pass) }+ else { let readiness = || owned_readiness(&**h, &sidecar, &caller); let aps_state = || native_aps_status(&sidecar, &caller)["data"].clone(); let aps_search = |a: &Value| native_aps_search(a); let start = || launch::start(&ctx, &json!({})); let aps_pair = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); let signin_fn = |a: &Value| signin::signin(&ctx, a, &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_pair, aps_signin: &aps_signin }); demo::demo(&ctx, &parsed["args"], &demo::DemoEnv { readiness: &readiness, signin: &signin_fn, aps_state: &aps_state, aps_search: &aps_search }, &pass) }, "web_signin" => signin::web_signin(&ctx, &parsed["args"]),+ "library_manager" => managed::library_manager(&ctx, &parsed["args"], &pass), "managed_library_create" => managed::create(&ctx, &parsed["args"], &pass), "managed_library_link" => managed::link(&ctx, &parsed["args"], &pass), "managed_library_pull" => managed::pull(&ctx, &parsed["args"], &pass), "managed_library_push" => managed::push(&ctx, &parsed["args"], &pass), "managed_library_unlink" => managed::unlink(&ctx, &parsed["args"], &pass),+ "mcp_status" | "mcp_tools" | "mcp_call" | "mcp_resources" | "mcp_enable" => { let st = native_aps_status(&sidecar, &caller); let aps = (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)); match cmd { "mcp_status" => mcp::status(&ctx, VERSION, aps), "mcp_tools" => mcp::tools(VERSION, aps), "mcp_call" => mcp::call(VERSION, &parsed["args"], aps), "mcp_resources" => mcp::resources(VERSION, &parsed["args"], aps), _ => mcp::enable(&ctx, VERSION, aps, &pass) } }, _ => maintenance::uninstall(&ctx, &h.addin_dirs(), &parsed["args"]) }; r["shell"] = json!("rust"); r },+ "fusion_live_feed" | "live_feed" if addin_owner_is_exe(&**h) => { let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let mut r = livefeed::live_feed(&caller, pids, &parsed["args"]); r["shell"] = json!("rust"); r },+ "fusion_check_3d_thumbnail" | "check_3d_thumbnail" if addin_owner_is_exe(&**h) => { let mut r = prove::check_3d_thumbnail(&parsed["args"]); r["shell"] = json!("rust"); r },+ "fusion_resolve_seat_dialog" | "resolve_seat_dialog" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = readiness_exec::resolve_seat_dialog_verb(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r },+ v @ ("fusion_stop" | "stop" | "fusion_kill" | "kill" | "fusion_restart_for_addin" | "restart_for_addin") if addin_owner_is_exe(&**h) => {+ let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t);+ let sd = h.bridge_state_dir(); let dlg = |r: Value| r;+ let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg };+ let cmd = v.strip_prefix("fusion_").unwrap_or(v);+ let t0 = Instant::now();+ let mut r = match cmd { "stop" => lifecycle::stop(&ctx, &parsed["args"]), "kill" => lifecycle::kill(&ctx, &parsed["args"]), _ => lifecycle::restart_for_addin(&ctx, &parsed["args"], &|verb, a| addin::pass_through(verb, &a, &ctx)) };+ timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false));+ r["shell"] = json!("rust"); r },+ "fusion_get_preferences" | "get_preferences" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = fusion_prefs::get_preferences(&|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },+ "fusion_set_preference" | "set_preference" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = fusion_prefs::set_preference(&parsed["args"], &|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },+ "fusion_reset_workspace" | "reset_workspace" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = fusion_prefs::reset_workspace(&sd, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx), &|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },+ "fusion_describe" | "describe" => {+ let c: Value = serde_json::from_str(fusion_core::contract::FROZEN).unwrap_or(json!({}));+ let verbs = c["verbs"].clone(); let n = verbs.as_array().map(|a| a.len()).unwrap_or(0);+ json!({"success": true, "bridge": "fusion360", "verbCount": n, "verbs": verbs, "data": {"bridge": "fusion360", "verbCount": n, "verbs": c["verbs"]}, "shell": "rust"}) },+ "fusion_close_window" | "close_window" if addin_owner_is_exe(&**h) => { let mut r = small_verbs::close_window(&parsed["args"], &caller); r["shell"] = json!("rust"); r },+ "fusion_screenshot_fusion" | "screenshot_fusion" if addin_owner_is_exe(&**h) => { let mut r = small_verbs::screenshot_fusion(&parsed["args"], &caller); r["shell"] = json!("rust"); r },+ "fusion_dismiss_blocking_dialogs" | "dismiss_blocking_dialogs" if addin_owner_is_exe(&**h) => {+ let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect();+ let (sc, ca) = (sidecar.clone(), caller.clone());+ let mut r = small_verbs::dismiss_blocking_dialogs(&h.bridge_state_dir(), &parsed["args"], &caller, &pids, &|pct| timings::taskbar_pct(&ca, pct)); let _ = ≻+ r["shell"] = json!("rust"); r },+ "fusion_notify_owner" | "notify_owner" => { let mut r = small_verbs::notify_owner(&h.bridge_state_dir(), &parsed["args"], &caller); r["shell"] = json!("rust"); r },+ "fusion_cancel_run" | "cancel_run" if chains_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let mut r = small_verbs::cancel_run(&parsed["args"], &|b| { let _ = addin::addin_http("activity_banner", &b, 20); }); let _ = (&sc, &ca); r["shell"] = json!("rust"); r },+ "fusion_set_auto_update" | "set_auto_update" => { let mut r = small_verbs::set_auto_update(&h.bridge_state_dir(), &parsed["args"]); r["shell"] = json!("rust"); r },+ "fusion_set_seat_takeover" | "set_seat_takeover" => { let mut r = small_verbs::set_seat_takeover(&h.bridge_state_dir(), &parsed["args"]); r["shell"] = json!("rust"); r },+ "fusion_set_window_sizing" | "set_window_sizing" => { let mut r = small_verbs::set_window_sizing(&h.bridge_state_dir(), &parsed["args"]); r["shell"] = json!("rust"); r },+ "fusion_set_driving_badge" | "set_driving_badge" => { let (sc, ca) = (sidecar.clone(), caller.clone()); let mut r = small_verbs::set_driving_badge(&h.bridge_state_dir(), &parsed["args"], &|| { if !etiquette::clear_badge(&ca) && !addin_owner_is_exe(&**h) { let _ = proxy_with_timeout(&sc, &json!({"command": "set_driving_badge", "args": {"enabled": false}}).to_string(), &ca, 20); } }); r["shell"] = json!("rust"); r },+ "fusion_addin_status" | "addin_status" => { let mut r = small_verbs::addin_status(!h.fusion_processes().is_empty()); r["shell"] = json!("rust"); r },+ "fusion_plan_library_import" | "plan_library_import" => {+ let installed = h.fusion_executable().is_some(); let running = !h.fusion_processes().is_empty();+ let addin_ok = running && ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(6)).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);+ let mut r = small_verbs::plan_library_import(&h.bridge_state_dir(), installed, running, addin_ok, &parsed["args"]); r["shell"] = json!("rust"); r }, "fusion_readiness" | "readiness" => { let mut r = proxy(&sidecar, &raw, &caller); let native = shadow_readiness(&**h, &caller);@@ -354,6 +471,11 @@ fn main() { "fusion_aps_create_folder" | "aps_create_folder" => native_aps_create_folder(&parsed["args"]), "fusion_aps_upload" | "aps_upload" => native_aps_upload(&parsed["args"]), "fusion_aps_status" | "aps_status" => native_aps_status(&sidecar, &caller),+ "fusion_aps_set_client_id" | "aps_set_client_id" => { let mut r = aps_signin::set_client_id(&parsed["args"]); r["shell"] = json!("rust"); r },+ "fusion_aps_set_browser" | "aps_set_browser" => { let mut r = aps_signin::set_browser(&parsed["args"]); r["shell"] = json!("rust"); r },+ "fusion_aps_get_browser" | "aps_get_browser" => { let mut r = aps_signin::get_browser(); r["shell"] = json!("rust"); r },+ "fusion_aps_forget_browser" | "aps_forget_browser" => { let mut r = aps_signin::forget_browser(); r["shell"] = json!("rust"); r },+ "fusion_open_by_urn_note" | "open_by_urn_note" => json!({"success": true, "shell": "rust", "_hint": "See fusion_open_by_urn: a board is NOT a standalone file - open the electronics DESIGN document by URN, then fusion_show_2d_board / fusion_show_3d_board for its views."}), "shell_info" => json!({"success": true, "shell": "rust", "bridgeVersion": VERSION, "contractFrozen": catalog.frozen, "_hint": "Rust plumbing shell; every catalogued verb is served, natively or via the Python sidecar."}),@@ -370,18 +492,20 @@ fn main() { } /// Forward an unported verb to the Python sidecar, identity headers intact.-fn proxy(sidecar: &Arc<Mutex<Option<Sidecar>>>, raw: &str, caller: &Caller) -> Value {+fn proxy(sidecar: &Arc<Mutex<Option<Sidecar>>>, raw: &str, caller: &Caller) -> Value { proxy_with_timeout(sidecar, raw, caller, 600) }++fn proxy_with_timeout(sidecar: &Arc<Mutex<Option<Sidecar>>>, raw: &str, caller: &Caller, timeout_secs: u64) -> Value { // ab routes the first verb the moment /status is 2xx, which is before the sidecar has // bound its port: wait out the sidecar's startup (bounded) instead of failing that verb. if let Some(code) = sidecar.lock().unwrap().as_mut().and_then(|s| s.exited()) { return json!({"success": false, "errorCode": "sidecar_exited", "error": format!("The Python sidecar exited with code {code}."), "_hint": "Read ~/.adom/fusion-bridge/sidecar.log for the traceback, then bridge_kill {name:'fusion360'} so ab respawns the shell and its sidecar."}); }- let (url, young) = match sidecar.lock().unwrap().as_ref() { Some(s) => (s.url("/command"), s.started.elapsed() < Duration::from_secs(45)), None => return json!({+ let (url, young) = match sidecar.lock().unwrap().as_ref() { Some(s) => { *launch::SIDECAR_URL.lock().unwrap() = Some(s.url("/command")); (s.url("/command"), s.started.elapsed() < Duration::from_secs(45)) }, None => return json!({ "success": false, "errorCode": "sidecar_absent", "error": "This verb is not ported yet and the Python sidecar is not running.", "_hint": "Set ADOM_SIDECAR_PYTHON to ab's provisioned python.exe, or run the Python bridge directly until this verb is ported."}) };- let mut r = ureq::post(&url).timeout(Duration::from_secs(600)).set("Content-Type", "application/json");+ let mut r = ureq::post(&url).timeout(Duration::from_secs(timeout_secs)).set("Content-Type", "application/json"); if !caller.thread.is_empty() { r = r.set("X-Adom-Caller-Thread", &caller.thread); } if !caller.container.is_empty() { r = r.set("X-Adom-Caller-Container", &caller.container); } if !caller.reason.is_empty() { r = r.set("X-Adom-Caller-Reason", &caller.reason); }@@ -406,7 +530,7 @@ fn proxy(sidecar: &Arc<Mutex<Option<Sidecar>>>, raw: &str, caller: &Caller) -> V /// no top-level window, so dialog enumeration cannot see them; its AppLogFile can. The log's /// stamps are LOCAL time, so the cutoff is computed in the same convention (the Python bridge /// compares `strptime(...).timestamp()` with `time.time()`, which is the same thing).-fn native_alerts(h: &dyn HostBoth, since_sec: f64) -> Value {+pub(crate) fn native_alerts(h: &dyn HostBoth, since_sec: f64) -> Value { let hint = "Fusion draws many errors as in-canvas CEF overlays with NO top-level window, so check_dialogs alone cannot see them; these come from Fusion's own AppLogFile 'New Alert message' entries. An empty list means Fusion raised no toast in the window - not that a command succeeded."; let Some(dir) = h.fusion_log_dir() else { return json!({"success": true, "alerts": [], "logFile": Value::Null, "shell": "rust",@@ -622,7 +746,7 @@ fn native_aps_browse(args: &Value) -> Value { } /// `fusion_aps_search`: the server-indexed name search across the hub, newest first.-fn native_aps_search(args: &Value) -> Value {+pub(crate) fn native_aps_search(args: &Value) -> Value { use fusion_core::aps::*; let query = args["query"].as_str().unwrap_or("").trim().to_string(); if query.is_empty() { return json!({"success": false, "error": "Missing query.", "shell": "rust"}); }@@ -824,7 +948,7 @@ fn native_aps_status(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) -> let live = has_at && tok["expires_at"].as_u64().unwrap_or(0) > now; let usable = if live { true } else if has_at { store.valid_access_token().is_some() } else { false }; let refresh_failed = has_at && !usable;- let pending = if sidecar.lock().unwrap().is_some() {+ let pending = aps_signin::signin_pending() || if sidecar.lock().unwrap().is_some() { let r = proxy(sidecar, &json!({"command": "aps_status", "args": {}}).to_string(), caller); r["data"]["signinPending"].as_bool().unwrap_or(false) } else { false };@@ -851,10 +975,16 @@ fn native_aps_status(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) -> /// 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)+/// Who owns a surface: the exe unless prefs.json names the sidecar explicitly (the rollback+/// switch while the Python bridge is still in the package). Defaults flipped 2026-09-04 once+/// every verb answered natively and the drill and the tour had run on the exe alone.+fn owner_is_exe(h: &dyn HostBoth, key: &str) -> bool { owner_pref(h, key).as_deref() != Some("sidecar") }+fn owner_pref(h: &dyn HostBoth, key: &str) -> Option<String> {+ 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()).and_then(|p| p[key].as_str().map(str::to_string)) }+/// The sidecar is spawned only when some surface is handed back to it.+fn sidecar_wanted(h: &dyn HostBoth) -> bool { ["readinessOwner", "chainsOwner", "addinOwner"].iter().any(|k| owner_pref(h, k).as_deref() == Some("sidecar")) || std::env::var("ADOM_FUSION_SIDECAR").map(|v| v == "1").unwrap_or(false) }+fn readiness_owner_is_exe(h: &dyn HostBoth) -> bool { owner_is_exe(h, "readinessOwner") } /// 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).@@ -1015,3 +1145,55 @@ fn gather_readiness_inputs(h: &dyn HostBoth, caller: &Caller) -> fusion_core::re *WINDOW_FACTS_DEGRADED.lock().unwrap() = (facts.degraded, facts.error.clone()); inputs }+++/// The exe as the chains' host: sidecar verbs through the proxy, the add-in directly.+struct ExeChainHost { sidecar: Arc<Mutex<Option<Sidecar>>>, caller: Caller, state_dir: std::path::PathBuf }+impl chains::ChainHost for ExeChainHost {+ fn call(&self, verb: &str, args: Value, timeout_secs: u64) -> Value {+ // the bind and prove leaves are native when the exe owns the add-in; everything else+ // the chain asks for is a sidecar executor+ const NATIVE: &[&str] = &["build_library_3d", "prove_library", "make_3d_package", "chain_docs_open", "chain_close_docs_since", "chain_close_bridge_doc", "chain_sweep_orphans", "chain_publish_library", "chain_addin_restart_info", "activate_document", "activity_banner", "start", "restart_for_addin", "plan_library_import"];+ if NATIVE.contains(&verb) && addin_owner_is_exe(&*host()) {+ let h = host();+ let (sc, ca) = (self.sidecar.clone(), self.caller.clone());+ let side = move |v: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": v, "args": a}).to_string(), &ca, t);+ let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx = addin::Ctx { host: &*h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: self.state_dir.clone(), caller: self.caller.clone(), pids, sidecar: &side, dialogs: &dlg };+ let pass = |v: &str, a: Value| addin::pass_through(v, &a, &ctx);+ let mut a = args.clone(); if verb == "build_library_3d" { a["async"] = json!(false); }+ return match verb {+ "build_library_3d" => mint::build_library_3d(&ctx, &a, &pass), "make_3d_package" => mint::make_3d_package(&ctx, &a, &pass), "prove_library" => prove::prove_library(&ctx, &a, &pass),+ "chain_docs_open" => chainexec::docs_open(&pass), "chain_close_docs_since" => chainexec::close_docs_since(&ctx, &a, &pass), "chain_close_bridge_doc" => chainexec::close_bridge_doc(&ctx, &a, &pass),+ "plan_library_import" => { let running = !h.fusion_processes().is_empty(); small_verbs::plan_library_import(&self.state_dir, h.fusion_executable().is_some(), running, running && self.addin_round_trip_ready(), &a) },+ "start" => launch::start(&ctx, &a), "restart_for_addin" => lifecycle::restart_for_addin(&ctx, &a, &pass),+ "chain_sweep_orphans" => chainexec::sweep_orphans(&ctx, &pass), "chain_publish_library" => chainexec::publish_library(&ctx, &a, &pass), "chain_addin_restart_info" => chainexec::addin_restart_info(&ctx, &a, &pass),+ _ => pass(verb, a),+ };+ }+ proxy_with_timeout(&self.sidecar, &json!({"command": verb, "args": args}).to_string(), &self.caller, timeout_secs)+ }+ fn state_dir(&self) -> std::path::PathBuf { self.state_dir.clone() }+ fn addin_round_trip_ready(&self) -> bool {+ ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(6)).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)+ }+ fn addin_status(&self) -> Value { addin_probe() }+ fn fusion_running(&self) -> bool { host().fusion_processes().len() > 0 }+ fn set_long_op(&self, name: Option<String>) { *LONG_OP_SINCE.lock().unwrap() = if name.is_some() { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) } else { 0.0 }; *LONG_OP.lock().unwrap() = name; }+ fn taskbar(&self, pct: Option<f64>) { timings::taskbar_pct(&self.caller, pct); }+}++/// The chain in flight (busy: true on /status, like the Python bridge's _LONG_OP).+static LONG_OP: Mutex<Option<String>> = Mutex::new(None);+static LONG_OP_SINCE: Mutex<f64> = Mutex::new(0.0);+/// (name, since) of the long op in flight: a chain, else a walk/search on the pass-through.+pub(crate) fn long_op_marker() -> Option<(String, f64)> {+ if let Some(n) = LONG_OP.lock().unwrap().clone() { return Some((n, *LONG_OP_SINCE.lock().unwrap())); }+ addin::long_command_active().and_then(|v| Some((v["command"].as_str()?.to_string(), v["startedAt"].as_f64()?)))+}++fn chains_owner_is_exe(h: &dyn HostBoth) -> bool { owner_is_exe(h, "chainsOwner") }++fn addin_owner_is_exe(h: &dyn HostBoth) -> bool { owner_is_exe(h, "addinOwner") }
rust/bridge/src/maintenance.rsadded+188@@ -0,0 +1,188 @@+//! Fusion's own update offer, the silent installer and the bridge uninstall, native.++use serde_json::{json, Value};+use std::time::Duration;++use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};++const UPDATE_BUTTONS: &[&str] = &["Update Now", "Update now", "Install Now", "Restart Now"];+const UPDATE_BUTTONS_EXIT_CLASS: &[&str] = &["Install Now", "Restart Now"];++fn find_controls_any(v: &Value) -> Value {+ fn walk(v: &Value, depth: u8) -> Value {+ if depth > 5 { return json!({}); }+ if let Some(s) = v.as_str() { if s.starts_with('{') || s.starts_with('[') { if let Ok(p) = serde_json::from_str::<Value>(s) { return walk(&p, depth + 1); } } return json!({}); }+ if let Some(o) = v.as_object() {+ if o.get("best").map(|b| b.is_object()).unwrap_or(false) || o.get("controls").map(|c| c.is_array()).unwrap_or(false) { return json!({"best": o.get("best").cloned().unwrap_or(json!({})), "controls": o.get("controls").cloned().unwrap_or(json!([]))}); }+ for (_, x) in o { let r = walk(x, depth + 1); if r.as_object().map(|m| !m.is_empty()).unwrap_or(false) { return r; } }+ }+ json!({})+ }+ walk(v, 0)+}++/// `_find_update_offer`: is Fusion offering an update (a button on any family window)?+pub fn find_update_offer(ctx: &Ctx) -> Value {+ let Some(ab) = Loopback::from_env() else { return json!({"offered": false}) };+ let facts = fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller);+ let Some(hw) = facts.main_hwnd else { return json!({"offered": false}) };+ let mut candidates = vec![hw];+ for w in &facts.family { if let Some(h) = w["hwnd"].as_i64() { if h != hw { candidates.push(h); } } }+ for h in candidates {+ for label in UPDATE_BUTTONS {+ let r = ab.call("desktop_find_control", json!({"hwnd": h, "name": label}), &ctx.caller, 20).map(|v| find_controls_any(&v)).unwrap_or(json!({}));+ let best = &r["best"];+ if best["name"].as_str().map(|n| !n.is_empty()).unwrap_or(false) && best["invokable"].as_bool().unwrap_or(false) {+ crate::small_verbs::set_pref(&ctx.state_dir, "lastUpdateOffer", json!({"offered": true, "at": now_f(), "button": best["name"]}));+ return json!({"offered": true, "button": best["name"], "hwnd": h, "detail": format!("Fusion is offering an update ('{}' is on screen)", best["name"].as_str().unwrap_or(""))});+ }+ }+ }+ json!({"offered": false, "hwnd": hw})+}++fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++/// `_fusion_verifiably_idle`: may something EXIT Fusion right now?+pub fn fusion_verifiably_idle(pass: &dyn Fn(&str, Value) -> Value) -> Value {+ if let Some((name, _)) = crate::long_op_marker() { return json!({"idle": false, "why": format!("long op in flight: {name}")}); }+ if let Some(t) = crate::etiquette::last_drive_at() { let ago = now_f() - t; if ago < 180.0 { return json!({"idle": false, "why": format!("driving verb {}s ago", ago as i64)}); } }+ let st = pass("document_info", json!({}));+ if st["success"].as_bool() != Some(true) { return json!({"idle": false, "why": "add-in unreachable - cannot prove no open docs"}); }+ let n = st["data"]["openDocuments"].as_array().map(|a| a.len()).unwrap_or(0);+ if n > 0 { return json!({"idle": false, "why": format!("{n} document(s) open")}); }+ json!({"idle": true, "why": "no long op, no recent driving, no open documents"})+}++/// `_apply_fusion_update_silently` + the verb shape (`fusion_apply_fusion_update`).+pub fn apply_fusion_update(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let enabled = crate::small_verbs::get_pref(&ctx.state_dir, "autoUpdateFusion").map(|v| v != json!(false)).unwrap_or(true);+ let offered_before = crate::small_verbs::get_pref(&ctx.state_dir, "lastUpdateOffer").map(|v| v["offered"].as_bool().unwrap_or(false)).unwrap_or(false);+ let r = if !enabled { json!({"applied": false, "why": "user opted out (sticky pref autoUpdateFusion=false)"}) } else {+ let offer = find_update_offer(ctx);+ if offer["offered"].as_bool() != Some(true) { json!({"applied": false, "why": "no update offered"}) } else {+ let button = offer["button"].as_str().unwrap_or("").to_string();+ let exit_class = UPDATE_BUTTONS_EXIT_CLASS.contains(&button.as_str());+ let mut deferred = None;+ if exit_class { let idle = fusion_verifiably_idle(pass); if idle["idle"].as_bool() != Some(true) { crate::fusion_prefs::action_ledger(&ctx.state_dir, "auto_update_deferred", json!({"button": button, "notIdle": idle["why"]})); eprintln!("[fusion-bridge] auto-update: {button:?} on screen but Fusion is NOT idle ({}) - DEFERRED, will retry when idle", idle["why"].as_str().unwrap_or("")); deferred = Some(json!({"applied": false, "deferred": true, "button": button, "why": format!("exit-class update button deferred: {}", idle["why"].as_str().unwrap_or(""))})); } }+ match deferred { Some(d) => d, None => {+ let ok = Loopback::from_env().and_then(|ab| ab.call("desktop_ui_click", json!({"hwnd": offer["hwnd"], "name": button}), &ctx.caller, 30).ok()).map(|r| r["invoked"].as_bool().unwrap_or(false) || r["success"].as_bool().unwrap_or(false)).unwrap_or(false);+ if ok { eprintln!("[fusion-bridge] auto-update: clicked {button:?} in the background"); }+ crate::fusion_prefs::action_ledger(&ctx.state_dir, "auto_update_click", json!({"button": button, "invoked": ok, "exitClass": exit_class}));+ json!({"applied": ok, "button": button, "why": if ok { format!("clicked {button:?} in the background; Fusion downloads now and swaps on next launch") } else { "found the button but the UIA invoke did not take".to_string() }})+ } }+ }+ };+ let mut out = json!({"success": true, "offered": offered_before || r["button"].is_string(), "autoUpdateEnabled": enabled});+ if let (Some(o), Some(ro)) = (out.as_object_mut(), r.as_object()) { for (k, v) in ro { o.insert(k.clone(), v.clone()); } }+ out+}++fn installer_running(ctx: &Ctx) -> bool {+ let Some(ab) = Loopback::from_env() else { return false };+ for name in ["streamer", "FusionDL", "Fusion Client Downloader"] {+ 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+}++/// `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."}); }+ 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");+ if code != 200 || bytes.is_empty() || std::fs::write(&stub, &bytes).is_err() { return json!({"success": false, "error": format!("Installer download failed: HTTP {code}"), "errorCode": "installer_download_failed", "_hint": "Check the box is online; retry fusion_install_fusion. The stub URL is Autodesk's official appstreaming installer."}); }+ let elevated = ctx.host.is_elevated();+ let mut cmd = std::process::Command::new(&stub);+ cmd.arg("--quiet"); if elevated { cmd.arg("--globalinstall"); }+ cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+ #[cfg(windows)]+ { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000 | 0x0000_0008); }+ if let Err(e) = cmd.spawn() { return json!({"success": false, "error": format!("Installer launch failed: {e}"), "errorCode": "installer_launch_failed"}); }+ json!({"success": true, "installing": true, "elevated": elevated, "mode": if elevated { "globalinstall" } else { "per-user" }, "statusVerb": "fusion_readiness",+ "_hint": format!("Fusion 360 install STARTED ({}). It streams several GB (10-30 min): poll fusion_readiness until installed:true (it also reports installing:true while the streamer runs), then fusion_start. NOTIFY the user it is underway (progress toast) and again at the Autodesk sign-in.", if elevated { "system-wide" } else { "per-user - no admin needed; a non-elevated --globalinstall dies silently on UAC" })})+}++/// `fusion_uninstall`: the add-in out of Autodesk's dirs, this bridge's prefs and APS tokens.+pub fn uninstall(ctx: &Ctx, addin_dirs: &[std::path::PathBuf], args: &Value) -> Value {+ let dry = args["dryRun"].as_bool().unwrap_or(false);+ let home = std::path::PathBuf::from(std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default());+ let addin_targets: Vec<std::path::PathBuf> = addin_dirs.iter().map(|d| d.join("AdomBridge")).collect();+ let data_targets = vec![home.join(".adom").join("fusion-bridge"), home.join(".adom").join("fusion-aps")];+ let (mut removed, mut failed, mut skipped): (Vec<Value>, Vec<Value>, Vec<Value>) = (vec![], vec![], vec![]);+ for (p, kind) in addin_targets.iter().map(|p| (p, "addin")).chain(data_targets.iter().map(|p| (p, "userdata"))) {+ let ps = p.to_string_lossy().to_string();+ if !p.exists() { skipped.push(json!({"path": ps, "kind": kind, "why": "not present"})); continue; }+ if dry { removed.push(json!({"path": ps, "kind": kind, "dryRun": true})); continue; }+ match std::fs::remove_dir_all(p) { Ok(_) => removed.push(json!({"path": ps, "kind": kind})), Err(e) => failed.push(json!({"path": ps, "kind": kind, "error": e.to_string()})) }+ }+ let running = !ctx.host.fusion_processes().is_empty();+ let mut out = json!({"success": failed.is_empty(), "dryRun": dry, "cascade": args["cascade"].as_bool().unwrap_or(false), "fusionRunning": running, "removed": removed, "failed": failed, "skipped": skipped});+ let nf = out["failed"].as_array().map(|a| a.len()).unwrap_or(0);+ out["_hint"] = json!(if nf > 0 && running { format!("Fusion is RUNNING and holds the add-in files open, so {nf} path(s) could not be removed. Close Fusion (fusion_stop) and re-run fusion_uninstall, or delete those paths by hand - otherwise the stale add-in keeps loading inside Fusion on every launch and binding its port.") }+ else if nf > 0 { format!("{nf} path(s) could not be removed (permissions?). They are listed in `failed`; delete them by hand so no stale add-in is left inside Fusion.") }+ else if dry { "DRY RUN - nothing was touched. These are the paths a real uninstall removes: the Fusion add-in in Autodesk's AddIns dirs, plus this bridge's prefs and APS OAuth tokens under ~/.adom.".to_string() }+ else { "Cleaned up: the Fusion add-in is gone from Autodesk's directories, and this bridge's prefs + APS OAuth tokens are removed. Fusion itself is untouched. Re-installing the bridge redeploys the add-in on its next start.".to_string() });+ out+}++const PREF_SECTIONS: &[(&str, Option<&str>, f64, f64)] = &[("general", None, 0.071, 0.092), ("api", Some("general"), 0.071, 0.135), ("design", Some("general"), 0.071, 0.179), ("manufacture", Some("general"), 0.071, 0.222), ("electronics", Some("general"), 0.071, 0.265), ("render", Some("general"), 0.071, 0.310), ("drawing", Some("general"), 0.071, 0.353), ("material", None, 0.071, 0.483), ("graphics", None, 0.071, 0.527), ("network", None, 0.071, 0.570), ("preview features", None, 0.071, 0.744)];++fn prefs_dialog_hwnd(ab: &dyn AbClient, caller: &fusion_core::ab::Caller) -> Option<i64> {+ let v = ab.call("desktop_list_windows", json!({}), caller, 40).ok()?;+ let rows = v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned().unwrap_or_default();+ rows.iter().find(|w| w["title"].as_str().unwrap_or("").trim() == "Preferences").and_then(|w| w["hwnd"].as_i64())+}+fn prefs_shot(ab: &dyn AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> (Value, f64, f64, Value) {+ let s = ab.call("desktop_screenshot_window", json!({"hwnd": hwnd}), caller, 45).unwrap_or(json!({}));+ let cm = &s["coordMap"]; let img = &cm["image"];+ (cm["shotId"].clone(), img["w"].as_f64().unwrap_or(1400.0), img["h"].as_f64().unwrap_or(836.0), if s["localSafePath"].is_null() { s["safePath"].clone() } else { s["localSafePath"].clone() })+}+fn image_click(ab: &dyn AbClient, caller: &fusion_core::ab::Caller, shot_id: &Value, hwnd: i64, x: i64, y: i64) { let _ = ab.call("desktop_click", json!({"space": "image", "shotId": shot_id, "hwnd": hwnd, "x": x, "y": y}), caller, 30); }++/// `fusion_prefs_open`: Fusion Preferences, optionally at a section, with a screenshot to click in.+pub fn prefs_open(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ if ctx.host.fusion_processes().is_empty() { return json!({"success": false, "error": "Fusion is not running.", "_hint": "fusion_start first."}); }+ let Some(ab) = Loopback::from_env() else { return json!({"success": false, "error": "ab loopback unavailable"}) };+ let section = args["section"].as_str().unwrap_or("").trim().to_lowercase();+ let mut steps: Vec<String> = Vec::new();+ let mut hwnd = prefs_dialog_hwnd(&ab, &ctx.caller);+ if hwnd.is_none() {+ let _ = ab.call("desktop_caption", json!({"text": "Adom: opening Fusion preferences", "id": "fusion-bridge-foreground", "expiresInMs": 2500}), &ctx.caller, 15);+ let _ = pass("execute_text_command", json!({"command": "Commands.Start PreferencesCommand"}));+ std::thread::sleep(Duration::from_secs(3));+ hwnd = prefs_dialog_hwnd(&ab, &ctx.caller);+ steps.push("opened Preferences".into());+ }+ let Some(hwnd) = hwnd else { return json!({"success": false, "error": "Preferences dialog did not open.", "steps": steps}) };+ let known: Vec<&str> = { let mut k: Vec<&str> = PREF_SECTIONS.iter().map(|s| s.0).collect(); k.sort(); k };+ if !section.is_empty() {+ let Some(spec) = PREF_SECTIONS.iter().find(|s| s.0 == section) else { return json!({"success": false, "error": format!("Unknown section {}.", fusion_lbr::py_repr_str(§ion)), "knownSections": known, "_hint": "Pass one of knownSections, or omit `section` and click the returned shotId yourself with desktop_click {space:'image'}."}) };+ let (mut sid, mut w, mut h, _) = prefs_shot(&ab, &ctx.caller, hwnd);+ if let Some(parent) = spec.1 {+ let ps = PREF_SECTIONS.iter().find(|s| s.0 == parent).unwrap();+ image_click(&ab, &ctx.caller, &sid, hwnd, (w * 0.029) as i64, (h * ps.3) as i64);+ steps.push(format!("expanded {parent}")); std::thread::sleep(Duration::from_secs(2));+ let t = prefs_shot(&ab, &ctx.caller, hwnd); sid = t.0; w = t.1; h = t.2;+ }+ image_click(&ab, &ctx.caller, &sid, hwnd, (w * spec.2) as i64, (h * spec.3) as i64);+ steps.push(format!("selected {section}")); std::thread::sleep(Duration::from_secs(2));+ }+ let (sid, w, h, path) = prefs_shot(&ab, &ctx.caller, hwnd);+ json!({"success": true, "hwnd": hwnd, "section": if section.is_empty() { "general".to_string() } else { section }, "shotId": sid, "imageWidth": w, "imageHeight": h, "screenshot": path, "steps": steps, "knownSections": known,+ "_hint": "Preferences is OPEN and showing this section. LOOK at the screenshot, then click any control with desktop_click {space:'image', shotId, x, y, hwnd}. ⛔ Nothing is saved until you call fusion_prefs_close {save:true} (Apply+OK) - killing or restarting Fusion first DISCARDS the change. Child sections under General are lazily rendered and absent from the UIA tree, which is why this verb hands you an image to click rather than control names.", "statusVerb": "fusion_get_preferences"})+}++/// `fusion_prefs_close`: Apply+OK (save:true, default) or Cancel.+pub fn prefs_close(ctx: &Ctx, args: &Value) -> Value {+ let save = args["save"].as_bool().unwrap_or(true);+ let Some(ab) = Loopback::from_env() else { return json!({"success": false, "error": "ab loopback unavailable"}) };+ let Some(hwnd) = prefs_dialog_hwnd(&ab, &ctx.caller) else { return json!({"success": true, "closed": false, "_hint": "Preferences was not open; nothing to do."}) };+ let (sid, w, h, _) = prefs_shot(&ab, &ctx.caller, hwnd);+ if save { image_click(&ab, &ctx.caller, &sid, hwnd, (w * 0.839) as i64, (h * 0.948) as i64); std::thread::sleep(Duration::from_secs(2)); image_click(&ab, &ctx.caller, &sid, hwnd, (w * 0.894) as i64, (h * 0.948) as i64); }+ else { image_click(&ab, &ctx.caller, &sid, hwnd, (w * 0.951) as i64, (h * 0.948) as i64); }+ std::thread::sleep(Duration::from_secs(2));+ json!({"success": true, "closed": prefs_dialog_hwnd(&ab, &ctx.caller).is_none(), "saved": save, "narrate": if save { "Saved your Fusion preferences." } else { "Closed preferences without saving." }, "_hint": if save { "Applied and closed. Some settings (the MCP server is one) only take effect once saved this way." } else { "Cancelled; nothing was changed." }, "statusVerb": "fusion_get_preferences"})+}
rust/bridge/src/managed.rsadded+386@@ -0,0 +1,386 @@+//! `fusion_library_manager` and `fusion_managed_library_*`: Fusion's library.io choreography,+//! driven by measurement (ribbon DIPs scaled by the window's real DPI, the LIBRARY.IO popup+//! located as a window, CEF dialogs answered by rect fraction) and judged by Fusion's own+//! alert log, never by hope.+use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};+use serde_json::{json, Value};+use std::time::{Duration, Instant};++/// LIBRARY.IO ribbon targets in DIPs from the client origin. Re-measured 2026-09-04 on+/// ConfRoomROG after Fusion's morning update moved the ribbon tab row down ~11 DIP (the+/// 2026-09-01 numbers were 42 / 103 / 80); `group_alt` is the older group row, tried second.+const LIBIO_DIP: &[(&str, f64, f64)] = &[("manage_tab", 296.0, 53.0), ("group", 455.0, 113.0), ("group_alt", 455.0, 103.0), ("open_icon", 352.0, 86.0)];+/// The dropdown's four items, in order; the order does not change with link state.+const LIBIO_MENU_ITEMS: &[&str] = &["push", "pull", "link", "unlink"];+const PICKER_COLUMNS: &[&str] = &["name", "type", "version", "managedFolder", "description"];++fn sleep_s(s: f64) { std::thread::sleep(Duration::from_secs_f64(s)); }+fn ab_call(ctx: &Ctx, verb: &str, args: Value, timeout: u64) -> Value {+ Loopback::from_env().and_then(|ab| ab.call(verb, args, &ctx.caller, timeout).ok()).unwrap_or(json!({}))+}+/// `_find_key`: the first value for `key` anywhere in a nested ab response.+pub fn find_key(v: &Value, key: &str, depth: u8) -> Option<Value> {+ if depth > 6 { return None; }+ match v {+ Value::Object(o) => { if let Some(x) = o.get(key) { return Some(x.clone()); } o.values().find_map(|x| find_key(x, key, depth + 1)) }+ Value::Array(a) => a.iter().take(20).find_map(|x| find_key(x, key, depth + 1)),+ Value::String(s) if s.trim_start().starts_with('{') && s.contains(key) => serde_json::from_str::<Value>(s).ok().and_then(|p| find_key(&p, key, depth + 1)),+ _ => None,+ }+}+fn windows(ctx: &Ctx) -> Vec<Value> { find_key(&ab_call(ctx, "desktop_list_windows", json!({}), 25), "windows", 0).and_then(|w| w.as_array().cloned()).unwrap_or_default() }++/// `_fusion_main_window` + `_window_dpi_scale`: {hwnd, rect, dpiScale, title} or None.+fn fusion_main_window(ctx: &Ctx) -> Option<Value> {+ let w = windows(ctx).into_iter().find(|w| w["title"].as_str().unwrap_or("").contains("Autodesk Fusion") && w["kind"].as_str() == Some("normal"))?;+ let hwnd = w["hwnd"].as_i64()?;+ Some(json!({"hwnd": hwnd, "rect": w["rect"], "dpiScale": ctx.host.window_dpi_scale(hwnd), "title": w["title"]}))+}+fn alerts(_ctx: &Ctx, since: f64) -> Vec<Value> { crate::native_alerts(&*crate::host(), since)["alerts"].as_array().cloned().unwrap_or_default() }+/// `_alert_watermark`: the stamp of Fusion's most recent alert right now, so a verb only ever+/// judges itself by its OWN alerts.+fn alert_watermark(ctx: &Ctx) -> String { alerts(ctx, 3600.0).last().and_then(|a| a["at"].as_str()).unwrap_or("").to_string() }+fn alerts_since(ctx: &Ctx, mark: &str) -> Vec<Value> { let rows = alerts(ctx, 300.0); if mark.is_empty() { rows } else { rows.into_iter().filter(|r| r["at"].as_str().unwrap_or("") > mark).collect() } }++/// `_library_in_front`: (ok, doc, workspace) for the document Fusion actually has in front.+fn library_in_front(pass: &dyn Fn(&str, Value) -> Value) -> (bool, String, String) {+ let st = pass("get_app_state", json!({}));+ let doc = st["data"]["activeDocument"].as_str().unwrap_or("").to_string();+ let ws = st["data"]["activeWorkspace"].as_str().unwrap_or("").to_string();+ (ws.to_lowercase().contains("librar"), doc, ws)+}+fn not_a_library(doc: &str, ws: &str) -> Value {+ json!({"success": false, "errorCode": "not_a_library", "error": format!("The document in front is {} in the {} workspace, not a library.", fusion_lbr::py_repr_str(doc), if ws.is_empty() { "unknown" } else { ws }),+ "_hint": "fusion_open_lbr {lbrPath} first - it lands the library editor in front and verifies it, which activate_document alone does NOT."})+}+fn hub_check(pass: &dyn Fn(&str, Value) -> Value, doc: &str, with_open: bool) -> Value {+ let want = doc.replace('*', "").trim().to_string();+ let extra = if with_open { " 'id': (df.id if df else None), 'open': [app.documents.item(i).name for i in range(app.documents.count)][:8]" } else { "" };+ let script = format!("import adsk.core\napp=adsk.core.Application.get()\nwant={}\nfound=None\nfor i in range(app.documents.count):\n dd=app.documents.item(i)\n if (dd.name or '').replace('*','').strip()==want:\n found=dd; break\ndf=None\nif found is not None:\n try: df=found.dataFile\n except Exception: df=None\nresult={{'name': (found.name if found else None), 'matched': found is not None, 'inHub': df is not None,{}}}\n", fusion_lbr::py_repr_str(&want), extra);+ pass("run_modeling_script", json!({"script": script}))["data"]["result"].clone()+}+/// `_managed_precheck`: a LIBRARY is in front, and (when the action needs it) it lives in a Hub.+fn precheck(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, require_hub: bool) -> Result<(String, String, Value), Value> {+ let (ok, doc, ws) = library_in_front(pass);+ if !ok { return Err(not_a_library(&doc, &ws)); }+ let Some(win) = fusion_main_window(ctx) else { return Err(json!({"success": false, "errorCode": "no_fusion_window", "error": "Could not locate Fusion's main window."})) };+ if require_hub {+ let res = hub_check(pass, &doc, false);+ if res["inHub"].as_bool() != Some(true) {+ return Err(json!({"success": false, "errorCode": "not_in_hub", "error": format!("Library {} is not saved in a Fusion Hub.", fusion_lbr::py_repr_str(res["name"].as_str().unwrap_or(&doc))), "remedyVerb": "fusion_save_to_cloud",+ "_hint": "fusion_save_to_cloud {name, projectName, folderPath} first; a real library save returns a fileId ending in .flbr."}));+ }+ }+ Ok((doc, ws, win))+}++/// `_ribbon_click`: a LIBRARY.IO ribbon target, re-foregrounding Fusion in the same call+/// (the user is constantly alt-tabbing away), except a popup item, where refocusing would+/// dismiss the menu.+fn ribbon_click(ctx: &Ctx, win: &Value, key: &str, why: &str) -> Value {+ let scale = win["dpiScale"].as_f64().unwrap_or(1.0);+ let hwnd = win["hwnd"].as_i64().unwrap_or(0);+ let (ox, oy) = ctx.host.client_origin(hwnd);+ let (dx, dy) = LIBIO_DIP.iter().find(|k| k.0 == key).map(|k| (k.1, k.2)).unwrap_or((0.0, 0.0));+ let (x, y) = ((ox as f64 + dx * scale) as i64, (oy as f64 + dy * scale) as i64);+ let _ = ab_call(ctx, "desktop_click", json!({"hwnd": hwnd, "x": x, "y": y, "cursor": false, "reason": why}), 25);+ json!({"step": key, "at": [x, y], "scale": scale})+}+/// `_popup_menu_rect`: the open LIBRARY.IO dropdown, measured as a window (Qt683QWindow titled+/// "Fusion360", menu-sized, topmost), never read.+fn popup_menu_rect(ctx: &Ctx, timeout: f64) -> Option<Value> {+ let deadline = Instant::now() + Duration::from_secs_f64(timeout);+ while Instant::now() < deadline {+ let mut cands: Vec<(i64, Value)> = windows(ctx).into_iter().filter(|w| {+ let r = &w["rect"]; let (wd, ht) = (r["width"].as_i64().unwrap_or(0), r["height"].as_i64().unwrap_or(0));+ w["className"].as_str() == Some("Qt683QWindow") && w["title"].as_str() == Some("Fusion360") && (200..=1400).contains(&wd) && (60..=700).contains(&ht)+ }).map(|w| (w["z"].as_i64().unwrap_or(99), w)).collect();+ if !cands.is_empty() {+ cands.sort_by_key(|c| c.0);+ let w = &cands[0].1; let r = &w["rect"];+ return Some(json!({"hwnd": w["hwnd"], "left": r["left"].as_i64().unwrap_or(0), "top": r["top"].as_i64().unwrap_or(0), "width": r["width"].as_i64().unwrap_or(0), "height": r["height"].as_i64().unwrap_or(0)}));+ }+ sleep_s(0.4);+ }+ None+}+/// `_libio_menu`: MANAGE tab -> LIBRARY.IO group -> one item by NAME. None on success.+fn libio_menu(ctx: &Ctx, win: &Value, item: &str, why: &str, steps: &mut Vec<Value>) -> Option<Value> {+ let Some(idx) = LIBIO_MENU_ITEMS.iter().position(|i| *i == item) else { return Some(json!({"success": false, "errorCode": "bad_menu_item", "error": format!("{} is not a LIBRARY.IO menu item; expected one of {}", fusion_lbr::py_repr_str(item), LIBIO_MENU_ITEMS.join(", "))})) };+ steps.push(ribbon_click(ctx, win, "manage_tab", "open the library editor's MANAGE tab"));+ sleep_s(1.4);+ let mut rect = None;+ for attempt in 1..=2 {+ steps.push(ribbon_click(ctx, win, if attempt == 1 { "group" } else { "group_alt" }, "open the LIBRARY.IO command group"));+ sleep_s(1.0);+ rect = popup_menu_rect(ctx, 6.0);+ if rect.is_some() { break; }+ steps.push(json!({"step": "group_retry", "attempt": attempt}));+ }+ let Some(rect) = rect else { return Some(json!({"success": false, "errorCode": "libio_menu_missing", "steps": steps, "error": "The LIBRARY.IO dropdown did not open.",+ "_hint": "Almost always means a LIBRARY is not really in front - Fusion's API can report a library as active while an empty Untitled design holds the visible tab. Re-open the library, confirm the ribbon reads COMPONENT/DOCUMENT/MANAGE (not SOLID/SURFACE), then retry."})) };+ let n = LIBIO_MENU_ITEMS.len() as f64;+ let x = rect["left"].as_i64().unwrap_or(0) + rect["width"].as_i64().unwrap_or(0) / 2;+ let y = (rect["top"].as_i64().unwrap_or(0) as f64 + (idx as f64 + 0.5) * rect["height"].as_i64().unwrap_or(0) as f64 / n) as i64;+ let _ = ab_call(ctx, "desktop_mouse_click", json!({"x": x, "y": y, "reason": why}), 25);+ steps.push(json!({"step": item, "at": [x, y], "menuRect": rect}));+ sleep_s(2.0);+ None+}+/// `_wait_for_top_window`: a top-level window whose title contains `want` (Fusion's own dialogs+/// are real windows, unlike its ribbon and menus).+fn wait_for_top_window(ctx: &Ctx, want: &str, timeout: f64) -> Option<Value> {+ let w = want.to_lowercase();+ let deadline = Instant::now() + Duration::from_secs_f64(timeout);+ while Instant::now() < deadline {+ if let Some(x) = windows(ctx).into_iter().find(|x| x["title"].as_str().unwrap_or("").to_lowercase().contains(&w)) { return Some(x); }+ sleep_s(0.8);+ }+ None+}+/// True once no window matching `want` remains (the list lags a beat behind a dismissal).+fn wait_for_top_window_gone(ctx: &Ctx, want: &str, timeout: f64) -> bool {+ let w = want.to_lowercase();+ let deadline = Instant::now() + Duration::from_secs_f64(timeout);+ while Instant::now() < deadline {+ if let Some(list) = find_key(&ab_call(ctx, "desktop_list_windows", json!({}), 25), "windows", 0).and_then(|v| v.as_array().cloned()) {+ if !list.iter().any(|x| x["title"].as_str().unwrap_or("").to_lowercase().contains(&w)) { return true; }+ }+ sleep_s(1.0);+ }+ false+}+/// `_wait_for_dialog`: a REAL top-level Fusion dialog (dialog array) whose title contains `want`.+fn wait_for_dialog(ctx: &Ctx, want: &str, timeout: f64) -> Option<Value> {+ let w = want.to_lowercase();+ let deadline = Instant::now() + Duration::from_secs_f64(timeout);+ while Instant::now() < deadline {+ if let Some(d) = crate::small_verbs::capture_dialog_array(&ctx.caller, &ctx.pids, 200) {+ if let Some(x) = d["dialogs"].as_array().and_then(|a| a.iter().find(|x| x["title"].as_str().unwrap_or("").to_lowercase().contains(&w))) { return Some(x.clone()); }+ }+ // Library Manager is a top-level Qt window of Fusion's own (not a dialog row): the+ // window list sees it when the dialog array does not (ConfRoomROG, 2026-09-04)+ if let Some(x) = windows(ctx).into_iter().find(|x| x["title"].as_str().unwrap_or("").to_lowercase().contains(&w)) { return Some(x); }+ sleep_s(1.0);+ }+ None+}+/// `_click_cef_button`: a button in a CEF-drawn Fusion dialog by rect fraction (UIA sees nothing).+fn click_cef_button(ctx: &Ctx, win: &Value, xf: f64, yf: f64, why: &str) -> bool {+ let r = &win["rect"];+ let (Some(left), Some(top)) = (r["left"].as_f64(), r["top"].as_f64()) else { return false };+ let (w, h) = (r["width"].as_f64().unwrap_or(0.0), r["height"].as_f64().unwrap_or(0.0));+ if w <= 0.0 || h <= 0.0 { return false; }+ let _ = ab_call(ctx, "desktop_click", json!({"x": (left + w * xf) as i64, "y": (top + h * yf) as i64, "hwnd": win["hwnd"], "cursor": false, "reason": why}), 25);+ true+}+/// `_handle_update_prompt`: "force update your library?" -> [Merge] [Replace] [Cancel]; Cancel by+/// default because Replace DESTROYS the local library.+fn handle_update_prompt(ctx: &Ctx, choice: &str, timeout: f64) -> Option<Value> {+ let label = match choice { "merge" => "Merge Latest Version", "replace" => "Replace Local Library", _ => "Cancel" };+ let win = wait_for_top_window(ctx, "Update", timeout)?;+ let frac = match label { "Merge Latest Version" => 0.396, "Replace Local Library" => 0.689, _ => 0.903 };+ if !click_cef_button(ctx, &win, frac, 0.824, &format!("answer Fusion's library Update prompt with {label}")) {+ return Some(json!({"prompt": "Update", "answered": false, "choice": label, "error": "Could not measure the Update dialog to answer it."}));+ }+ sleep_s(2.0);+ let gone = wait_for_top_window_gone(ctx, "Update", 20.0);+ Some(json!({"prompt": "Update", "choice": label, "answered": gone, "dialogClosed": gone}))+}+/// `_dismiss_ok_dialog`: a single-OK CEF result dialog ("Up to Date"), rightmost button.+fn dismiss_ok_dialog(ctx: &Ctx, title: &str, timeout: f64) -> Option<Value> {+ let win = wait_for_top_window(ctx, title, timeout)?;+ let clicked = click_cef_button(ctx, &win, 0.903, 0.824, &format!("dismiss Fusion's {title} dialog"));+ sleep_s(1.5);+ let gone = wait_for_top_window_gone(ctx, title, 20.0);+ Some(json!({"dialog": title, "clicked": clicked, "dismissed": gone}))+}+/// `_confirm_create_version`: "Create New Version" [Create] [Cancel], which is what performs a push.+fn confirm_create_version(ctx: &Ctx, choice: &str, timeout: f64) -> Option<Value> {+ let win = wait_for_top_window(ctx, "Create New Version", timeout)?;+ let frac = if choice == "create" { 0.565 } else { 0.818 };+ let clicked = click_cef_button(ctx, &win, frac, 0.824, &format!("answer Fusion's Create New Version prompt with {choice}"));+ sleep_s(2.0);+ let gone = wait_for_top_window_gone(ctx, "Create New Version", 20.0);+ Some(json!({"prompt": "Create New Version", "choice": choice, "clicked": clicked, "answered": gone, "dialogClosed": gone}))+}+/// `_picker_rows`: the Select Managed Library table (UIA TreeItems, five cells per row, only+/// the realized rows).+fn picker_rows(ctx: &Ctx, hwnd: &Value) -> (Vec<Value>, Option<String>) {+ let resp = ab_call(ctx, "desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 14}), 60);+ let mut cells: Vec<(String, Value, Value)> = Vec::new();+ fn walk(n: &Value, cells: &mut Vec<(String, Value, Value)>) {+ match n {+ Value::Object(o) => {+ if o.get("role").and_then(|r| r.as_str()) == Some("TreeItem") { if let Some(name) = o.get("name").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { let r = o.get("rect").cloned().unwrap_or(json!({})); cells.push((name.to_string(), r["centerX"].clone(), r["centerY"].clone())); } }+ for v in o.values() { if v.is_object() || v.is_array() { walk(v, cells); } }+ }+ Value::Array(a) => { for v in a { walk(v, cells); } }+ _ => {}+ }+ }+ walk(&resp, &mut cells);+ let n = PICKER_COLUMNS.len();+ let mut rows = Vec::new();+ let mut i = 0;+ while i + n <= cells.len() {+ let chunk = &cells[i..i + n];+ let mut row = json!({});+ for (k, c) in PICKER_COLUMNS.iter().zip(chunk) { row[*k] = json!(c.0); }+ row["_at"] = json!([chunk[0].1, chunk[0].2]);+ rows.push(row);+ i += n;+ }+ (rows, None)+}++/// `fusion_library_manager`: open Library Manager and read one of its three tabs.+pub fn library_manager(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (ok, doc, ws) = library_in_front(pass);+ if !ok { return json!({"success": false, "errorCode": "not_a_library", "error": format!("A library must be in front; the front document is {} ({}).", fusion_lbr::py_repr_str(&doc), ws), "_hint": "fusion_open_lbr {lbrPath} first."}); }+ let Some(win) = fusion_main_window(ctx) else { return json!({"success": false, "errorCode": "no_fusion_window", "error": "Could not locate Fusion's main window."}) };+ let mut steps = vec![ribbon_click(ctx, &win, "manage_tab", "open the MANAGE tab")];+ sleep_s(1.2);+ steps.push(ribbon_click(ctx, &win, "open_icon", "open Library Manager (the OPEN icon's default action)"));+ sleep_s(9.0);+ let Some(dlg) = wait_for_dialog(ctx, "Library Manager", 30.0) else { return json!({"success": false, "errorCode": "library_manager_missing", "steps": steps, "alerts": alerts(ctx, 120.0)}) };+ let tab = args["tab"].as_str().unwrap_or("private").to_lowercase();+ let label = match tab.as_str() { "hub" => "Hub Libraries", "public" => "Public Libraries", _ => "Private Libraries" };+ let click = ab_call(ctx, "desktop_ui_click", json!({"hwnd": dlg["hwnd"], "name": label, "reason": format!("switch Library Manager to {label}")}), 40);+ steps.push(json!({"step": "tab", "label": label, "ok": click["success"].as_bool() == Some(true)}));+ sleep_s(12.0);+ let tree = ab_call(ctx, "desktop_ui_tree", json!({"hwnd": dlg["hwnd"], "maxDepth": 10, "maxNodes": 600}), 120);+ let mut rows: Vec<String> = Vec::new();+ fn walk(n: &Value, d: u8, rows: &mut Vec<String>) {+ if d > 10 { return; }+ if let Some(o) = n.as_object() {+ let nm = o.get("name").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();+ if nm.len() > 1 && nm.len() < 80 { rows.push(nm); }+ for c in o.get("children").and_then(|c| c.as_array()).cloned().unwrap_or_default() { walk(&c, d + 1, rows); }+ }+ }+ walk(&tree["root"], 0, &mut rows);+ const CHROME: &[&str] = &["Library Manager", "System", "Close", "Electronics Desktop Widget", "Hub Libraries", "Public Libraries", "Private Libraries", "Select an item to see details here"];+ let entries: Vec<&String> = rows.iter().filter(|r| !CHROME.contains(&r.as_str())).collect();+ json!({"success": true, "tab": label, "hwnd": dlg["hwnd"], "steps": steps, "rows": rows.iter().take(200).collect::<Vec<_>>(), "libraries": entries.iter().take(200).collect::<Vec<_>>(), "tableReadable": !entries.is_empty(), "nodeCount": tree["nodeCount"],+ "screenshotVerb": format!("desktop_screenshot_window {{hwnd: {}}}", dlg["hwnd"]),+ "_hint": "Hub Libraries = libraries in the Fusion hub (what our bind chain produces, and what Autodesk recommends for sharing). Private Libraries = library.io managed libraries in folders THIS account belongs to. Public Libraries = library.io's public catalogue. NOTE: the library TABLE is CEF-drawn and invisible to UIA, so libraries[] is normally EMPTY and that is not a failure - screenshot the hwnd to read the rows. Close it with fusion_close_window when done."})+}++/// `fusion_managed_library_create`: publish the library in front as a library.io managed library.+pub fn create(ctx: &Ctx, _args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (ok, doc, ws) = library_in_front(pass);+ if !ok { return not_a_library(&doc, &ws); }+ let hub = hub_check(pass, &doc, true);+ if hub["inHub"].as_bool() != Some(true) {+ return json!({"success": false, "errorCode": "not_in_hub", "openDocuments": hub["open"], "error": format!("Library {} is not saved in a Fusion Hub, so Fusion cannot make it a managed library.", fusion_lbr::py_repr_str(hub["name"].as_str().unwrap_or(&doc))), "remedyVerb": "fusion_save_to_cloud",+ "_hint": "fusion_save_to_cloud {name, projectName, folderPath} on this library first; a real library save returns a fileId ending in .flbr (a .f3d means the WRONG document was in front)."});+ }+ let Some(win) = fusion_main_window(ctx) else { return json!({"success": false, "errorCode": "no_fusion_window", "error": "Could not locate Fusion's main window to drive the ribbon."}) };+ let mut steps = Vec::new();+ if let Some(e) = libio_menu(ctx, &win, "push", "Push changes to library.io", &mut steps) { return e; }+ let Some(dlg) = wait_for_top_window(ctx, "Create Managed Library", 25.0) else {+ return json!({"success": false, "errorCode": "create_dialog_missing", "steps": steps, "alerts": alerts(ctx, 120.0), "error": "The LIBRARY.IO menu opened but Create Managed Library never appeared.", "windowRect": win["rect"],+ "_hint": "Push offers Create only for a library that is NOT yet linked. If this library is already linked, fusion_managed_library_push pushes a new version instead - check fusion_alerts."});+ };+ let ok = ab_call(ctx, "desktop_ui_click", json!({"hwnd": dlg["hwnd"], "name": "Create", "reason": "create the managed library on library.io"}), 30);+ steps.push(json!({"step": "create", "ok": ok["success"].as_bool() == Some(true)}));+ if let Some(conf) = wait_for_dialog(ctx, "Create Managed Library", 40.0) {+ let _ = ab_call(ctx, "desktop_ui_click", json!({"hwnd": conf["hwnd"], "name": "OK", "reason": "acknowledge the managed-library confirmation"}), 30);+ steps.push(json!({"step": "ok"}));+ }+ let al = alerts(ctx, 120.0);+ json!({"success": al.is_empty(), "library": hub["name"].as_str().unwrap_or(&doc), "steps": steps, "alerts": al,+ "_hint": if al.is_empty() { "Managed library created; it lives under the account's managed FOLDER on library.io (not public - a public search will not find it). Share it by managing that folder on library.io." } else { "Fusion raised an alert during the push - read alerts[] and treat this as failed." }})+}++/// `fusion_managed_library_link`: link to an EXISTING managed library; without `name`, list the+/// picker and cancel.+pub fn link(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (doc, _ws, win) = match precheck(ctx, pass, false) { Ok(c) => c, Err(e) => return e };+ let mut steps = Vec::new();+ let mark = alert_watermark(ctx);+ if let Some(e) = libio_menu(ctx, &win, "link", "Link to a managed library on library.io", &mut steps) { return e; }+ let Some(dlg) = wait_for_top_window(ctx, "Select Managed Library", 25.0) else {+ return json!({"success": false, "errorCode": "picker_missing", "steps": steps, "alerts": alerts(ctx, 120.0), "error": "The LIBRARY.IO menu opened but Select Managed Library never appeared.",+ "_hint": "Fusion greys out Link on a library that is already linked - call fusion_managed_library_unlink first, or check alerts[]."});+ };+ let hwnd = dlg["hwnd"].clone();+ let (rows, read_err) = picker_rows(ctx, &hwnd);+ let want = args["name"].as_str().unwrap_or("").trim().to_string();+ let close = |why: &str| { let _ = ab_call(ctx, "desktop_ui_click", json!({"hwnd": hwnd, "name": "Cancel", "reason": why}), 30); };+ if want.is_empty() {+ close("close the Select Managed Library picker after listing it read-only");+ return json!({"success": true, "listedOnly": true, "steps": steps, "managedLibraries": rows, "count": rows.len(), "readError": read_err,+ "_hint": "Pass name:<one of these> to link. This is what THIS Autodesk account can reach: library.io's public managed folders plus any managed folder this account belongs to. A managed library owned by another account does NOT appear until its managed folder is shared with this one. Qt virtualizes the list, so only realized rows are returned - scroll or filter in the dialog if the one you want is missing."});+ }+ let hit = rows.iter().find(|r| r["name"].as_str().unwrap_or("").to_lowercase() == want.to_lowercase()).cloned();+ let at = hit.as_ref().map(|h| h["_at"].clone()).unwrap_or(Value::Null);+ let (Some(hit), Some(ax), Some(ay)) = (hit, at[0].as_f64(), at[1].as_f64()) else {+ close("close the picker; the requested managed library was not listed");+ return json!({"success": false, "errorCode": "not_listed", "steps": steps, "managedLibraries": rows, "count": rows.len(), "error": format!("{} is not in this account's managed-library list.", fusion_lbr::py_repr_str(&want)),+ "_hint": "Names are exact. If you expect a library shared from another Autodesk account, it only appears once its MANAGED FOLDER is shared with this account on library.io - being in the same Fusion hub is not enough."});+ };+ let _ = ab_call(ctx, "desktop_click", json!({"x": ax as i64, "y": ay as i64, "cursor": false, "reason": format!("select {want} in the Select Managed Library picker")}), 25);+ sleep_s(1.0);+ let ok = ab_call(ctx, "desktop_ui_click", json!({"hwnd": hwnd, "name": "OK", "reason": "confirm the managed-library link"}), 30);+ steps.push(json!({"step": "confirm", "row": hit["name"], "clicked": find_key(&ok, "success", 0).and_then(|v| v.as_bool()).unwrap_or(false)}));+ sleep_s(2.0);+ let gone = wait_for_top_window_gone(ctx, "Select Managed Library", 15.0);+ let prompt = handle_update_prompt(ctx, args["onUpdatePrompt"].as_str().unwrap_or("cancel"), 12.0);+ if let Some(p) = &prompt { steps.push(p.clone()); }+ let al = alerts_since(ctx, &mark);+ let choice = prompt.as_ref().and_then(|p| p["choice"].as_str()).map(|c| fusion_lbr::py_repr_str(c)).unwrap_or("'not shown'".into());+ json!({"success": gone, "linkedTo": hit["name"], "library": doc, "managedFolder": hit["managedFolder"], "version": hit["version"], "steps": steps, "alerts": al, "dialogClosed": gone, "updatePrompt": prompt,+ "_hint": format!("Linked. Fusion offered to force-update this library from the managed one and that prompt was answered {choice} - pass onUpdatePrompt:'merge' to take the managed library's parts, or 'replace' to overwrite yours entirely.")})+}++/// `fusion_managed_library_pull`: the newest version FROM library.io into this linked library.+pub fn pull(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (doc, _ws, win) = match precheck(ctx, pass, false) { Ok(c) => c, Err(e) => return e };+ let mut steps = Vec::new();+ let mark = alert_watermark(ctx);+ if let Some(e) = libio_menu(ctx, &win, "pull", "Pull changes from the managed library on library.io", &mut steps) { return e; }+ let prompt = handle_update_prompt(ctx, args["onUpdatePrompt"].as_str().unwrap_or("merge"), 12.0);+ if let Some(p) = &prompt { steps.push(p.clone()); }+ let up_to_date = if prompt.is_none() { let u = dismiss_ok_dialog(ctx, "Up to Date", 10.0); if let Some(x) = &u { steps.push(x.clone()); } u } else { None };+ let al = alerts_since(ctx, &mark);+ let answered = prompt.is_some() || up_to_date.is_some();+ let hint = if answered && al.is_empty() { if up_to_date.is_some() { "Already current - Fusion answered with Up to Date.".to_string() } else { format!("Pulled; the force-update prompt was answered {}.", prompt.as_ref().and_then(|p| p["choice"].as_str()).map(|c| fusion_lbr::py_repr_str(c)).unwrap_or("None".into())) } }+ else if !al.is_empty() { "Fusion raised an alert - read alerts[].".to_string() } else { "Neither the update prompt nor Up to Date appeared, so NOTHING was pulled. Fusion greys Pull out on a library that was never linked - link it first.".to_string() };+ json!({"success": answered && al.is_empty(), "library": doc, "steps": steps, "alerts": al, "upToDate": up_to_date.is_some(), "updatePrompt": prompt, "_hint": hint})+}++/// `fusion_managed_library_push`: a NEW VERSION of an already-linked managed library.+pub fn push(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (doc, _ws, win) = match precheck(ctx, pass, true) { Ok(c) => c, Err(e) => return e };+ let mut steps = Vec::new();+ let mark = alert_watermark(ctx);+ if let Some(e) = libio_menu(ctx, &win, "push", "Push changes to library.io", &mut steps) { return e; }+ if wait_for_top_window(ctx, "Create Managed Library", 6.0).is_some() {+ return json!({"success": false, "errorCode": "not_linked_yet", "steps": steps, "dialog": "Create Managed Library",+ "_hint": "This library is not linked yet - Fusion offered Create Managed Library. Call fusion_managed_library_create instead (it completes that dialog), or fusion_managed_library_link to attach it to an existing one. The dialog is still open; answer or close it before the next verb."});+ }+ let confirm = confirm_create_version(ctx, args["onConfirm"].as_str().unwrap_or("create"), 20.0);+ if let Some(c) = &confirm { steps.push(c.clone()); }+ let al = alerts_since(ctx, &mark);+ let Some(confirm) = confirm else {+ return json!({"success": false, "errorCode": "confirm_missing", "steps": steps, "alerts": al, "library": doc, "error": "Push did not raise the Create New Version confirmation.",+ "_hint": "Nothing was pushed. Check that this library is linked to a managed library (fusion_managed_library_link) and read alerts[]."});+ };+ let ok = confirm["dialogClosed"].as_bool() == Some(true) && al.is_empty();+ json!({"success": ok, "library": doc, "steps": steps, "alerts": al, "confirmed": confirm,+ "_hint": if ok { "A new version was pushed to the managed library. Library Manager still shows the IMPORTED version number until you pull, so the row gains an update-available badge rather than incrementing." } else { "The confirmation did not close or Fusion alerted - treat this as NOT pushed and read alerts[]." }})+}++/// `fusion_managed_library_unlink`: unlink this library from its managed library.+pub fn unlink(ctx: &Ctx, _args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (doc, _ws, win) = match precheck(ctx, pass, false) { Ok(c) => c, Err(e) => return e };+ let mut steps = Vec::new();+ let mark = alert_watermark(ctx);+ if let Some(e) = libio_menu(ctx, &win, "unlink", "Unlink from the managed library on library.io", &mut steps) { return e; }+ let al = alerts_since(ctx, &mark);+ json!({"success": al.is_empty(), "library": doc, "steps": steps, "alerts": al,+ "_hint": if al.is_empty() { "Unlink requested. Fusion greys this item out on a library that was never linked, so a silent no-op there is expected." } else { "Fusion raised an alert - read alerts[] and treat this as failed." }})+}
rust/bridge/src/mcp.rsadded+132@@ -0,0 +1,132 @@+//! `fusion_mcp_*`: Autodesk's own Fusion MCP server on 127.0.0.1:27182 (Streamable HTTP).+//! The bridge is a plain JSON-RPC client of it; `mcp_enable` flips the Preferences toggle+//! Autodesk exposes no API for.+use crate::addin::Ctx;+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::Duration;++pub const MCP_URL: &str = "http://127.0.0.1:27182/mcp";+static SESSION_ID: Mutex<Option<String>> = Mutex::new(None);+static RPC_LOCK: Mutex<()> = Mutex::new(());++const OFF_STATIC: &str = "Fusion's MCP server is not reachable on 127.0.0.1:27182. It is OFF by default. The bridge can turn it on FOR the user: call fusion_mcp_enable, which opens Preferences, expands General, selects API, ticks 'Fusion MCP Server (runs locally on this device)' and clicks Apply/OK. It needs Fusion running and briefly uses the foreground (announced with an on-screen caption). Manual path if you prefer: profile icon (top right) -> Preferences -> General -> API -> tick the box. NOTE the setting does NOT survive if Fusion is killed before Apply is clicked.";++/// `{status, headers, body}` / `{httpError, body}` / `{error}` for one POST.+fn post(body: &Value, sid: Option<&str>, timeout: u64) -> Value {+ let mut req = ureq::post(MCP_URL).timeout(Duration::from_secs(timeout)).set("Content-Type", "application/json").set("Accept", "application/json, text/event-stream");+ if let Some(s) = sid { req = req.set("MCP-Session-Id", s); }+ match req.send_string(&body.to_string()) {+ Ok(r) => { let sid = r.header("mcp-session-id").map(str::to_string); let status = r.status(); let text = r.into_string().unwrap_or_default(); json!({"status": status, "sessionId": sid, "body": text}) }+ Err(ureq::Error::Status(code, r)) => { let text = r.into_string().unwrap_or_default(); json!({"httpError": code, "body": text.chars().take(1000).collect::<String>()}) }+ Err(e) => json!({"error": format!("{}", e).chars().take(220).collect::<String>()}),+ }+}++/// initialize + notifications/initialized: `{sessionId, serverInfo}` or `{error}`.+pub fn new_session(version: &str) -> Value {+ let r = post(&json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "fusion-bridge", "version": version}}}), None, 30);+ if !r["error"].is_null() || !r["httpError"].is_null() {+ return json!({"error": if r["error"].is_null() { format!("HTTP {}", r["httpError"]) } else { r["error"].as_str().unwrap_or("").to_string() }, "raw": r["body"]});+ }+ let sid = r["sessionId"].as_str().map(str::to_string);+ let info = serde_json::from_str::<Value>(r["body"].as_str().unwrap_or("{}")).map(|v| v["result"]["serverInfo"].clone()).unwrap_or(json!({}));+ if let Some(s) = &sid { let _ = post(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), Some(s), 30); *SESSION_ID.lock().unwrap() = Some(s.clone()); }+ json!({"sessionId": sid, "serverInfo": if info.is_null() { json!({}) } else { info }})+}++/// One JSON-RPC call with automatic session (re)establishment; `{_mcpError}` on failure.+pub fn rpc(version: &str, method: &str, params: Option<Value>, timeout: u64) -> Value {+ let sid = {+ let _g = RPC_LOCK.lock().unwrap();+ if SESSION_ID.lock().unwrap().is_none() { let s = new_session(version); if !s["error"].is_null() { return json!({"_mcpError": s["error"]}); } }+ SESSION_ID.lock().unwrap().clone()+ };+ let mut body = json!({"jsonrpc": "2.0", "id": 7, "method": method});+ if let Some(p) = params { body["params"] = p; }+ let mut r = post(&body, sid.as_deref(), timeout);+ if matches!(r["httpError"].as_u64(), Some(400) | Some(404)) && r["body"].as_str().unwrap_or("").contains("ession") {+ let sid2 = { let _g = RPC_LOCK.lock().unwrap(); *SESSION_ID.lock().unwrap() = None; let s = new_session(version); if !s["error"].is_null() { return json!({"_mcpError": s["error"]}); } SESSION_ID.lock().unwrap().clone() };+ r = post(&body, sid2.as_deref(), timeout);+ }+ if !r["error"].is_null() || !r["httpError"].is_null() {+ return json!({"_mcpError": if r["error"].is_null() { format!("HTTP {}: {}", r["httpError"], r["body"].as_str().unwrap_or("").chars().take(200).collect::<String>()) } else { r["error"].as_str().unwrap_or("").to_string() }});+ }+ serde_json::from_str::<Value>(r["body"].as_str().unwrap_or("{}")).unwrap_or_else(|_| json!({"_mcpError": "unparseable response", "raw": r["body"].as_str().unwrap_or("").chars().take(400).collect::<String>()}))+}++/// The MCP-unreachable hint, state-aware: never send the AI to enable MCP when APS is signed+/// in and could serve a file search right now. `aps` = (configured, signedIn).+pub fn off_hint(aps: (bool, bool)) -> String {+ let lead = if aps.1 { "NOTE FIRST: APS is signed in, so for FILE SEARCH you do not need MCP at all - fusion_aps_search works right now. Only enable MCP if you need its other surface (script text-to-CAD, screenshots, electronics object-model reads). " }+ else if aps.0 { "NOTE: for FILE SEARCH, APS is configured and only needs fusion_aps_signin (silent on a warm SSO session) - that may be less disruptive than enabling MCP. " } else { "" };+ format!("{lead}{OFF_STATIC}")+}++pub fn status(ctx: &Ctx, version: &str, aps: (bool, bool)) -> Value {+ let s = new_session(version);+ if !s["error"].is_null() {+ return json!({"success": true, "enabled": false, "url": MCP_URL, "error": s["error"], "lastEnabledAt": crate::small_verbs::get_pref(&ctx.state_dir, "mcpLastEnabledAt").unwrap_or(Value::Null),+ "fusionRunning": !ctx.host.fusion_processes().is_empty(), "_hint": off_hint(aps), "statusVerb": "fusion_mcp_status"});+ }+ let tl = rpc(version, "tools/list", None, 60);+ let tools: Vec<Value> = if tl["_mcpError"].is_null() { tl["result"]["tools"].as_array().cloned().unwrap_or_default().iter().map(|t| json!({"name": t["name"], "description": t["description"].as_str().unwrap_or("").split('\n').next().unwrap_or("").chars().take(160).collect::<String>()})).collect() } else { vec![] };+ crate::small_verbs::set_pref(&ctx.state_dir, "mcpLastEnabledAt", json!(chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()));+ json!({"success": true, "enabled": true, "url": MCP_URL, "sessionId": s["sessionId"], "serverInfo": s["serverInfo"], "toolCount": tools.len(), "tools": tools,+ "_hint": "Autodesk's Fusion MCP server is LIVE. Its tools are Autodesk's own, not this bridge's: call them with fusion_mcp_call {tool, arguments}. Use fusion_mcp_tools for full input schemas and fusion_mcp_resources for the Electronics entity schemas. These COMPLEMENT the fusion_* verbs: prefer a native verb when one exists (it is tested and returns richer hints), and reach for MCP for Autodesk's text-to-CAD surface.", "statusVerb": "fusion_mcp_status"})+}++pub fn tools(version: &str, aps: (bool, bool)) -> Value {+ let tl = rpc(version, "tools/list", None, 60);+ if !tl["_mcpError"].is_null() { return json!({"success": false, "enabled": false, "error": tl["_mcpError"], "_hint": off_hint(aps)}); }+ let tools = tl["result"]["tools"].as_array().cloned().unwrap_or_default();+ json!({"success": true, "count": tools.len(), "tools": tools, "_hint": "Call one with fusion_mcp_call {\"tool\":\"<name>\",\"arguments\":{...}}.", "statusVerb": "fusion_mcp_status"})+}++pub fn call(version: &str, args: &Value, aps: (bool, bool)) -> Value {+ let Some(tool) = args["tool"].as_str().filter(|s| !s.is_empty()) else { return json!({"success": false, "error": "Pass {tool, arguments}.", "_hint": "fusion_mcp_tools lists the available tools and their input schemas."}) };+ let arguments = if args["arguments"].is_object() { args["arguments"].clone() } else { json!({}) };+ let r = rpc(version, "tools/call", Some(json!({"name": tool, "arguments": arguments})), args["timeout"].as_u64().filter(|t| *t > 0).unwrap_or(120));+ if !r["_mcpError"].is_null() { return json!({"success": false, "error": r["_mcpError"], "_hint": off_hint(aps)}); }+ let res = if r["result"].is_object() { r["result"].clone() } else { json!({}) };+ json!({"success": res["isError"].as_bool() != Some(true), "tool": tool, "result": res,+ "_hint": "Autodesk MCP tool result. Content is usually a list of {type,text} blocks. If it complains about no active document, open one first (fusion_aps_open) - MCP acts on the ACTIVE Fusion document.", "statusVerb": "fusion_mcp_status"})+}++pub fn resources(version: &str, args: &Value, aps: (bool, bool)) -> Value {+ if let Some(uri) = args["uri"].as_str().filter(|s| !s.is_empty()) {+ let r = rpc(version, "resources/read", Some(json!({"uri": uri})), 60);+ if !r["_mcpError"].is_null() { return json!({"success": false, "error": r["_mcpError"], "_hint": off_hint(aps)}); }+ return json!({"success": true, "uri": uri, "result": r["result"], "statusVerb": "fusion_mcp_status"});+ }+ let r = rpc(version, "resources/list", None, 60);+ if !r["_mcpError"].is_null() { return json!({"success": false, "error": r["_mcpError"], "_hint": off_hint(aps)}); }+ let res = r["result"]["resources"].as_array().cloned().unwrap_or_default();+ json!({"success": true, "count": res.len(), "resources": res.iter().map(|x| x["uri"].clone()).collect::<Vec<_>>(), "_hint": "Read one with fusion_mcp_resources {\"uri\":\"resource://...\"}.", "statusVerb": "fusion_mcp_status"})+}++/// Turn Autodesk's MCP server ON by driving Fusion's Preferences dialog (no API exists for the+/// toggle). The section click is image-space and takes the foreground, announced.+pub fn enable(ctx: &Ctx, version: &str, aps: (bool, bool), pass: &dyn Fn(&str, Value) -> Value) -> Value {+ if ctx.host.fusion_processes().is_empty() { return json!({"success": false, "error": "Fusion is not running.", "_hint": "fusion_start first, then fusion_mcp_enable."}); }+ if new_session(version)["error"].is_null() { return json!({"success": true, "enabled": true, "alreadyOn": true, "_hint": "Already enabled; nothing to do. fusion_mcp_status shows the tools."}); }+ let opened = crate::maintenance::prefs_open(ctx, &json!({"section": "api"}), pass);+ let mut steps: Vec<Value> = opened["steps"].as_array().cloned().unwrap_or_default();+ if opened["success"].as_bool() != Some(true) { return json!({"success": false, "error": opened["error"], "steps": steps, "_hint": off_hint(aps)}); }+ let Some(ab) = fusion_core::ab::Loopback::from_env() else { return json!({"success": false, "error": "ab loopback unavailable", "steps": steps}) };+ use fusion_core::ab::AbClient;+ let (hwnd, w, h) = (opened["hwnd"].as_i64().unwrap_or(0), opened["imageWidth"].as_f64().unwrap_or(1400.0), opened["imageHeight"].as_f64().unwrap_or(836.0));+ let _ = ab.call("desktop_click", json!({"space": "image", "shotId": opened["shotId"], "hwnd": hwnd, "x": (w * 0.493) as i64, "y": (h * 0.569) as i64}), &ctx.caller, 30);+ steps.push(json!("ticked 'Fusion MCP Server'"));+ std::thread::sleep(Duration::from_secs(1));+ let closed = crate::maintenance::prefs_close(ctx, &json!({"save": true}));+ steps.push(json!("clicked Apply then OK"));+ if closed["closed"].as_bool() != Some(true) { steps.push(json!("Preferences still open after Apply/OK")); }+ std::thread::sleep(Duration::from_secs(4));+ let s = new_session(version);+ let ok = s["error"].is_null();+ json!({"success": ok, "enabled": ok, "steps": steps, "sessionId": s["sessionId"], "serverInfo": s["serverInfo"],+ "narrate": if ok { "Fusion's MCP server is on. I enabled it for you in Preferences." } else { "I drove the Preferences dialog but the MCP port is still closed." },+ "_hint": if ok { "MCP server ENABLED - fusion_mcp_status lists its tools. Tell the user it is handled; they do not need to touch Preferences." } else { "The toggle did not take. Re-call fusion_mcp_enable - driving Preferences is racy on a busy main thread and a second pass usually lands it. Do NOT hand the user a Preferences walkthrough (describe.py says NEVER ask them to do this by hand); if a second pass also fails, report what the steps[] show and that the setting is discarded when Fusion restarts before Apply." },+ "statusVerb": "fusion_mcp_status"})+}
rust/bridge/src/mint.rsadded+472@@ -0,0 +1,472 @@+//! The 3D mint and the multi-part bind, native. `make_3d_package` runs the same Fusion-side+//! script server.py builds (the template is derived from a Python-generated golden and a test+//! pins it byte for byte), through the add-in pass-through; the cloud folder discovery and the+//! APS upload fallback use the exe's own APS client. `build_library_3d` loops the mint per part,+//! injects every binding in one pass (the lbr crate) and opens the bound library once.++use serde_json::{json, Value};+use std::time::{Duration, Instant};++use crate::addin::Ctx;++const TEMPLATE: &str = include_str!("make3d_template.py");+const ORIENT_BLOCK: &str = include_str!("make3d_orient_block.py");+pub const DEFAULT_UPLOAD_PROJECT: &str = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE";+pub const KNOWN_ROOT_FOLDER: &str = "urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg";+pub const AI_WORKSPACE_NAME: &str = "Adom AI Workspace";++fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++/// Python's float repr for the values this script carries (shortest round trip, ".0" on integers).+fn py_float(v: f64) -> String { let s = format!("{v}"); if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") { s } else { format!("{s}.0") } }++/// The exact script `_orchestrate_make_3d_package` sends to the add-in.+pub fn make_3d_script(model_path: &str, orient: bool, pin1: Option<(f64, f64)>, layers_off: &[String], task: &str, name: &str) -> String {+ let mut layers: Vec<String> = layers_off.iter().cloned().collect::<std::collections::BTreeSet<_>>().into_iter().collect();+ layers.sort();+ let layers_repr = format!("[{}]", layers.iter().map(|l| fusion_lbr::py_repr_str(l)).collect::<Vec<_>>().join(", "));+ let (pin1_repr, pad1_repr) = match pin1 { Some((x, y)) => (format!("({}, {})", py_float(x / 10.0), py_float(y / 10.0)), format!("[{},{}]", py_float(x), py_float(y))), None => ("None".to_string(), "None".to_string()) };+ TEMPLATE.replace("{MODEL_PATH}", model_path).replace("{DEFAULT_PROJECT}", DEFAULT_UPLOAD_PROJECT).replace("{TASK}", task).replace("{NAME}", name)+ .replace("{PIN1}", &pin1_repr).replace("{PAD1}", &pad1_repr).replace("{LAYERS_OFF}", &layers_repr).replace("{ORIENT_BLOCK}", if orient { ORIENT_BLOCK } else { "" })+}++/// `_pad1_xy_mm`: (x, y) of pad or smd "1" in the FIRST package of the .lbr.+pub fn pad1_xy_mm(lbr_path: &str) -> Option<(f64, f64)> {+ let t = std::fs::read_to_string(lbr_path).ok()?;+ let body = regex::Regex::new(r#"(?s)<package name="[^"]*">(.*?)</package>"#).unwrap().captures(&t).map(|c| c[1].to_string()).unwrap_or(t.clone());+ let c = regex::Regex::new(r#"<(?:smd|pad) name="1" x="([-0-9.]+)" y="([-0-9.]+)""#).unwrap().captures(&body)?;+ Some((c[1].parse().ok()?, c[2].parse().ok()?))+}++fn safe_folder_name(n: &str) -> String { let s: String = regex::Regex::new(r#"[<>:"/\\|?*]"#).unwrap().replace_all(n, "").trim().chars().take(60).collect(); if s.is_empty() { "task".into() } else { s } }++fn find_child_folder(project_id: &str, parent_id: &str, name: &str) -> Option<String> {+ let r = crate::native_aps_browse(&json!({"projectId": project_id, "folderId": parent_id}));+ r["data"]["items"].as_array()?.iter().find(|it| it["type"].as_str() == Some("folders") && it["name"].as_str().map(|n| n.trim() == name).unwrap_or(false)).and_then(|it| it["id"].as_str().map(str::to_string))+}++/// `_ensure_workspace_folder`: a folder inside "Adom AI Workspace", never a project root.+fn ensure_workspace_folder(project_id: &str, task: Option<&str>) -> Option<String> {+ let task = task.map(safe_folder_name);+ let ws = find_child_folder(project_id, KNOWN_ROOT_FOLDER, AI_WORKSPACE_NAME).or_else(|| { let cr = crate::native_aps_create_folder(&json!({"projectId": project_id, "parentFolderId": KNOWN_ROOT_FOLDER, "name": AI_WORKSPACE_NAME})); if cr["success"].as_bool() == Some(true) { cr["data"]["folderId"].as_str().map(str::to_string) } else { None } });+ let mut folder = ws.clone();+ if let (Some(t), Some(w)) = (task, ws) {+ let sub = find_child_folder(project_id, &w, &t).or_else(|| { let cr = crate::native_aps_create_folder(&json!({"projectId": project_id, "parentFolderId": w, "name": t})); if cr["success"].as_bool() == Some(true) { cr["data"]["folderId"].as_str().map(str::to_string) } else { None } });+ folder = sub.or(Some(w));+ }+ folder+}++/// `_discover_upload_target`: (projectId, folderId) - always an AI-owned non-root folder.+pub fn discover_upload_target_pub(args: &Value) -> (String, Option<String>) { discover_upload_target(args) }+fn discover_upload_target(args: &Value) -> (String, Option<String>) {+ let project_id = args["projectId"].as_str().filter(|s| !s.is_empty()).unwrap_or(DEFAULT_UPLOAD_PROJECT).to_string();+ let mut fid = args["folderId"].as_str().filter(|s| !s.is_empty()).map(str::to_string);+ if fid.as_deref() == Some(KNOWN_ROOT_FOLDER) { fid = None; }+ if fid.is_none() { fid = ensure_workspace_folder(&project_id, args["task"].as_str()); }+ (project_id, fid)+}++fn step_story_from_file(model_path: &str) -> String {+ let Ok(bytes) = std::fs::read(model_path) else { return String::new() };+ let head = String::from_utf8_lossy(&bytes[..bytes.len().min(4 * 1024 * 1024)]);+ let words = [("Adom.HeatsinkVias", "the heatsink via barrels under the exposed pad"), ("Adom.SolderBalls", "solder-ball hemispheres on every pad"), ("Adom.LaserEtch", "the laser-etched part number"), ("Adom.Board", "board stock"), ("Adom.Pads", "rendered pads")];+ let got: Vec<&str> = words.iter().filter(|(g, _)| head.contains(g)).map(|(_, w)| *w).collect();+ if got.is_empty() { "chip body only (the baseline model)".into() } else { format!("unlike the chip-only model this STEP carries {}, kept visible", got.join(" and ")) }+}++pub fn step_layer_story(bodies: &Value) -> String {+ let mut groups: std::collections::BTreeMap<String, usize> = Default::default();+ for b in bodies.as_array().cloned().unwrap_or_default() { let g = b.as_str().unwrap_or("").split('/').next().unwrap_or("").to_string(); if g.starts_with("Adom.") && g != "Adom.Chip" { *groups.entry(g).or_insert(0) += 1; } }+ if groups.is_empty() { return "chip body only (the baseline model)".into(); }+ let words = |g: &str| match g { "Adom.HeatsinkVias" => "heatsink via barrels under the exposed pad".to_string(), "Adom.SolderBalls" => "solder-ball hemispheres sitting on the pads".into(), "Adom.LaserEtch" => "the laser-etched part number".into(), "Adom.Board" => "board stock".into(), "Adom.Pads" => "rendered pads".into(), o => o[5..].to_string() };+ format!("unlike the chip-only model, this one carries {}, all left visible", groups.iter().map(|(g, n)| format!("{n} {}", words(g))).collect::<Vec<_>>().join(" and "))+}++/// `fusion_make_3d_package`: a RENDERING footprint+chip 3D package, no GUI dialogs.+pub fn make_3d_package(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let lbr_path = args["lbrPath"].as_str().or(args["filePath"].as_str()).unwrap_or("").replace('\\', "/");+ let model_path = args["modelPath"].as_str().unwrap_or("").replace('\\', "/");+ let (project_id, folder_id) = discover_upload_target(args);+ if lbr_path.is_empty() || model_path.is_empty() { return json!({"success": false, "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}); }+ let cap_label = args["captureLabel"].as_str().filter(|s| !s.is_empty()).map(str::to_string);+ let task = safe_folder_name(args["task"].as_str().unwrap_or("AI 3D packages"));+ let orient = args["orient"].as_bool().unwrap_or(true);+ let base = model_path.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();+ let name = args["fileName"].as_str().filter(|s| !s.is_empty()).map(str::to_string).unwrap_or_else(|| format!("{base}_3d")).replace('\'', "").replace(".f3d", "");+ crate::chains::note_bridge_doc_pub(&ctx.state_dir, &name);+ let f3d_name = format!("{name}.f3d");+ let op = crate::orchestrations::open_lbr(ctx, &json!({"filePath": lbr_path}), pass);+ if op["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("open_lbr failed: {}", op["error"].as_str().unwrap_or("None"))}); }+ std::thread::sleep(Duration::from_secs(1));+ let cp = crate::orchestrations::create_3d_package_pub(ctx, &lbr_path, pass);+ if cp["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("Create3DPackage failed: {}", cp["error"].as_str().unwrap_or("None")), "_hint": "The .lbr must be adom-lbr-generated; its FIRST package's footprint is loaded into the generator."}); }+ std::thread::sleep(Duration::from_secs(2));+ let expected_pkg = std::fs::read_to_string(&lbr_path).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()).and_then(|n| n.pac.first().cloned()).unwrap_or_default();+ let di = pass("document_info", json!({}));+ let gen_doc = match &di["data"]["activeDocument"] { Value::Object(o) => o.get("name").and_then(|v| v.as_str()).unwrap_or(""), Value::String(s) => s.as_str(), _ => "" }.trim_end_matches('*').trim().to_string();+ if !expected_pkg.is_empty() && !gen_doc.is_empty() && gen_doc.to_lowercase() != expected_pkg.to_lowercase() && !gen_doc.to_lowercase().starts_with("untitled") {+ return json!({"success": false, "errorCode": "wrong_footprint_loaded", "error": format!("The Package3D generator loaded footprint {} but this mint is for package {}; the chip would have landed on the wrong footprint.", fusion_lbr::py_repr_str(&gen_doc), fusion_lbr::py_repr_str(&expected_pkg)),+ "_hint": "Mint each part on a library whose FIRST package is that part's footprint (fusion_install_library_bundle splits a merged library per part for this reason)."});+ }+ let mut before_shot = Value::Null;+ if let Some(l) = &cap_label { let _ = pass("run_modeling_script", json!({"script": "app.activeViewport.fit()\nresult={}"})); std::thread::sleep(Duration::from_millis(400)); before_shot = json!(crate::orchestrations::capture_labeled(ctx, &format!("{l}_before"))); }+ let layers: Vec<String> = args["layersOff"].as_array().map(|a| a.iter().map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).collect()).unwrap_or_default();+ let script = make_3d_script(&model_path, orient, pad1_xy_mm(&lbr_path), &layers, &task, &name);+ let r = pass("run_modeling_script", json!({"script": script}));+ let res = r["data"]["result"].clone();+ let f3d_path = res["path"].as_str().map(str::to_string);+ let after_shot = cap_label.as_ref().map(|l| json!(crate::orchestrations::capture_labeled(ctx, &format!("{l}_after")))).unwrap_or(Value::Null);+ if let Some(p) = &f3d_path {+ if p.contains("dm.lineage:") {+ let lineage = p.rsplit("dm.lineage:").next().unwrap_or("").to_string();+ let wip = format!("urn:adsk.wipprod:fs.file:vf.{lineage}?version=1");+ if lbr_path.contains("0603WAF3300T5E") { let _ = std::fs::write(ctx.state_dir.join("r3d-urn.json"), json!({"0603WAF3300T5E": wip}).to_string()); }+ return json!({"success": true, "wip_urn": wip, "lineageUrn": p, "dims": res["dims"], "pin1": res["pin1"], "bodies": res["bodies"], "renamedTo": res["renamedTo"], "fileName": f3d_name, "savedTo": "cloud (Adom AI Workspace)", "before": before_shot, "after": after_shot,+ "_hint": "Saved DIRECTLY to the cloud workspace - no upload step needed. Bind this wip_urn into the library's <packages3d>."});+ }+ }+ if res["errorCode"].as_str() == Some("no_cloud_project") {+ let detail: serde_json::Map<String, Value> = ["projErr", "projErr2", "createErr"].iter().filter_map(|k| res.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect();+ return json!({"success": false, "errorCode": "no_cloud_project", "error": format!("3D minting uploads an .f3d to Autodesk cloud storage, and this account has no usable cloud project (typical of a brand-new Autodesk account). The bridge searched every hub for any healthy project and also tried to CREATE an 'Adom' project; all failed: {}", if detail.is_empty() { "(no per-step detail)".to_string() } else { crate::fusion_prefs::py_str(&Value::Object(detail)) }),+ "before": before_shot, "after": after_shot, "_hint": "This is account setup, not a broken mint. Open Fusion's Data Panel once (a fresh account provisions its default project on first touch), or create any project in the hub, then re-run. fusion_list_cloud_projects shows what the account can see."});+ }+ let Some(f3d_path) = f3d_path.filter(|p| p.ends_with(".f3d")) else {+ return json!({"success": false, "error": format!("f3d saveAs did not produce a .f3d path: {}{}", crate::fusion_prefs::py_str(&res), r["error"].as_str().filter(|e| !e.is_empty()).map(|e| format!(" | addin: {}", e.chars().take(200).collect::<String>())).unwrap_or_default()), "before": before_shot, "after": after_shot, "_hint": "Confirm modelPath is a valid Windows .step path and Fusion is running."});+ };+ let Some(folder_id) = folder_id else {+ return json!({"success": false, "errorCode": "no_work_folder", "error": format!("In-app cloud save failed ({}) and APS has no 'Adom AI Workspace' folder to upload the local f3d to.", crate::fusion_prefs::py_str(&res).chars().take(120).collect::<String>()), "before": before_shot, "after": after_shot,+ "_hint": "Two independent paths can mint the 3D package: the add-in's direct cloud save (needs Fusion running + signed in; the res/projErr above says why it failed) or the APS upload fallback (needs fusion_aps_signin). Fix either. The bridge never writes to a shared project ROOT."});+ };+ let up = crate::native_aps_upload(&json!({"projectId": project_id, "folderId": folder_id, "localPath": f3d_path, "fileName": f3d_name}));+ let up_d = if up["data"].is_object() { up["data"].clone() } else { up.clone() };+ let mut item_urn = up_d["itemUrn"].as_str().unwrap_or("").to_string();+ let _ = pass("run_modeling_script", json!({"script": "app.activeDocument.close(False)\nresult={}"}));+ if !item_urn.contains("dm.lineage:") {+ if up_d["httpStatus"].as_i64() == Some(409) {+ let store = fusion_core::aps::ApsStore::default_location();+ if let Ok((200, listing)) = fusion_core::aps::aps_get(&store, &format!("/data/v1/projects/{project_id}/folders/{folder_id}/contents"), &[]) {+ let stem = f3d_name.to_lowercase().rsplit_once(".f3d").map(|(s, _)| s.to_string()).unwrap_or_default();+ for it in listing["data"].as_array().cloned().unwrap_or_default() {+ let nm = it["attributes"]["displayName"].as_str().unwrap_or("").to_string(); let iid = it["id"].as_str().unwrap_or("").to_string();+ if iid.contains("dm.lineage:") && nm.to_lowercase().starts_with(&stem) { eprintln!("[fusion-bridge] upload 409: reusing existing cloud f3d {nm:?} -> {}", &iid[iid.len().saturating_sub(40)..]); item_urn = iid; break; }+ }+ }+ }+ if !item_urn.contains("dm.lineage:") { return json!({"success": false, "error": format!("f3d upload did not return a lineage urn: {}", crate::fusion_prefs::py_str(&up_d))}); }+ }+ let lid = item_urn.rsplit("dm.lineage:").next().unwrap_or("").to_string();+ let wip = format!("urn:adsk.wipprod:fs.file:vf.{lid}?version=1");+ json!({"success": true, "wip_urn": wip, "dims_mm": res["dims"], "f3d": f3d_path, "before": before_shot, "after": after_shot,+ "_hint": format!("DONE - a PROPER component 3D package (FOOTPRINT + chip, aligned) created with NO GUI dialogs. NEXT: hand-write this wip_urn into the library's EAGLE XML - a <package3d name=\"PKG\" urn=\"\" wip_urn=\"{wip}\" locally_modified=\"yes\" type=\"model\"> (with <packageinstances><packageinstance name=\"PKG\"/></packageinstances>) inside <packages3d> (between </packages> and <symbols>), PLUS <package3dinstances><package3dinstance package3d_urn=\"{wip}\"/></package3dinstances> inside the device after </connects>. Then fusion_open_lbr the merged .lbr. EXPECT: fusion_check_dialogs == 0 (no broken-ref), the device 3D preview shows footprint + chip (NOT 'Thumbnail download failed'), and opening the f3d shows the chip's pads landing on the footprint pads. SPEEDUP: for a many-part library, call this verb once per part, collect the urns, hand-merge ALL bindings in ONE pass, then fusion_open_lbr ONCE (don't open/save per part). PITFALLS: needs Fusion running (after a bridge_install respawn it can transiently report 'not running' - fusion_start, then retry); a RAW STEP upload binds but renders NOTHING ('Thumbnail download failed') so always go through this verb (it makes an f3d); NEVER FINISH the Package3D generator (Package3DStop) - its Save dialog + two opaque CEF modals are unbeatable, this verb saveAs-es instead. Full recipe: the fusion-multipart-libraries skill.")})+}++fn bare(n: &str) -> String { regex::Regex::new(r"\s*\([^)]*\)\s*$").unwrap().replace(&n.replace('*', ""), "").trim().to_lowercase() }++/// `_close_docs_opened_since`: close every document that appeared since `before` (plus bridge scratch by name).+pub fn close_docs_opened_since(ctx: &Ctx, before: &[String], keep: &[String], pass: &dyn Fn(&str, Value) -> Value) -> Vec<String> {+ let keep: Vec<String> = keep.iter().map(|k| k.to_lowercase()).collect();+ let open_names = |pass: &dyn Fn(&str, Value) -> Value| -> Vec<String> { pass("document_info", json!({}))["data"]["openDocuments"].as_array().map(|a| a.iter().filter_map(|d| d["name"].as_str().map(str::to_string)).filter(|n| !n.is_empty()).collect()).unwrap_or_default() };+ let bridge_owned = |n: &str| { let b = bare(n); b.starts_with("adom-test-") || b.ends_with("_3d") || b == "adomdemosample" || b.starts_with("untitled") };+ let now_open = open_names(pass);+ let mut targets: std::collections::BTreeSet<String> = now_open.iter().filter(|n| !before.contains(n)).cloned().collect();+ for n in &now_open { if bridge_owned(n) { targets.insert(n.clone()); } }+ let deadline = Instant::now() + Duration::from_secs(240);+ while Instant::now() < deadline {+ let jr = pass("run_modeling_script", json!({"script": "import adsk.core\nresult={'jobs': adsk.core.Application.get().hasActiveJobs}"}));+ if jr["success"].as_bool() != Some(true) || jr["data"]["result"]["jobs"].as_bool() != Some(true) { break; }+ std::thread::sleep(Duration::from_secs(5));+ }+ let mut closed = Vec::new();+ let count_named = |pass: &dyn Fn(&str, Value) -> Value, name: &str| -> (usize, usize) { let docs = open_names(pass); (docs.len(), docs.iter().filter(|d| bare(d) == bare(name)).count()) };+ for n in targets {+ if keep.contains(&bare(&n)) { continue; }+ crate::chains::note_bridge_doc_pub(&ctx.state_dir, &n);+ for _ in 0..12 {+ let (total, mine) = count_named(pass, &n);+ if total <= 1 || mine == 0 { break; }+ let r = crate::orchestrations::close_bridge_doc_verified(ctx, &n, 30, pass);+ if r["deferred"].as_bool() == Some(true) { break; }+ let (_, mine2) = count_named(pass, &n);+ if mine2 < mine { closed.push(n.clone()); continue; }+ break;+ }+ }+ let mut last: Option<usize> = None;+ for _ in 0..12 {+ let st = pass("document_info", json!({}));+ let docs = st["data"]["openDocuments"].as_array().cloned().unwrap_or_default();+ if docs.len() <= 1 { break; }+ if let Some(l) = last { if docs.len() >= l { break; } }+ last = Some(docs.len());+ let Some(u) = docs.iter().find(|d| d["name"].as_str().unwrap_or("").starts_with("Untitled") && !(d["isModified"].as_bool().unwrap_or(false) || d["modified"].as_bool().unwrap_or(false))) else { break };+ let nm = u["name"].as_str().unwrap_or("").to_string();+ if pass("close_document", json!({"name": nm, "save": false}))["success"].as_bool() == Some(true) { closed.push(nm); } else { break; }+ }+ closed+}++/// `_build_library_3d_core`: mint every part, inject all bindings in one pass, open once.+pub fn build_library_3d_core(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value, on_part: &dyn Fn(&Value, usize, usize)) -> Value {+ let combined = args["lbrPath"].as_str().unwrap_or("").replace('\\', "/");+ let parts = args["parts"].as_array().cloned().unwrap_or_default();+ if combined.is_empty() || parts.is_empty() { return json!({"success": false, "error": "lbrPath (combined .lbr) and parts[] are required.", "_hint": "parts: [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]. projectId/folderId default to the MAIN-project upload folder."}); }+ let out_path = args["outLbrPath"].as_str().filter(|s| !s.is_empty()).map(|s| s.replace('\\', "/")).unwrap_or_else(|| combined.clone());+ let capture = args["capture"].as_bool().unwrap_or(true);+ let task = args["task"].as_str().filter(|s| !s.is_empty()).map(str::to_string).unwrap_or_else(|| combined.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default());+ let mut targs = args.clone(); targs["task"] = json!(task);+ let (project_id, folder_id) = discover_upload_target(&targs);+ let (mut results, mut bindings, mut shots): (Vec<Value>, Vec<fusion_lbr::Binding>, Vec<Value>) = (vec![], vec![], vec![]);+ let mut cancelled = false;+ let total = parts.len();+ for p in &parts {+ let pkg = p["package"].as_str().unwrap_or("").to_string();+ let per_lbr = p["lbrPath"].as_str().unwrap_or("").replace('\\', "/");+ let step = p["modelPath"].as_str().unwrap_or("").replace('\\', "/");+ if let Some(key) = args["_cancelKey"].as_str() { if crate::chains::cancel_requested_pub(key) { cancelled = true; break; } }+ let docs_before: Vec<String> = pass("document_info", json!({}))["data"]["openDocuments"].as_array().map(|a| a.iter().filter_map(|d| d["name"].as_str().map(str::to_string)).collect()).unwrap_or_default();+ if pkg.is_empty() || per_lbr.is_empty() || step.is_empty() { results.push(json!({"package": pkg, "success": false, "error": "package, lbrPath (per-part .lbr) and modelPath (.step) all required"})); on_part(results.last().unwrap(), results.len(), total); continue; }+ let mk_args = json!({"lbrPath": per_lbr, "modelPath": step, "projectId": project_id, "folderId": folder_id, "fileName": format!("{pkg}_3d"), "task": task, "captureLabel": if capture { json!(pkg) } else { Value::Null }, "orient": p["orient"].as_bool().unwrap_or(true)});+ let mut mk = make_3d_package(ctx, &mk_args, pass);+ if mk["success"].as_bool() != Some(true) { std::thread::sleep(Duration::from_secs(2)); mk = make_3d_package(ctx, &mk_args, pass); }+ let mut entry = json!({"package": pkg, "success": mk["success"].as_bool().unwrap_or(false)});+ if mk["success"].as_bool() == Some(true) {+ entry["wip_urn"] = mk["wip_urn"].clone(); entry["dims_mm"] = mk["dims_mm"].clone(); entry["pin1"] = mk["pin1"].clone(); entry["stepStory"] = json!(step_layer_story(&mk["bodies"]));+ bindings.push(fusion_lbr::Binding { package: pkg.clone(), wip_urn: mk["wip_urn"].as_str().unwrap_or("").to_string() });+ } else { entry["error"] = mk["error"].clone(); }+ if !mk["before"].is_null() { shots.push(json!({"package": pkg, "stage": "before", "path": mk["before"]})); }+ if !mk["after"].is_null() { shots.push(json!({"package": pkg, "stage": "after", "path": mk["after"]})); }+ entry["scratchClosed"] = json!(close_docs_opened_since(ctx, &docs_before, &[], pass));+ results.push(entry);+ on_part(results.last().unwrap(), results.len(), total);+ }+ let mut bound: Option<String> = None;+ if !bindings.is_empty() {+ let src = if std::path::Path::new(&out_path).exists() { out_path.clone() } else { combined.clone() };+ match std::fs::read_to_string(&src).map(|t| fusion_lbr::inject_package3d_bindings(&t, &bindings)).and_then(|t| std::fs::write(&out_path, t)) {+ Ok(_) => bound = Some(out_path.clone()),+ Err(e) => return json!({"success": false, "error": format!("binding injection failed: {e}"), "parts": results, "bindings": bindings, "screenshots": shots}),+ }+ }+ let mut opened = Value::Null;+ if let Some(b) = &bound { if args["openWhenDone"].as_bool().unwrap_or(true) { opened = json!(crate::orchestrations::open_lbr(ctx, &json!({"filePath": b}), pass)["success"].as_bool() == Some(true)); } }+ let n_ok = results.iter().filter(|r| r["success"].as_bool() == Some(true)).count();+ json!({"success": n_ok > 0, "cancelled": cancelled, "boundLbr": bound, "partsBound": n_ok, "partsTotal": total, "parts": results, "screenshots": shots, "opened": opened,+ "_hint": format!("{n_ok}/{total} parts got a RENDERING footprint+chip 3D package; all bindings injected into {} and the library opened once. NEXT: fusion_check_dialogs should be 0 (no broken-ref). For clean device previews + screenshots, save to the cloud and REOPEN from the cloud (fusion_aps_open) - the in-session view often gets an empty 'Untitled' shoved in front. Each device's lower-right 3D preview should show footprint + chip (NOT 'Thumbnail download failed'). The BEFORE (footprint) / AFTER (chip placed) PNGs are in screenshots[] on the Windows box (C:/tmp/conduit-screenshots) - pull them with desktop_pull_file (or re-capture via desktop_screenshot_window) for the demo video. AFTER (recommended): call fusion_capture_library_views per showcase part to grab the symbol / footprint / component (pin<->pad mapped) views - those are what make EEs trust the library, and the 3D-only shots miss them. PITFALLS: needs Fusion running. ⚠️ RELAY TIMEOUT: a many-part run takes minutes but the Adom Bridge relay times out the REQUEST at ~60s - you may get 'Request timed out' even though the build KEEPS RUNNING server-side and finishes. Do NOT assume it failed: wait, then verify by reading the boundLbr (count <package3d name=) + the before/after PNGs in C:/tmp/conduit-screenshots. If a part is missing, just re-run build_library_3d for the missing parts pointing outLbrPath at the SAME file - binding injection is now MERGE-AWARE and idempotent, so it accumulates onto the existing bindings (it no longer wipes the parts that already succeeded). Each part also self-retries once on a transient first-part failure. Full recipe: the fusion-multipart-libraries skill.", bound.as_deref().unwrap_or("None"))})+}++#[cfg(test)]+mod tests {+ #[test]+ fn attach_script_matches_python_golden() {+ let cases: Vec<serde_json::Value> = serde_json::from_str(include_str!("../tests/golden/attach_script.json")).unwrap();+ for c in cases {+ let layers: Vec<String> = c["layersOff"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();+ let got = super::attach_script(c["modelPath"].as_str().unwrap(), c["orient"].as_bool().unwrap(), &layers);+ assert_eq!(got, c["script"].as_str().unwrap(), "attach script drift");+ }+ }+ #[test]+ fn mint_script_matches_python_golden() {+ let cases: Vec<serde_json::Value> = serde_json::from_str(include_str!("../tests/golden/make3d_script.json")).unwrap();+ for c in cases {+ let pin1 = c["pin1"].as_array().map(|a| (a[0].as_f64().unwrap(), a[1].as_f64().unwrap()));+ let layers: Vec<String> = c["layersOff"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();+ let got = super::make_3d_script(c["modelPath"].as_str().unwrap(), c["orient"].as_bool().unwrap(), pin1, &layers, c["task"].as_str().unwrap(), c["name"].as_str().unwrap());+ let want = c["script"].as_str().unwrap();+ if got != want { let i = got.chars().zip(want.chars()).position(|(a, b)| a != b).unwrap_or(0); panic!("script drift at byte {i}: got {:?} want {:?}", &got[i.saturating_sub(40)..(i + 80).min(got.len())], &want[i.saturating_sub(40)..(i + 80).min(want.len())]); }+ }+ }+}++/// `fusion_build_library_3d`: detached by default (a many-part build outlives ab's relay cap);+/// progress lands in the shared run store and the palette, and the bound file is the proof.+pub fn build_library_3d(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let combined = args["lbrPath"].as_str().unwrap_or("").replace('\\', "/");+ let out_path = args["outLbrPath"].as_str().filter(|s| !s.is_empty()).map(|s| s.replace('\\', "/")).unwrap_or_else(|| combined.clone());+ let parts = args["parts"].as_array().cloned().unwrap_or_default();+ if args["async"].as_bool().unwrap_or(true) && !parts.is_empty() {+ // record the build in flight on disk (the capture guard reads it, respawns included)+ let flight_p = ctx.state_dir.join("builds_in_flight.json");+ let mut flight: Value = std::fs::read_to_string(&flight_p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+ flight[&out_path] = json!(now_f());+ let _ = std::fs::write(&flight_p, flight.to_string());+ let run_id = format!("{:x}", { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); format!("{}{}", now_f(), std::process::id()).hash(&mut h); h.finish() })[..12].to_string();+ let total = parts.len();+ let mut bstate = json!({"runId": run_id, "verb": "fusion_build_library_3d", "stage": "starting", "boundLbr": out_path, "partsTotal": total, "partsDone": 0, "parts": [], "owner": "exe"});+ crate::chains::save_state_pub(&run_id, &bstate);+ // the build outlives this request: a fresh context on a thread (the borrowed one cannot cross)+ let (sd, caller, args2, rid) = (ctx.state_dir.clone(), ctx.caller.clone(), args.clone(), run_id.clone());+ let lib_label = crate::chains::lib_label_pub(&combined);+ std::thread::spawn(move || {+ let host = crate::host_for_diagnosis();+ let side_url = crate::launch::SIDECAR_URL.lock().unwrap().clone();+ let side = move |verb: &str, a: Value, t: u64| -> Value { match &side_url { Some(u) => ureq::post(u).timeout(Duration::from_secs(t)).send_json(json!({"command": verb, "args": a})).ok().and_then(|r| r.into_json().ok()).unwrap_or(json!({"success": false, "error": "sidecar unreachable"})), None => json!({"success": false, "error": "sidecar absent"}) } };+ let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx2 = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids, sidecar: &side, dialogs: &dlg };+ let pass2 = |verb: &str, a: Value| crate::addin::pass_through(verb, &a, &ctx2);+ let st = std::cell::RefCell::new(bstate.clone());+ let on_part = |entry: &Value, i: usize, total: usize| {+ let mut s = st.borrow_mut();+ s["stage"] = json!(format!("minting {} ({i}/{total})", entry["package"].as_str().unwrap_or("part")));+ s["partsDone"] = json!(i);+ let mut ps = s["parts"].as_array().cloned().unwrap_or_default(); ps.push(entry.clone()); s["parts"] = json!(ps);+ crate::chains::save_state_pub(&rid, &s);+ let _ = crate::addin::addin_http("activity_banner", &json!({"action": "update", "title": format!("{lib_label}: binding 3D, part {i} of {total}"), "detail": format!("{}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), "percent": 100.0 * i as f64 / total.max(1) as f64 * 0.4, "etaSec": Value::Null, "tone": "working"}), 20);+ };+ let res = build_library_3d_core(&ctx2, &args2, &pass2, &on_part);+ { let mut s = st.borrow_mut(); s["stage"] = json!("done"); s["success"] = json!(res["success"].as_bool().unwrap_or(false)); s["result"] = res; let n = s["parts"].as_array().map(|a| a.len()).unwrap_or(0); s["partsDone"] = json!(n); bstate = s.clone(); }+ crate::chains::save_state_pub(&rid, &bstate);+ let fp = sd.join("builds_in_flight.json");+ if let Some(mut f) = std::fs::read_to_string(&fp).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { if let Some(o) = f.as_object_mut() { o.remove(bstate["boundLbr"].as_str().unwrap_or("")); } let _ = std::fs::write(&fp, f.to_string()); }+ });+ return json!({"success": true, "status": "started", "async": true, "runId": run_id, "statusVerb": "fusion_prove_result", "boundLbr": out_path, "partsTotal": total, "owner": "exe",+ "_hint": "⏳ 3D bind runs DETACHED (survives ab's 60s relay cap, which used to kill it + lose every wip_urn). POLL the boundLbr via read_file every ~30s until it has one <package3d name=... wip_urn=urn:...> PER PART (count == partsTotal). Do NOT re-fire while running (check fusion_addin_status.busy). When done, embed those <packages3d> + a per-<element> package3d_urn in your .brd, then fusion_open_board + fusion_show_3d_board for real 3D bodies (all background). If a urn stays unresolved in the 3D view, re-run for just that part - its f3d upload failed."});+ }+ let lib_label = crate::chains::lib_label_pub(&combined);+ let on_part = |entry: &Value, i: usize, total: usize| {+ let story = step_story_from_file(entry["modelPath"].as_str().unwrap_or(""));+ let _ = crate::addin::addin_http("activity_banner", &json!({"action": "update", "title": format!("{lib_label}: binding 3D, part {i} of {total}"), "detail": format!("{}. {story}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), "percent": 100.0 * i as f64 / total.max(1) as f64 * 0.4, "etaSec": Value::Null, "tone": "working"}), 20);+ };+ build_library_3d_core(ctx, args, pass, &on_part)+}++/// `fusion_bind_library_3d`: mint one part's 3D, write the binding into the .lbr on disk, reopen.+pub fn bind_library_3d(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let lbr_path = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ let step_path = args["stepPath"].as_str().unwrap_or("").replace('\\', "/");+ if lbr_path.is_empty() || step_path.is_empty() { return json!({"success": false, "error": "libraryPath (.lbr) and stepPath (.step) required (Windows paths)."}); }+ let mut txt = match std::fs::read(&lbr_path) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "error": format!("cannot read {lbr_path}: {e}")}) };+ let deviceset = match args["deviceset"].as_str().filter(|s| !s.is_empty()) { Some(d) => d.to_string(), None => match regex::Regex::new(r#"<deviceset name="([^"]+)""#).unwrap().captures(&txt) { Some(c) => c[1].to_string(), None => return json!({"success": false, "error": "no deviceset in the library"}) } };+ let pkg_name = regex::Regex::new(r#"<package name="([^"]+)""#).unwrap().captures(&txt).map(|c| c[1].to_string()).unwrap_or_else(|| deviceset.clone());+ if let Some(c) = regex::Regex::new(r#"<package3d[^>]*urn="([^"]+)""#).unwrap().captures(&txt) {+ if args["force"].as_bool() != Some(true) {+ return json!({"success": true, "alreadyBound": true, "wip_urn": &c[1], "deviceset": deviceset, "libraryPath": lbr_path, "_hint": "This library already carries a package3d urn - nothing to mint. Pass force:true to re-bind (e.g. after replacing the STEP). fusion_check_3d_thumbnail verifies the cloud thumbnail rendered."});+ }+ }+ let safe_ds: String = regex::Regex::new(r"[^A-Za-z0-9_-]").unwrap().replace_all(&deviceset, "_").chars().take(40).collect();+ let mint = crate::small_verbs::apply_failure_dialogs(make_3d_package(ctx, &json!({"lbrPath": lbr_path, "modelPath": step_path, "fileName": format!("ADOM-{safe_ds}-3D")}), pass), &ctx.caller, &ctx.pids);+ let urn = mint["wip_urn"].as_str().unwrap_or("").to_string();+ if urn.is_empty() { return json!({"success": false, "error": format!("mint failed: {}", mint["error"].as_str().unwrap_or("None").chars().take(150).collect::<String>()), "_hint": mint["_hint"].as_str().unwrap_or("").chars().take(150).collect::<String>()}); }+ let bak = format!("{lbr_path}.prebind");+ if !std::path::Path::new(&bak).exists() { let _ = std::fs::copy(&lbr_path, &bak); }+ txt = regex::Regex::new(r"(?s)<packages3d>.*?</packages3d>\n?").unwrap().replace_all(&txt, "").to_string();+ let binding = format!(r#"<packages3d><package3d name="{deviceset}" urn="" wip_urn="{urn}" locally_modified="yes" type="model"><packageinstances><packageinstance name="{pkg_name}"/></packageinstances></package3d></packages3d>"#);+ txt = txt.replacen("</packages>", &format!("</packages>\n{binding}"), 1);+ txt = regex::Regex::new(r"(?s)<package3dinstances>.*?</package3dinstances>").unwrap().replace_all(&txt, "").to_string();+ txt = txt.replacen("<technologies>", &format!(r#"<package3dinstances><package3dinstance package3d_urn="{urn}"/></package3dinstances><technologies>"#), 1);+ if let Err(e) = std::fs::write(&lbr_path, &txt) { return json!({"success": false, "error": format!("binding write failed: {e}"), "wip_urn": urn}); }+ std::thread::sleep(Duration::from_secs(1));+ let reopened = crate::orchestrations::open_lbr(ctx, &json!({"filePath": lbr_path}), pass);+ if reopened["success"].as_bool() != Some(true) {+ let why: String = reopened["error"].as_str().or(reopened["_hint"].as_str()).unwrap_or("library re-open returned success:false with no detail (open_lbr)").chars().take(220).collect();+ return json!({"success": true, "wip_urn": urn, "libraryPath": lbr_path, "deviceset": deviceset, "bound": true, "reopened": false, "mutatedInput": true, "prebindBackup": bak, "reopenError": why,+ "_hint": format!("Binding minted ({urn}) and WRITTEN to the .lbr - the bind is real and persisted. Re-opening the library into Fusion failed (reopenError above); the prove stage opens the library itself, so the chain continues. If prove's 3D frame is missing, the library just needs a clean re-open once Fusion is idle.")});+ }+ let swept = sweep_untitled_scratch(pass);+ json!({"success": true, "wip_urn": urn, "mutatedInput": true, "untitledScratchClosed": swept.len(), "prebindBackup": bak, "libraryPath": lbr_path, "deviceset": deviceset, "bound": true, "reopened": true,+ "_hint": format!("NOTE: the input .lbr was REWRITTEN with the minted package3d binding (account-scoped; pristine copy kept at <libraryPath>.prebind). Library re-opened WITH the 3D binding. The Content Manager's 3D preview resolves after Autodesk's thumbnail service catches up (~2-3 min for a fresh mint) - EDIT {deviceset}.dev then screenshot to verify the render.")})+}++/// `_sweep_untitled_scratch`: close UNMODIFIED Untitled documents (Fusion keeps one alive).+pub fn sweep_untitled_scratch(pass: &dyn Fn(&str, Value) -> Value) -> Vec<String> {+ let mut closed = Vec::new();+ let mut prev_total: Option<usize> = None;+ for _ in 0..24 {+ let st = pass("document_info", json!({}));+ let docs = st["data"]["openDocuments"].as_array().cloned().unwrap_or_default();+ let untitled: Vec<&Value> = docs.iter().filter(|d| d["name"].as_str().unwrap_or("").to_lowercase().starts_with("untitled") && !(d["isModified"].as_bool().unwrap_or(false) || d["modified"].as_bool().unwrap_or(false))).collect();+ if (untitled.len() <= 1 && docs.len() == untitled.len()) || untitled.is_empty() { break; }+ if let Some(p) = prev_total { if docs.len() >= p { break; } }+ prev_total = Some(docs.len());+ let victim = untitled[0]["name"].as_str().unwrap_or("").to_string();+ if pass("close_document", json!({"name": victim, "save": false}))["success"].as_bool() != Some(true) { break; }+ closed.push(victim);+ }+ closed+}++const ATTACH_TEMPLATE: &str = include_str!("attach_template.py");+const ATTACH_ORIENT_BLOCK: &str = include_str!("attach_orient_block.py");++/// The exact import/orient script `_orchestrate_attach_3d_package` sends.+pub fn attach_script(model_path: &str, orient: bool, layers_off: &[String]) -> String {+ let mut layers: Vec<String> = layers_off.iter().cloned().collect::<std::collections::BTreeSet<_>>().into_iter().collect(); layers.sort();+ let layers_repr = format!("[{}]", layers.iter().map(|l| fusion_lbr::py_repr_str(l)).collect::<Vec<_>>().join(", "));+ ATTACH_TEMPLATE.replace("{MODEL_PATH}", model_path).replace("{LAYERS_OFF}", &layers_repr).replace("{ORIENT_BLOCK}", if orient { ATTACH_ORIENT_BLOCK } else { "axis=None\n" })+}++/// `fusion_attach_3d_package`: enter the Package3D environment, import and orient the STEP, FINISH.+pub fn attach_3d_package(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let file_path = args["filePath"].as_str().unwrap_or("").replace('\\', "/");+ let model_path = args["modelPath"].as_str().unwrap_or("").replace('\\', "/");+ let package = args["packageName"].as_str().unwrap_or("").to_string();+ let orient = args["orient"].as_bool().unwrap_or(true);+ if file_path.is_empty() || model_path.is_empty() { return json!({"success": false, "error": "filePath (.lbr) and modelPath (.step) are required (Windows paths, e.g. C:/...).", "_hint": "Stage both onto Windows first (no container->Windows push verb). See the fusion-libraries skill."}); }+ let mut steps: Vec<String> = Vec::new();+ if args["foreground"].as_bool() != Some(true) { ctx.host.background_envelope(600.0, false); }+ let op = crate::orchestrations::open_lbr(ctx, &json!({"filePath": file_path}), pass);+ if op["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("open_lbr failed: {}", op["error"].as_str().unwrap_or("None")), "data": {"steps": steps}}); }+ steps.push("open_lbr: ok (library active)".into());+ std::thread::sleep(Duration::from_secs(1));+ let cp = crate::orchestrations::create_3d_package_pub(ctx, &file_path, pass);+ if cp["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("Create3DPackage failed: {}", cp["error"].as_str().unwrap_or("None")), "_hint": "The library document must be ACTIVE, and the .lbr must be adom-lbr-generated (a raw vendor EAGLE .lbr fails Fusion's XML parser).", "data": {"steps": steps}}); }+ steps.push("Create3DPackage: ok (Package3DEnvironment)".into());+ std::thread::sleep(Duration::from_secs(2));+ let layers: Vec<String> = args["layersOff"].as_array().map(|a| a.iter().map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).collect()).unwrap_or_default();+ let imp = pass("run_modeling_script", json!({"script": attach_script(&model_path, orient, &layers)}));+ if imp["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("import/orient failed: {}", imp["error"].as_str().unwrap_or("None")), "data": {"steps": steps}}); }+ let dims = imp["data"]["result"].clone();+ steps.push(format!("import+orient: ok ({})", if dims.is_object() { crate::fusion_prefs::py_str(&dims["dims_mm"]) } else { crate::fusion_prefs::py_str(&dims) }));+ if dims.is_object() && !dims["bodies"].is_null() { steps.push(format!("bodies: {}; layers hidden: {}", crate::fusion_prefs::py_str(&dims["bodies"]), crate::fusion_prefs::py_str(&dims["layersHidden"]))); }+ std::thread::sleep(Duration::from_secs(1));+ let _ = pass("run_modeling_script", json!({"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"}));+ steps.push("FINISH (Package3DStop): executed".into());+ json!({"success": true, "output": format!("3D model placed + oriented on the footprint and FINISH executed for '{}'. A Save dialog is now up.", if package.is_empty() { &file_path } else { &package }), "data": {"steps": steps, "package": package}, "savePending": true,+ "_hint": "FINAL STEP (desktop-side, one click): a Fusion 'Save' dialog is now up to save the 3D package. Click it: desktop_find_window {titleContains:'Save'} -> desktop_ui_click {hwnd, automationId:'QTApplication.QTFrameWindow.standardActions.SaveButton'}. THEN VERIFY with desktop_screenshot_window on the Fusion hwnd: check ownedPopupCount (errors are owned popups), and the deviceset's Package column should flip Placeholder->part-name + the 3D preview becomes the real chip (the preview LAGS a beat - re-grab). Full flow: fusion-libraries skill / LIBRARY_FINDINGS.md sect 11."})+}++const SHOW_3D_FALLBACK_PACKAGE: &str = r#"<package name="{N}"><description>Adom auto footprint (3D carrier)</description><smd name="1" x="-1.27" y="0" dx="1.2" dy="1.2" layer="1"/><smd name="2" x="1.27" y="0" dx="1.2" dy="1.2" layer="1"/><wire x1="-2.2" y1="1.6" x2="2.2" y2="1.6" width="0.127" layer="21"/><wire x1="-2.2" y1="-1.6" x2="2.2" y2="-1.6" width="0.127" layer="21"/><text x="-2.2" y="2" size="1" layer="25">>NAME</text></package>"#;++fn p3d_names(lbr: &str) -> Vec<String> { std::fs::read_to_string(lbr).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()).map(|n| n.p3d).unwrap_or_default() }++/// `fusion_show_3d_package`: the 3D chip for a library package, attaching a STEP first when asked.+pub fn show_3d_package(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut package_xml = args["packageXml"].as_str().map(str::to_string);+ let step_path = args["stepPath"].as_str().unwrap_or("").replace('\\', "/");+ let mut name = crate::orchestrations::artifact_name_from(args, &["packageXml"]);+ if name.is_empty() && !step_path.is_empty() { name = regex::Regex::new(r"[^A-Za-z0-9_-]").unwrap().replace_all(step_path.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s).unwrap_or(""), "_").chars().take(60).collect(); }+ if name.is_empty() { return json!({"success": false, "error": "name required (or derivable from packageXml/stepPath)."}); }+ let mut library_path = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ let mut wrapped: Option<String> = None;+ if library_path.is_empty() {+ if package_xml.is_none() && !step_path.is_empty() { package_xml = Some(SHOW_3D_FALLBACK_PACKAGE.replace("{N}", &name)); }+ let Some(px) = package_xml.clone() else { return json!({"success": false, "error": "Need a footprint to host the 3D: pass libraryPath, packageXml, or stepPath.", "_hint": "A 3D package lives ON a footprint. stepPath alone works: a minimal 2-pad carrier footprint is generated."}) };+ match crate::orchestrations::lbr_wrap_write(&ctx.state_dir, &name, None, Some(&px)) { (Some(p), _) => { wrapped = Some(p.clone()); library_path = p; }, (_, Some(e)) => return json!({"success": false, "error": e}), _ => unreachable!() }+ }+ if !step_path.is_empty() {+ let mut res = crate::small_verbs::merge_dialog_array(attach_3d_package(ctx, &json!({"filePath": library_path, "modelPath": step_path, "packageName": name}), pass), &ctx.caller, &ctx.pids);+ if p3d_names(&library_path).is_empty() {+ let up_err = res["error"].as_str().or(res["attachError"].as_str()).unwrap_or("attach reported no error but bound nothing").to_string();+ return json!({"success": false, "errorCode": "3d_attach_failed", "error": "Attaching the STEP failed, so the library still has no 3D package - refusing to show Fusion's Placeholder as if it were the part.", "attachError": up_err.chars().take(400).collect::<String>(), "libraryPath": library_path,+ "_hint": "The upstream failure is in attachError (a 409 here usually means the f3d already exists in the cloud - the bind path reuses it as of 1.9.11x, so retry fusion_bind_library_3d and read ITS message). Nothing was rendered because a Placeholder screenshot photographs exactly like a real chip and must never count as one."});+ }+ if let Some(w) = &wrapped { res["wrappedLibrary"] = json!(w); }+ res["view"] = json!("3d-package");+ return res;+ }+ let opened = crate::orchestrations::open_lbr(ctx, &json!({"filePath": library_path}), pass);+ if opened["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("open_lbr failed: {}", opened["error"].as_str().unwrap_or("None"))}); }+ if p3d_names(&library_path).is_empty() {+ return json!({"success": false, "error": "That library has no 3D package bound, so the only thing to show is Fusion's grey Placeholder - refusing to report that as a 3D package.", "errorCode": "no_3d_package_bound", "libraryPath": library_path,+ "_hint": "Bind a real model first: re-call fusion_show_3d_package with stepPath pointing at the STEP file and the bridge attaches it onto the footprint, then shows it. A Placeholder rendering LOOKS like a successful 3D view in a screenshot, which is why this refuses rather than letting a caller count it as proof."});+ }+ let cp = crate::orchestrations::create_3d_package_pub(ctx, &library_path, pass);+ if cp["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("Create3DPackage failed: {}", cp["error"].as_str().unwrap_or("None")), "_hint": "The library must be Fusion-parseable (adom-lbr style); raw vendor .lbr files fail Fusion's parser."}); }+ let mut out = json!({"success": true, "view": "3d-package", "name": name, "libraryPath": library_path});+ let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+ if shot["success"].as_bool() == Some(true) { if let Some(p) = shot["savedTo"].as_str() { out["screenshot"] = json!(p); } }+ if let Some(w) = &wrapped { out["wrappedLibrary"] = json!(w); }+ out+}
rust/bridge/src/orchestrations.rsadded+867@@ -0,0 +1,867 @@+//! Bridge-level orchestrations, native: `open_lbr` first (the most used one), with the+//! part-metadata report, the open-library memory and the deterministic bridge-doc close it+//! leans on. Each Fusion hop goes through the add-in pass-through, so the etiquette, gates+//! and dialog handling are the same as for a direct verb.++use serde_json::{json, Value};+use std::time::{Duration, Instant};++use crate::addin::Ctx;++fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++/// `_part_metadata_report`: the short, user-facing ordering report an import carries up the chain.+pub fn part_metadata_report(path: &str) -> Value {+ let Ok(xml) = std::fs::read_to_string(path) else { return json!({"ok": Value::Null, "unavailable": "could not read the library"}) };+ let md = match fusion_lbr::library_part_metadata(&xml) { Ok(m) if m.ok => m, Ok(_) => return json!({"ok": Value::Null, "unavailable": "could not read the library"}), Err(e) => return json!({"ok": Value::Null, "unavailable": e.to_string()}) };+ let lint = fusion_lbr::lint_part_metadata(&xml);+ let dist = ["MOUSER_PN", "DIGIKEY_PN", "LCSC_PN"];+ let fields = ["MPN", "MANUFACTURER", "MOUSER_PN", "DIGIKEY_PN", "LCSC_PN"];+ let mut comps: Vec<Value> = Vec::new();+ let mut missing: std::collections::BTreeMap<&str, i64> = fields.iter().map(|f| (*f, 0)).collect();+ let mut n_no_dist = 0i64;+ for ds in &md.devicesets {+ let absent: Vec<&str> = fields.iter().filter(|f| !ds.variants.iter().any(|v| v.attributes.get(**f).map(|s| !s.is_empty()).unwrap_or(false))).cloned().collect();+ for f in &absent { *missing.get_mut(f).unwrap() += 1; }+ let mut blocking: Vec<String> = ["MPN", "MANUFACTURER"].iter().filter(|f| absent.contains(f)).map(|s| s.to_string()).collect();+ let absent_dist: Vec<&str> = dist.iter().filter(|d| absent.contains(d)).cloned().collect();+ if absent_dist.len() == dist.len() { blocking.push("a distributor part number".into()); n_no_dist += 1; }+ if !blocking.is_empty() || !absent_dist.is_empty() {+ comps.push(json!({"component": ds.name, "variants": ds.variants.len(), "missing": blocking, "absentDistributors": absent_dist, "variantNames": ds.variants.iter().map(|v| v.full_name.clone()).collect::<Vec<_>>()}));+ }+ }+ let total = md.devicesets.len() as i64;+ let label = |f: &str| match f { "MPN" => "no manufacturer part number", "MANUFACTURER" => "no manufacturer", "MOUSER_PN" => "no Mouser part number", "DIGIKEY_PN" => "no DigiKey part number", _ => "no LCSC part number" };+ let n_no_desc = md.devicesets.iter().filter(|d| d.description.trim().is_empty()).count() as i64;+ for ds in &md.devicesets {+ if ds.description.trim().is_empty() {+ if let Some(hit) = comps.iter_mut().find(|c| c["component"].as_str() == Some(&ds.name)) { hit["missing"].as_array_mut().unwrap().push(json!("a description")); }+ else { comps.push(json!({"component": ds.name, "variants": ds.variants.len(), "missing": ["a description"], "absentDistributors": [], "variantNames": ds.variants.iter().map(|v| v.full_name.clone()).collect::<Vec<_>>()})); }+ }+ }+ let n_phrase = |n: i64, what: &str| format!("{n} of {total} component{} ha{} {what}", if total == 1 { "" } else { "s" }, if n == 1 { "s" } else { "ve" });+ let mut bits: Vec<String> = ["MPN", "MANUFACTURER"].iter().filter(|f| missing[*f] > 0).map(|f| n_phrase(missing[f], label(f))).collect();+ if n_no_desc > 0 { bits.push(n_phrase(n_no_desc, "no description")); }+ if n_no_dist > 0 { bits.push(n_phrase(n_no_dist, "no distributor part number at all")); }+ let mut mc: serde_json::Map<String, Value> = missing.iter().map(|(k, v)| (k.to_string(), json!(v))).collect();+ mc.insert("DESCRIPTION".into(), json!(n_no_desc));+ let dist_note: Vec<String> = dist.iter().filter(|d| missing[*d] > 0).map(|d| n_phrase(missing[d], label(d))).collect();+ json!({"ok": lint.ok, "componentsTotal": total, "variantsTotal": md.totals.variants, "missingCounts": mc, "componentsWithoutAnyDistributor": n_no_dist,+ "distributorCoverage": dist.iter().map(|d| (d.to_string(), json!(total - missing[d]))).collect::<serde_json::Map<_, _>>(),+ "components": comps.into_iter().take(40).collect::<Vec<_>>(), "warnings": lint.warnings,+ "summary": if bits.is_empty() { format!("All {total} component(s) carry a manufacturer part number and at least one distributor part number.") } else { format!("{}. The library imported fine - this is about ordering, not geometry.", bits.join("; ")) },+ "distributorNote": if dist_note.is_empty() { Value::Null } else { json!(dist_note.join("; ")) },+ "userAction": if bits.is_empty() { Value::Null } else { json!("Add the missing part numbers where the component metadata lives (upstream of this bridge), then re-export.") }})+}++/// `_remember_open_library`: persisted so a later capture can pre-check names without a path.+pub fn remember_open_library(state_dir: &std::path::Path, lbr: &str) {+ let _ = std::fs::create_dir_all(state_dir);+ let _ = std::fs::write(state_dir.join("open_library.json"), json!({"libraryPath": lbr.replace('\\', "/"), "at": now_f()}).to_string());+}++/// `_close_bridge_doc_verified`: close a bridge-owned scratch document and VERIFY it is gone,+/// answering a Save prompt inside the same call; never over an in-flight Hub upload.+pub fn close_bridge_doc_verified(ctx: &Ctx, name: &str, timeout_s: u64, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let n = name.trim_end_matches('*').trim().to_string();+ if n.is_empty() { return json!({"closed": false, "why": "no name"}); }+ let jobs_deadline = Instant::now() + Duration::from_secs(120);+ let mut drained = false;+ while Instant::now() < jobs_deadline {+ let jr = pass("run_modeling_script", json!({"script": "import adsk.core\nresult={'jobs': adsk.core.Application.get().hasActiveJobs}"}));+ if jr["success"].as_bool() != Some(true) || jr["data"]["result"]["jobs"].as_bool() != Some(true) { drained = true; break; }+ eprintln!("[fusion-bridge] close({n:?}): Fusion Hub upload in flight - waiting, not closing (a close now would raise the lose-changes modal)");+ std::thread::sleep(Duration::from_secs(5));+ }+ if !drained { return json!({"closed": false, "deferred": true, "why": "Fusion Hub uploads still active after 120s - close deferred so the upload is not destroyed; retry after the jobs drain"}); }+ let _ = pass("close_document", json!({"name": n, "save": false}));+ let deadline = Instant::now() + Duration::from_secs(timeout_s);+ while Instant::now() < deadline {+ let di = pass("document_info", json!({}));+ if di["success"].as_bool() == Some(true) {+ let docs: Vec<String> = di["data"]["openDocuments"].as_array().map(|a| a.iter().filter_map(|d| d["name"].as_str()).map(|s| s.trim_end_matches('*').trim().to_lowercase()).collect()).unwrap_or_default();+ if !docs.contains(&n.to_lowercase()) { return json!({"closed": true}); }+ }+ let got = crate::etiquette::dismiss_save_prompt_if_bridge_doc(&ctx.state_dir, &ctx.caller, &ctx.pids, "");+ if got["handled"].as_bool() == Some(true) { eprintln!("[fusion-bridge] close({n:?}): answered the Save prompt inside the same call (deterministic close, never left on screen)"); }+ std::thread::sleep(Duration::from_secs(2));+ }+ json!({"closed": false, "why": format!("document still open after {timeout_s}s")})+}++/// `fusion_open_lbr`.+pub fn open_lbr(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let file_path = ["filePath", "lbrPath", "libraryPath", "path"].iter().find_map(|k| args[k].as_str()).unwrap_or("").to_string();+ if file_path.trim().is_empty() {+ let mut received: Vec<String> = args.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); received.sort();+ return json!({"success": false, "errorCode": "no_path_argument", "error": "No library path was given, so nothing was opened. This verb accepts filePath, lbrPath, libraryPath or path - the file itself was never examined, so do NOT re-stage it.",+ "acceptedArgs": ["filePath", "lbrPath", "libraryPath", "path"], "receivedArgs": received, "_hint": "Re-call with a Windows-local path, e.g. fusion_open_lbr {\"lbrPath\": \"C:/tmp/rayparts/tja1050.lbr\"}."});+ }+ let pf = fusion_lbr::preflight_lbr(std::path::Path::new(&file_path));+ let hard_ok = ["file-readable", "size-sane", "xml-parses", "root-is-eagle"].iter().all(|k| pf.checks.iter().find(|c| c.check == *k).map(|c| c.ok).unwrap_or(true));+ let file_path = file_path.replace('\\', "/");+ if !hard_ok {+ let sz = std::fs::metadata(&file_path).ok().map(|m| m.len());+ let head: Vec<u8> = std::fs::read(&file_path).ok().map(|b| b.into_iter().take(32).collect()).unwrap_or_default();+ return json!({"success": false, "errorCode": "lbr_invalid", "preflight": pf,+ "error": format!("{file_path} will not open in Fusion: {} - refusing to hand it over (the only possible outcome is a blocking Error modal plus an orphaned empty design).", pf.errors.join("; ").chars().take(240).collect::<String>()),+ "fileSize": sz, "fileHead": py_repr_bytes(&head),+ "_hint": "The file on THIS box is bad. Re-stage it from the source and compare hashes - a transfer that truncates or re-encodes is the usual culprit. fusion_preflight_lbr re-runs these checks in isolation. Nothing was opened and no dialog was raised."});+ }+ let stem = file_path.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();+ let di = pass("document_info", json!({}));+ for d in di["data"]["openDocuments"].as_array().cloned().unwrap_or_default() {+ let n0 = d["name"].as_str().unwrap_or("").trim_end_matches('*').trim().to_string();+ if n0.to_lowercase() == stem.to_lowercase() { let _ = close_bridge_doc_verified(ctx, &n0, 30, pass); }+ }+ let symbol_name = args["symbolName"].as_str().unwrap_or("").to_string();+ let verify = args["verify"].as_bool().unwrap_or(false);+ crate::chains::note_bridge_doc_pub(&ctx.state_dir, &file_path);+ let mut results: Vec<String> = Vec::new();+ let open_result = pass("execute_text_command", json!({"command": format!("Document.newDesignFromLocal {}", ctx.host_paths_short(&file_path))}));+ if open_result["success"].as_bool() != Some(true) {+ let expected = stem.to_lowercase();+ let mut opened_anyway = false;+ for _ in 0..20 {+ std::thread::sleep(Duration::from_secs(3));+ let st = pass("get_app_state", json!({}));+ let active = st["data"]["activeDocument"].as_str().unwrap_or("").to_lowercase();+ if !expected.is_empty() && active.contains(&expected) { opened_anyway = true; break; }+ }+ if !opened_anyway {+ return json!({"success": false, "error": format!("Document.newDesignFromLocal failed: {}", open_result["error"].as_str().unwrap_or("unknown")),+ "_hint": "The open did not complete AND the document never became active. On slow/software-rendered machines opens can take 60s+; this call already waited + verified by state. Check fusion_check_dialogs for a blocking modal, then retry.", "data": {"filePath": file_path}});+ }+ results.push("Document.newDesignFromLocal: ok (verified by app state after slow open)".into());+ } else { results.push("Document.newDesignFromLocal: ok".into()); }+ if !symbol_name.is_empty() {+ std::thread::sleep(Duration::from_secs(3));+ let sym_ref = if symbol_name.ends_with(".sym") { symbol_name.clone() } else { format!("{symbol_name}.sym") };+ let er = pass("electron_run", json!({"command": format!("EDIT {sym_ref}")}));+ results.push(format!("EDIT {sym_ref}: success={}", if er["success"].as_bool() == Some(true) { "True" } else { "False" }));+ let _ = pass("electron_run", json!({"command": "WINDOW FIT"}));+ results.push("WINDOW FIT: ok".into());+ }+ let mut verification = Value::Null;+ if verify {+ std::thread::sleep(Duration::from_secs(2));+ let export_path = std::env::temp_dir().join(format!("_adom_verify_{:08x}.scr", (now_f() * 1000.0) as u64 & 0xffff_ffff)).to_string_lossy().replace('\\', "/");+ let ex = pass("export_lbr", json!({"outputPath": export_path}));+ if ex["success"].as_bool() == Some(true) {+ let preview = ex["data"]["preview"].as_str().unwrap_or("").to_string();+ let has_symbol = !symbol_name.is_empty() && (preview.contains(&format!("'{}.sym'", symbol_name.to_uppercase())) || preview.contains(&format!("'{symbol_name}.sym'")));+ verification = json!({"exported": true, "fileSize": ex["data"]["fileSize"].as_i64().unwrap_or(0), "hasSymbol": has_symbol, "preview": preview.chars().take(500).collect::<String>()});+ } else { verification = json!({"exported": false, "error": ex["error"].as_str().unwrap_or("unknown")}); }+ results.push(format!("Verification: exported={}", if verification["exported"].as_bool() == Some(true) { "True" } else { "False" }));+ }+ remember_open_library(&ctx.state_dir, &file_path);+ let pm = part_metadata_report(&file_path);+ let mut response = json!({"success": true, "output": format!("Opened library: {}", file_path.rsplit('/').next().unwrap_or("")), "partMetadata": pm, "data": {"results": results, "filePath": file_path},+ "_hint": "Library opened in the Electronics Library editor (Content Manager). ALWAYS VERIFY VIA SCREENSHOT: a .lbr can FAIL to open ('<file>.lbr has errors and cannot be opened') while this call still returns success - that error is an OWNED POPUP. Grab desktop_screenshot_window on the Fusion main hwnd and CHECK ownedPopupCount + read the _screenshots[] array (ab v1.8.177+ captures owned dialogs invisible to a plain capture); ownedPopupCount>0 means an error/confirm dialog is up. NOTE: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package. To attach the real 3D chip, use fusion_attach_3d_package (it runs the Package3D generator + FINISH, which binds the 3D onto the deviceset). See the 'fusion-libraries' skill / LIBRARY_FINDINGS.md."});+ if !symbol_name.is_empty() { response["data"]["symbolName"] = json!(symbol_name); }+ if !verification.is_null() { response["data"]["verification"] = verification; }+ let (shots, _) = crate::lifecycle::post_open_screenshot(ctx, 2.0);+ response["data"]["postOpenScreenshot"] = shots;+ response+}++/// Python's `repr(bytes)`.+fn py_repr_bytes(b: &[u8]) -> String {+ let mut out = String::from("b'");+ for &c in b {+ match c { b'\\' => out.push_str("\\\\"), b'\'' => out.push_str("\\'"), b'\n' => out.push_str("\\n"), b'\r' => out.push_str("\\r"), b'\t' => out.push_str("\\t"),+ 0x20..=0x7e => out.push(c as char), _ => out.push_str(&format!("\\x{c:02x}")) }+ }+ out.push('\''); out+}++#[cfg(test)]+mod tests {+ #[test]+ fn ratio_matches_difflib_on_known_pairs() {+ // difflib: ratio("LAN7800","LAN7800A")=0.9333, ratio("abcd","bcde")=0.75, ratio("xyz","abc")=0.0+ assert!((super::seq_ratio("LAN7800", "LAN7800A") - 0.9333).abs() < 0.01);+ assert!((super::seq_ratio("abcd", "bcde") - 0.75).abs() < 0.001);+ assert_eq!(super::seq_ratio("xyz", "abc"), 0.0);+ assert_eq!(super::close_matches("LAN780", &["LAN7800".into(), "CH340G".into()], 5, 0.6), vec!["LAN7800".to_string()]);+ }+ #[test]+ fn wrap_skeleton_pinned_to_server_py() {+ let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../server.py")).unwrap();+ let parts: serde_json::Value = serde_json::from_str(super::WRAP_PARTS).unwrap();+ let grid = parts["_LBR_WRAP_GRID"].as_str().unwrap();+ assert!(src.contains(grid), "lbr_wrap_parts.json drifted from server.py's _LBR_WRAP_GRID");+ }+ #[test]+ fn bytes_repr_matches_python() { assert_eq!(super::py_repr_bytes(b"<?xml\n\x00\xff'"), "b'<?xml\\n\\x00\\xff\\''"); }+}++// ───────────────────────── the show-surface family ─────────────────────────++const WRAP_PARTS: &str = include_str!("lbr_wrap_parts.json");++/// `_artifact_name_from`: the explicit name, else the fragment's name attribute.+pub fn artifact_name_from(args: &Value, frag_keys: &[&str]) -> String {+ let n = args["name"].as_str().unwrap_or("").trim().to_string();+ if !n.is_empty() { return n; }+ for k in frag_keys {+ if let Some(frag) = args[k].as_str() { if let Some(c) = regex::Regex::new(r#"name="([^"]+)""#).unwrap().captures(frag) { return c[1].to_string(); } }+ }+ String::new()+}++/// `_lbr_wrap_build`: a minimal, Fusion-parseable EAGLE library around bare artifacts.+pub fn lbr_wrap_build(name: &str, symbol_xml: Option<&str>, package_xml: Option<&str>) -> String {+ let parts: Value = serde_json::from_str(WRAP_PARTS).unwrap_or(json!({}));+ let mut devicesets = String::new();+ if let Some(_) = symbol_xml {+ let variant = match package_xml {+ Some(p) => { let pkg = regex::Regex::new(r#"<package\s+name="([^"]+)""#).unwrap().captures(p).map(|c| c[1].to_string()).unwrap_or_else(|| name.to_string()); format!(r#"<device name="" package="{pkg}"><technologies><technology name=""/></technologies></device>"#) }+ None => r#"<device name=""/>"#.to_string(),+ };+ devicesets = format!(r#"<deviceset name="{name}" prefix="U"><description>Adom wrapper</description><gates><gate name="G$1" symbol="{name}" x="0" y="0"/></gates><devices>{variant}</devices></deviceset>"#);+ }+ format!("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n<eagle version=\"9.6.2\">\n<drawing>\n{}\n{}\n{}\n<library>\n<description>Adom wrapper library: {name}</description>\n<packages>{}</packages>\n<symbols>{}</symbols>\n<devicesets>{devicesets}</devicesets>\n</library>\n</drawing>\n</eagle>\n",+ parts["_LBR_WRAP_SETTINGS"].as_str().unwrap_or(""), parts["_LBR_WRAP_GRID"].as_str().unwrap_or(""), parts["_LBR_WRAP_LAYERS"].as_str().unwrap_or(""), package_xml.unwrap_or(""), symbol_xml.unwrap_or(""))+}++/// `_lbr_wrap_write`: (path, None) or (None, error).+pub fn lbr_wrap_write(state_dir: &std::path::Path, name: &str, symbol_xml: Option<&str>, package_xml: Option<&str>) -> (Option<String>, Option<String>) {+ for (frag, kind, tag) in [(symbol_xml, "symbolXml", "symbol"), (package_xml, "packageXml", "package")] {+ if let Some(f) = frag { if !regex::Regex::new(&format!(r"^\s*<{tag}[\s>]")).unwrap().is_match(f) { return (None, Some(format!("{kind} must be a bare <{tag} ...>...</{tag}> XML fragment (got something else). To open a whole library, pass libraryPath instead."))); } }+ }+ let mut safe: String = regex::Regex::new(r"[^A-Za-z0-9_-]").unwrap().replace_all(name, "_").chars().take(60).collect();+ if safe.is_empty() { safe = "wrapped".into(); }+ let d = std::env::temp_dir().join("adom-fusion-wrap");+ let _ = std::fs::create_dir_all(&d);+ let path = d.join(format!("{safe}.lbr")).to_string_lossy().replace('\\', "/");+ crate::chains::note_bridge_doc_pub(state_dir, &safe);+ match std::fs::write(&path, lbr_wrap_build(name, symbol_xml, package_xml)) { Ok(_) => (Some(path), None), Err(e) => (None, Some(format!("could not write wrapper library: {e}"))) }+}++fn last_library(state_dir: &std::path::Path) -> String { crate::small_verbs::get_pref(state_dir, "lastLibraryPath").and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() }+fn remember_library(state_dir: &std::path::Path, p: &str) { crate::small_verbs::set_pref(state_dir, "lastLibraryPath", json!(p)); }++/// Python's difflib.SequenceMatcher.ratio() over matching blocks (a faithful port of the+/// longest-matching-block recursion without the junk heuristic, which these short names never trigger).+fn seq_ratio(a: &str, b: &str) -> f64 {+ let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());+ fn matches(a: &[char], b: &[char]) -> usize {+ if a.is_empty() || b.is_empty() { return 0; }+ let (mut bi, mut bj, mut bk) = (0usize, 0usize, 0usize);+ for i in 0..a.len() { for j in 0..b.len() { let mut k = 0; while i + k < a.len() && j + k < b.len() && a[i + k] == b[j + k] { k += 1; } if k > bk { bi = i; bj = j; bk = k; } } }+ if bk == 0 { return 0; }+ bk + matches(&a[..bi], &b[..bj]) + matches(&a[bi + bk..], &b[bj + bk..])+ }+ let m = matches(&a, &b); let t = a.len() + b.len();+ if t == 0 { 1.0 } else { 2.0 * m as f64 / t as f64 }+}+fn close_matches(word: &str, have: &[String], n: usize, cutoff: f64) -> Vec<String> {+ let mut scored: Vec<(f64, &String)> = have.iter().map(|h| (seq_ratio(word, h), h)).filter(|(r, _)| *r >= cutoff).collect();+ scored.sort_by(|x, y| y.0.partial_cmp(&x.0).unwrap());+ scored.into_iter().take(n).map(|(_, h)| h.clone()).collect()+}++/// `_resolve_artifact_name`: a name that EXISTS in the .lbr, or the refusal, without asking Fusion.+pub fn resolve_artifact_name(name: &str, suffix: &str, lbr_path: &str) -> Result<String, Value> {+ let names = std::fs::read_to_string(lbr_path).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()).unwrap_or_default();+ let have: Vec<String> = match suffix { "sym" => names.sym.clone(), "pac" => names.pac.clone(), "dev" => names.dev.clone(), _ => vec![] };+ let kind = match suffix { "sym" => "symbol", "pac" => "footprint", "dev" => "device", s => s };+ if !have.is_empty() && !have.iter().any(|h| h == name) && (suffix == "sym" || suffix == "pac") {+ let via = (if suffix == "sym" { &names.dev_sym } else { &names.dev_pac }).get(name).cloned().unwrap_or_default();+ if via.len() == 1 && have.contains(&via[0]) { eprintln!("[fusion-bridge] resolved {name}.{suffix} via its deviceset -> {}", via[0]); return Ok(via[0].clone()); }+ }+ if have.is_empty() {+ return Err(json!({"success": false, "errorCode": "library_unreadable", "error": format!("Cannot read any {suffix} names from {lbr_path} - the library is unparseable or empty, so EDIT was NOT issued (it would certainly raise Fusion's Create-new prompt)."),+ "_hint": "Validate the .lbr first (fusion_open_lbr now refuses garbage with lbr_invalid + the file size/head). Re-stage the file from its source and compare hashes."}));+ }+ if have.iter().any(|h| h == name) { return Ok(name.to_string()); }+ let ci: Vec<&String> = have.iter().filter(|h| h.to_lowercase() == name.to_lowercase()).collect();+ if ci.len() == 1 { return Ok(ci[0].clone()); }+ let close = close_matches(name, &have, 5, 0.6);+ let stem = name.rsplit('/').next().unwrap_or(name).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_else(|| name.to_string());+ let plen = std::cmp::max(4, stem.len().saturating_sub(2));+ let prefix: String = stem.to_lowercase().chars().take(plen).collect();+ let pref: Vec<&String> = have.iter().filter(|h| h.to_lowercase().starts_with(&prefix)).collect();+ if close.len() == 1 && close[0].to_lowercase() != name.to_lowercase() { eprintln!("[fusion-bridge] resolved near-miss {name:?} -> {:?} (issue #42)", close[0]); return Ok(close[0].clone()); }+ if pref.len() == 1 { eprintln!("[fusion-bridge] resolved by stem {name:?} -> {:?} (issue #42)", pref[0]); return Ok(pref[0].clone()); }+ Err(json!({"success": false, "error": format!("No {kind} named {} in that library, so the EDIT was NOT issued.", fusion_lbr::py_repr_str(name)), "errorCode": "artifact_not_in_library",+ "available": have.iter().take(40).cloned().collect::<Vec<_>>(), "closestMatches": close,+ "_hint": "Deliberately refused BEFORE touching Fusion: EDIT on a missing name pops a 'Create new ...?' modal that blocks the add-in's main thread and makes every later verb look like a crash. Pick a name from `available` (they are the <symbol>/<package>/<deviceset> name attributes in the .lbr, the source of truth) and re-call. Nothing was opened and no dialog was raised."}))+}++fn stem_of(p: &str) -> String { p.replace('\\', "/").rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_else(|| p.to_string()).to_lowercase() }++/// `_fusion_front_doc_title`: the document IN FRONT, from the main window title.+fn front_doc_title(ctx: &Ctx) -> Option<String> {+ let ab = fusion_core::ab::Loopback::from_env()?;+ let facts = fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller);+ let t = facts.main_title?;+ if !t.to_lowercase().contains("autodesk fusion") { return None; }+ Some(t.to_lowercase().replace(" - autodesk fusion", "").trim().to_string())+}++/// `_ensure_library_active`: the .lbr is the ACTIVE document, verified, activating or reopening as needed.+pub fn ensure_library_active(ctx: &Ctx, lbr_path: &str, timeout_s: u64, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let stem = stem_of(lbr_path);+ let deadline = Instant::now() + Duration::from_secs(timeout_s);+ let (mut last_ws, mut reopened, mut tries, mut reopened2) = (Value::Null, false, 0u32, false);+ while Instant::now() < deadline {+ let st = pass("get_app_state", json!({}));+ let active = st["data"]["activeDocument"].as_str().unwrap_or("").to_string();+ let ws = st["data"]["activeWorkspace"].as_str().unwrap_or("").to_string();+ last_ws = json!(ws);+ let wl = ws.to_lowercase();+ let ws_ok = (wl.contains("librar") || wl.contains("schematic") || wl.contains("board") || wl.contains("pcb")) && !wl.contains("electronics design");+ let ok = st["success"].as_bool() == Some(true);+ if ok && active.to_lowercase().contains(&stem) && ws_ok {+ let front = front_doc_title(ctx);+ if front.as_ref().map(|f| f.contains(&stem)).unwrap_or(true) { return json!({"ok": true, "activeDocument": active, "activeWorkspace": ws, "frontWindow": front}); }+ if !reopened { reopened = true; let _ = close_bridge_doc_verified(ctx, &stem, 30, pass); let _ = open_lbr(ctx, &json!({"lbrPath": lbr_path}), pass); std::thread::sleep(Duration::from_secs(3)); continue; }+ }+ tries += 1;+ if ok && active.to_lowercase().contains(&stem) && !ws_ok && tries >= 4 && !reopened2 { reopened2 = true; let _ = open_lbr(ctx, &json!({"lbrPath": lbr_path}), pass); std::thread::sleep(Duration::from_secs(4)); continue; }+ if ok { let _ = pass("activate_document", json!({"name": stem})); }+ std::thread::sleep(Duration::from_millis(1500));+ }+ json!({"ok": false, "activeDocument": Value::Null, "activeWorkspace": last_ws})+}++pub fn open_doc_names(pass: &dyn Fn(&str, Value) -> Value) -> Vec<String> {+ pass("document_info", json!({}))["data"]["openDocuments"].as_array().map(|a| a.iter().filter_map(|d| d["name"].as_str().map(str::to_string)).filter(|n| !n.is_empty()).collect()).unwrap_or_default()+}++/// `_show_library_view`: EDIT to a library view, fit, background screenshot. Library must be open.+pub fn show_library_view(ctx: &Ctx, name: &str, suffix: &str, lbr_path: Option<&str>, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut name = name.to_string();+ if let Some(lp) = lbr_path {+ match resolve_artifact_name(&name, suffix, lp) { Ok(r) => { if r != name { eprintln!("[fusion-bridge] resolved {name:?} -> {r:?} from the library (avoids a Create-new prompt)"); name = r; } }, Err(e) => return e }+ let mut act = ensure_library_active(ctx, lp, 30, pass);+ if act["ok"].as_bool() != Some(true) {+ std::thread::sleep(Duration::from_secs(8));+ let stem = stem_of(lp);+ let names = open_doc_names(pass);+ let lb: Vec<String> = names.iter().map(|n| regex::Regex::new(r"\s*\([^)]*\)\s*$").unwrap().replace(&n.replace('*', ""), "").trim().to_lowercase()).collect();+ if !names.is_empty() && !lb.contains(&stem) { let _ = open_lbr(ctx, &json!({"filePath": lp}), pass); }+ act = ensure_library_active(ctx, lp, 60, pass);+ }+ if act["ok"].as_bool() != Some(true) {+ return json!({"success": false, "errorCode": "library_not_active", "error": format!("Could not make {} the ACTIVE document (active: {}), so EDIT was not issued - it would have driven the wrong library.", lp.rsplit('/').next().unwrap_or(lp), fusion_lbr::py_repr_str(act["activeDocument"].as_str().unwrap_or("None"))),+ "_hint": "fusion_open_lbr the library, then re-call. In a batch, close the previous part's documents first (the create_library chain now does this itself)."});+ }+ }+ let mut edit = json!({});+ for busy_try in 0..7 {+ edit = pass("electron_run", json!({"command": format!("EDIT {name}.{suffix}"), "confirm": "no"}));+ let etxt = edit["error"].as_str().unwrap_or("").to_string();+ if edit["success"].as_bool() == Some(true) { break; }+ if lbr_path.is_some() && (etxt.to_lowercase().contains("not in electronics editor") || etxt.to_lowercase().contains("current: design")) {+ eprintln!("[fusion-bridge] EDIT {name}.{suffix} hit Design workspace (attempt {}) - re-entering the library editor and retrying", busy_try + 1);+ let _ = ensure_library_active(ctx, lbr_path.unwrap(), 20, pass); std::thread::sleep(Duration::from_secs(1)); continue;+ }+ if !etxt.contains("BUSY") && !etxt.contains("busy") { break; }+ eprintln!("[fusion-bridge] EDIT {name}.{suffix} deferred - main thread busy (attempt {}), waiting 15s", busy_try + 1);+ std::thread::sleep(Duration::from_secs(15));+ }+ if let Some(ab) = fusion_core::ab::Loopback::from_env() {+ use fusion_core::ab::AbClient;+ let pending: Vec<Value> = crate::small_verbs::classify_blocking_dialogs(&ab, &ctx.caller, &ctx.pids).into_iter().filter(|d| d["category"].as_str() == Some("create_confirm")).collect();+ let mut about_this = false;+ for d in &pending {+ let body = d["hwnd"].as_i64().and_then(|h| ab.call("desktop_find_controls", json!({"hwnd": h}), &ctx.caller, 10).ok()).map(|r| { let dd = if r["data"].is_object() { r["data"].clone() } else { r }; dd["controls"].as_array().or(dd["matches"].as_array()).cloned().unwrap_or_default().iter().filter_map(|c| c["name"].as_str().map(str::to_string)).collect::<Vec<_>>().join(" ") }).unwrap_or_default();+ if format!("{body} {}", d["title"].as_str().unwrap_or("")).to_lowercase().contains(&name.to_lowercase()) { about_this = true; }+ if let Some(h) = d["hwnd"].as_i64() { let _ = ab.call("desktop_ui_click", json!({"hwnd": h, "name": "No"}), &ctx.caller, 12); }+ }+ if !pending.is_empty() && !about_this { eprintln!("[fusion-bridge] declined a STALE Create-new prompt that does not mention {name:?} - clearing it and trusting the EDIT's own result"); }+ if !pending.is_empty() && about_this {+ eprintln!("[fusion-bridge] declined a Create-new prompt for {name:?} - the name is not in the library (answering Yes would fabricate an empty artifact)");+ let kind = match suffix { "sym" => "symbol", "pac" => "footprint", "dev" => "device", s => s };+ return json!({"success": false, "error": format!("No {kind} named {} in the open library. Fusion offered to CREATE it; the bridge declined.", fusion_lbr::py_repr_str(&name)), "errorCode": "artifact_not_in_library", "promptDeclined": true,+ "_hint": "The prompt was answered (No) inside this call, so nothing is left blocking Fusion's main thread. Answering Yes would have created an EMPTY artifact and made this look like it worked. Pass libraryPath so the bridge can verify names up front and never raise the prompt at all."});+ }+ }+ if edit["success"].as_bool() != Some(true) {+ return json!({"success": false, "error": format!("EDIT {name}.{suffix} failed: {}", edit["error"].as_str().unwrap_or("None")),+ "_hint": "The name must match the deviceset/artifact inside the OPEN library exactly (EDIT on a missing name pops a 'Create new?' dialog - run fusion_dismiss_blocking_dialogs if one appeared). Not sure of the name? fusion_get_app_state shows the active document; the .lbr's <symbol>/<package> name attributes are the source of truth."});+ }+ let _ = pass("electron_run", json!({"command": "WINDOW FIT"}));+ std::thread::sleep(Duration::from_secs(1));+ let mut out = json!({"success": true, "view": suffix, "name": name, "resolvedName": name});+ if let Some(lp) = lbr_path {+ if let Some(n) = std::fs::read_to_string(lp).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()) {+ out["libraryPath"] = json!(lp); out["symbolNames"] = json!(n.sym); out["packageNames"] = json!(n.pac); out["devicesetNames"] = json!(n.dev); out["has3d"] = json!(!n.p3d.is_empty()); out["package3dNames"] = json!(n.p3d);+ }+ }+ let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+ if shot["success"].as_bool() == Some(true) { if let Some(p) = shot["savedTo"].as_str() { out["screenshot"] = json!(p); } }+ out+}++/// `_show_prepare_library`: explicit path, generated wrapper, or already open. Ok(wrapped path).+fn show_prepare_library(ctx: &Ctx, args: &Value, symbol_xml: Option<&str>, package_xml: Option<&str>, name: &str, pass: &dyn Fn(&str, Value) -> Value) -> Result<Option<String>, Value> {+ let mut library_path = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ let mut wrapped: Option<String> = None;+ if symbol_xml.is_some() || package_xml.is_some() {+ if !library_path.is_empty() { return Err(json!({"success": false, "error": "Pass EITHER libraryPath OR a bare XML fragment, not both.", "_hint": "libraryPath opens an existing library; a fragment gets wrapped in a generated one."})); }+ match lbr_wrap_write(&ctx.state_dir, name, symbol_xml, package_xml) { (Some(p), _) => { wrapped = Some(p.clone()); library_path = p; }, (_, Some(e)) => return Err(json!({"success": false, "error": e})), _ => unreachable!() }+ }+ if !library_path.is_empty() {+ let opened = open_lbr(ctx, &json!({"filePath": library_path}), pass);+ if opened["success"].as_bool() != Some(true) { return Err(json!({"success": false, "error": format!("open_lbr failed: {}", opened["error"].as_str().unwrap_or("None")), "data": {"libraryPath": library_path, "wrapped": wrapped.is_some()}})); }+ remember_library(&ctx.state_dir, &library_path);+ }+ Ok(wrapped)+}++/// `fusion_show_symbol`.+pub fn show_symbol(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let symbol_xml = args["symbolXml"].as_str();+ let name = artifact_name_from(args, &["symbolXml"]);+ if name.is_empty() { return json!({"success": false, "error": "name required (or a symbolXml fragment with a name attribute)."}); }+ let wrapped = match show_prepare_library(ctx, args, symbol_xml, None, &name, pass) { Ok(w) => w, Err(e) => return e };+ let lp = wrapped.clone().or_else(|| args["libraryPath"].as_str().map(|s| s.replace('\\', "/")).filter(|s| !s.is_empty())).unwrap_or_else(|| last_library(&ctx.state_dir));+ let mut out = show_library_view(ctx, &name, "sym", if lp.is_empty() { None } else { Some(&lp) }, pass);+ if let (Some(w), Some(true)) = (&wrapped, out["success"].as_bool()) {+ out["wrappedLibrary"] = json!(w);+ out["_hint"] = json!(format!("Bare symbol was wrapped in a generated library at {w} (a deviceset with one gate was synthesized so the Library Manager lists it). To pair it with a footprint, re-call with both symbolXml and packageXml."));+ }+ out+}++/// `fusion_show_footprint`.+pub fn show_footprint(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let package_xml = args["packageXml"].as_str();+ let name = artifact_name_from(args, &["packageXml"]);+ if name.is_empty() { return json!({"success": false, "error": "name required (or a packageXml fragment with a name attribute)."}); }+ let wrapped = match show_prepare_library(ctx, args, None, package_xml, &name, pass) { Ok(w) => w, Err(e) => return e };+ let lp = wrapped.clone().or_else(|| args["libraryPath"].as_str().map(|s| s.replace('\\', "/")).filter(|s| !s.is_empty())).unwrap_or_else(|| last_library(&ctx.state_dir));+ let mut out = show_library_view(ctx, &name, "pac", if lp.is_empty() { None } else { Some(&lp) }, pass);+ if let (Some(w), Some(true)) = (&wrapped, out["success"].as_bool()) { out["wrappedLibrary"] = json!(w); out["_hint"] = json!(format!("Bare footprint was wrapped in a generated library at {w}.")); }+ out+}++/// `fusion_show_library`: the LIBRARY surface by its smallest name.+pub fn show_library(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let lbr = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ let name = args["name"].as_str().unwrap_or("").trim().to_string();+ if !lbr.is_empty() {+ let opened = open_lbr(ctx, &json!({"filePath": lbr}), pass);+ if opened["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("open_lbr failed: {}", opened["error"].as_str().unwrap_or("None").chars().take(150).collect::<String>())}); }+ std::thread::sleep(Duration::from_secs(2));+ } else if !name.is_empty() {+ let active = pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_lowercase();+ if !active.contains(&name.to_lowercase()) {+ return json!({"success": false, "error": format!("library {} is not the active document and no libraryPath was given", fusion_lbr::py_repr_str(&name)), "_hint": "Pass libraryPath (WINDOWS .lbr path) to open it; name alone only handles the already-open case."});+ }+ } else { return json!({"success": false, "error": "libraryPath or name required."}); }+ let want = if name.is_empty() { stem_of(&lbr) } else { name.to_lowercase() };+ let active = pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_string();+ if !want.is_empty() && !active.is_empty() && !active.to_lowercase().contains(&want) {+ return json!({"success": false, "errorCode": "library_not_active", "error": format!("opened {} but the ACTIVE document is {} - the view you would screenshot is not the library.", fusion_lbr::py_repr_str(if name.is_empty() { &want } else { &name }), fusion_lbr::py_repr_str(&active)),+ "activeDocument": active, "expected": if name.is_empty() { want } else { name }, "_hint": "Fusion switched or left another tab in front. Re-call fusion_show_library {libraryPath} once; if it repeats, close the stray document (fusion_close_document) and retry."});+ }+ let mut out = json!({"success": true, "view": "library", "activeDocument": if active.is_empty() { Value::Null } else { json!(active) }});+ let bound: Option<bool> = if lbr.is_empty() { None } else { std::fs::read_to_string(&lbr).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()).map(|n| !n.p3d.is_empty()) };+ out["threeDBound"] = json!(bound);+ if bound == Some(false) { out["_hint"] = json!("This library has NO package3d, so Fusion renders a grey Placeholder slab in the 3D pane - that is not a captured 3D artifact. Run fusion_bind_library_3d {libraryPath, stepPath} FIRST, then re-open. (fusion_create_library does the whole chain for you.)"); }+ else if bound.is_none() && !lbr.is_empty() { out["_hint"] = json!("Could not read the .lbr to tell whether a package3d is bound; if the 3D pane shows a grey Placeholder, run fusion_bind_library_3d first."); }+ let docs = open_doc_names(pass);+ if docs.len() > 3 { out["openDocuments"] = json!(docs.len()); out["_hintDocs"] = json!(format!("{} documents are open in Fusion. show_library deliberately leaves the library on screen, so a batch of calls accumulates tabs - close them with fusion_close_all_documents between runs.", docs.len())); }+ let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+ if shot["success"].as_bool() == Some(true) { if let Some(p) = shot["savedTo"].as_str() { out["screenshot"] = json!(p); } }+ out+}++/// `fusion_show_project`: query or urn (the generated-test-project case stays with the sidecar).+pub fn show_project(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Option<Value> {+ let query = args["query"].as_str().or(args["fileName"].as_str()).unwrap_or("").trim().to_string();+ let urn = args["urn"].as_str().unwrap_or("").trim().to_string();+ if query.is_empty() && urn.is_empty() {+ let Some(tp) = crate::testproject::test_project(&ctx.state_dir) else { return Some(json!({"success": false, "error": "query (cloud design name) or urn required - or run fusion_make_test_project first."})) };+ let ok = crate::testproject::test_project_activate(ctx, "brd", pass);+ let mut out = json!({"success": ok, "view": "project", "document": tp["docBase"], "source": "generated test project"});+ if !ok { out["error"] = json!("could not activate the test project's board document"); }+ else { let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller); if let Some(p) = shot["savedTo"].as_str().or(shot["localSafePath"].as_str()).filter(|_| shot["success"].as_bool() == Some(true)) { out["screenshot"] = json!(p); } }+ return Some(out);+ }+ if !urn.is_empty() { return Some(pass("open_by_urn", json!({"urn": urn}))); }+ let active = pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_lowercase();+ let mut out = if active.contains(&query.to_lowercase()) { json!({"success": true, "view": "project", "document": active, "source": "already open"}) } else {+ let _ = open_cloud_file(ctx, &json!({"fileName": query, "wait": false}), pass);+ let deadline = Instant::now() + Duration::from_secs(170);+ let mut opened = false;+ while Instant::now() < deadline { if pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_lowercase().contains(&query.to_lowercase()) { opened = true; break; } std::thread::sleep(Duration::from_secs(5)); }+ if !opened { return Some(json!({"success": false, "error": format!("{} never became the active document within 170s", fusion_lbr::py_repr_str(&query)), "_hint": "Check the name with fusion_aps_search; cloud opens on cold sessions take minutes."})); }+ json!({"success": true, "view": "project", "document": query, "source": "cloud open"})+ };+ let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+ if shot["success"].as_bool() == Some(true) { if let Some(p) = shot["savedTo"].as_str() { out["screenshot"] = json!(p); } }+ Some(out)+}++/// `_handle_open_with_screenshot`: the add-in open or switch, then the post-open capture.+pub fn open_with_screenshot(ctx: &Ctx, cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut r = pass(cmd, args.clone());+ if r["success"].as_bool() == Some(true) {+ let (shots, _) = crate::lifecycle::post_open_screenshot(ctx, 2.0);+ if !r["data"].is_object() { r["data"] = json!({}); }+ r["data"]["postOpenScreenshot"] = shots;+ }+ r+}++/// `fusion_state`: the render-me verb. Server-side only: it answers while the add-in is busy.+pub fn fusion_state(ctx: &Ctx, args: &Value) -> Value {+ let sd = ctx.state_dir.clone();+ let running = !ctx.host.fusion_processes().is_empty();+ let mut result = json!({"success": true, "running": running});+ let focus: Vec<Value> = std::fs::read_to_string(sd.join("focus-events.jsonl")).ok().map(|t| t.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).collect::<Vec<_>>()).unwrap_or_default();+ result["focusEvents"] = json!(focus[focus.len().saturating_sub(20)..]);+ let timings = fusion_core::readiness::timings_summary(&crate::timings::load(&sd));+ result["timings"] = timings.clone();+ if let Some(s) = crate::launch::timings_sentence(&sd) { result["timingsSentence"] = json!(s); }+ if !running {+ result["_hint"] = json!("Fusion is not running - nothing to render. fusion_start launches it in the background; fusion_demo {mode:'tour'} cold-starts it itself. focusEvents and timings ARE still reported - they do not need Fusion.");+ result["output"] = json!(json!({"running": false}).to_string());+ return result;+ }+ let ab = fusion_core::ab::Loopback::from_env();+ let facts = ab.as_ref().map(|a| fusion_core::windows_facts::facts_from_ab(a, &ctx.caller)).unwrap_or_default();+ result["hwnd"] = json!(facts.main_hwnd); result["title"] = json!(facts.main_title);+ let main_row = facts.family.iter().find(|w| w["hwnd"].as_i64() == facts.main_hwnd).cloned();+ result["rect"] = main_row.map(|m| fusion_core::dialogs::rect_of(&m["rect"])).unwrap_or(Value::Null);+ let (dialogs, mut blocking) = match &ab {+ Some(a) => {+ use fusion_core::ab::AbClient;+ let rows = a.call("desktop_enum_windows", json!({"pids": ctx.pids}), &ctx.caller, 10).ok().map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default()).unwrap_or_default();+ (fusion_core::dialogs::qt_dialog_rows(&rows), crate::small_verbs::classify_blocking_dialogs(a, &ctx.caller, &ctx.pids))+ }+ None => (vec![], vec![]),+ };+ result["dialogs"] = json!(dialogs);+ if !blocking.iter().any(|d| d["category"].as_str() == Some("license_sessions_exceeded")) {+ if let Some(dlg) = &facts.licensing_dialog {+ let auto = crate::small_verbs::get_pref(&sd, "autoTakeOverSeat").map(|v| v != json!(false)).unwrap_or(true);+ blocking.push(json!({"category": "license_sessions_exceeded", "title": dlg["title"], "hwnd": dlg["hwnd"], "resolution": format!("Autodesk seat modal: this account's Fusion is active on another machine. {}", if auto { "Auto-takeover is ON - the bridge clicks Continue through both dialogs in the background (~20s sweep; a fusion_start resolves it immediately). The other machine's Fusion suspends RESUMABLY." } else { "Auto-takeover is OPTED OUT (fusion_set_seat_takeover) - resolve the dialog yourself or re-enable takeover." })}));+ }+ }+ result["blockingDialogs"] = json!(blocking);+ let st = crate::addin::addin_status_probe(Duration::from_secs(3));+ let busy = st.as_ref().filter(|s| s["busy"].as_bool() == Some(true)).map(|s| json!({"command": s["busyCommand"], "elapsedSeconds": s["elapsedSeconds"]}));+ result["addinResponding"] = json!(st.is_some());+ result["mainThreadBusy"] = busy.clone().unwrap_or(Value::Null);+ if let Some(lsa) = crate::small_verbs::get_pref(&sd, "launchStartedAt").and_then(|v| v.as_f64()) {+ if st.is_none() && now_f() - lsa < 900.0 {+ let stats = fusion_core::readiness::timings_stats(&crate::timings::load(&sd), "launch", "cold");+ result["progress"] = fusion_core::readiness::progress_block(&stats, lsa, now_f(), "Fusion is starting - waiting for the Adom add-in to serve", 150.0);+ }+ }+ result["drivingBadge"] = json!({"active": crate::etiquette::badge_active(), "lastDrivingVerbAt": crate::etiquette::last_drive_at(), "idleShedSeconds": 300, "enabled": crate::small_verbs::get_pref(&sd, "showDrivingBadge").map(|v| v != json!(false)).unwrap_or(true), "lastError": Value::Null});+ if st.is_some() && busy.is_none() {+ let app = ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(5)).send_json(json!({"command": "get_app_state", "args": {}})).ok().and_then(|r| r.into_json::<Value>().ok());+ if let Some(a) = app { result["activeDocument"] = a["data"]["activeDocument"].clone(); }+ }+ if args["screenshot"].as_bool() != Some(false) {+ let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+ if shot["success"].as_bool() == Some(true) {+ let p = shot["savedTo"].as_str().unwrap_or("").to_string();+ result["screenshot"] = json!(p);+ if args["imageAs"].as_str() == Some("base64") {+ if let Ok(raw) = std::fs::read(&p) {+ if raw.len() <= 4 * 1024 * 1024 { result["screenshotBase64"] = json!(crate::etiquette::b64_pub(&raw)); }+ else { result["screenshotNote"] = json!(format!("capture is {}KB (>4MB inline cap) and could not be downscaled - pull result.screenshot via read_file.", raw.len() / 1024)); }+ }+ }+ } else { result["screenshotError"] = json!(shot["error"].as_str().unwrap_or("capture failed")); }+ }+ let mut hints: Vec<String> = Vec::new();+ if let Some(b) = &busy { hints.push(format!("Main thread busy ({}, {}s) - add-in verbs are rejected right now; window/screenshot verbs still work.", b["command"].as_str().unwrap_or("?"), b["elapsedSeconds"])); }+ if !blocking.is_empty() { hints.push("A modal is up - see blockingDialogs for the classified cause + resolution.".into()); }+ if hints.is_empty() { hints.push("Fusion is clear. The screenshot shows the current surface - this is the verb to render any fusion_show_* result (they open in the background by design).".into()); }+ result["_hint"] = json!(hints.join(" "));+ result["output"] = json!(json!({"running": true, "activeDocument": result["activeDocument"], "mainThreadBusy": busy, "blockingDialogs": blocking, "screenshot": result["screenshot"]}).to_string());+ result+}++/// `_capture_labeled`: the main window to a labeled PNG under C:/tmp/conduit-screenshots+/// (ab captures to its own shot dir; the file is copied so pull paths stay where the skills say).+pub fn capture_labeled(ctx: &Ctx, label: &str) -> Option<String> {+ if label.is_empty() { return None; }+ let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+ let src = shot["savedTo"].as_str().filter(|_| shot["success"].as_bool() == Some(true))?.to_string();+ let safe: String = regex::Regex::new(r#"[<>:"/\\|?*]"#).unwrap().replace_all(label, "").replace(' ', "_").chars().take(40).collect();+ let dir = std::path::Path::new("C:/tmp/conduit-screenshots");+ let _ = std::fs::create_dir_all(dir);+ let ts = (now_f() * 1000.0) as u64;+ let ext = std::path::Path::new(&src).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "png".into());+ let dst = dir.join(format!("fusion-{safe}-{ts}.{ext}"));+ match std::fs::copy(&src, &dst) { Ok(_) => Some(dst.to_string_lossy().replace('\\', "/")), Err(_) => Some(src) }+}++fn remembered_open_library(state_dir: &std::path::Path) -> Option<String> {+ let t = std::fs::read_to_string(state_dir.join("open_library.json")).ok()?;+ let lp = serde_json::from_str::<Value>(&t).ok()?["libraryPath"].as_str()?.to_string();+ if !lp.is_empty() && std::path::Path::new(&lp).exists() { Some(lp) } else { None }+}++/// `_build_in_flight`: paths whose async 3D build (on the sidecar) is still running, from disk.+fn build_in_flight(state_dir: &std::path::Path) -> std::collections::BTreeMap<String, f64> {+ let t = std::fs::read_to_string(state_dir.join("builds_in_flight.json")).unwrap_or_default();+ let m: std::collections::BTreeMap<String, f64> = serde_json::from_str::<Value>(&t).ok().and_then(|v| v.as_object().map(|o| o.iter().filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f))).collect())).unwrap_or_default();+ m.into_iter().filter(|(_, v)| now_f() - v < 3600.0).collect()+}++/// `_dismiss_dialogs_bg`: WM_CLOSE (Cancel/No) on every Fusion dialog, never a focus steal. Titles dismissed.+fn dismiss_dialogs_bg(ctx: &Ctx) -> Vec<String> {+ let Some(ab) = fusion_core::ab::Loopback::from_env() else { return vec![] };+ use fusion_core::ab::AbClient;+ let rows = ab.call("desktop_enum_windows", json!({"pids": ctx.pids}), &ctx.caller, 10).ok().map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default()).unwrap_or_default();+ let mut out = Vec::new();+ for d in fusion_core::dialogs::qt_dialog_rows(&rows) { if let Some(h) = d["hwnd"].as_i64() { if ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10).is_ok() { out.push(d["title"].as_str().unwrap_or("").to_string()); } } }+ out+}++/// `_create_3d_package`: Electron.Create3DPackage, only once the library is verified active.+pub fn create_3d_package_pub(ctx: &Ctx, lbr_path: &str, pass: &dyn Fn(&str, Value) -> Value) -> Value { create_3d_package(ctx, lbr_path, pass) }+fn create_3d_package(ctx: &Ctx, lbr_path: &str, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let act = ensure_library_active(ctx, lbr_path, 60, pass);+ if act["ok"].as_bool() != Some(true) {+ return json!({"success": false, "errorCode": "library_not_active", "error": "The library opened but never became the ACTIVE document, so Create3DPackage would refuse ('Please run the command in library document').", "_hint": "Another document may be mid-operation on Fusion's main thread. Retry: the bridge polls 60s and activates by name before giving up."});+ }+ pass("execute_text_command", json!({"command": format!("Electron.Create3DPackage {lbr_path}")}))+}++fn capture_3d_view(ctx: &Ctx, pkg: &str, lbr_path: &str, settle: f64, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut entry = json!({"package": pkg, "view": "3d", "path": Value::Null, "ok": false});+ if !lbr_path.is_empty() {+ if let Some(n) = std::fs::read_to_string(lbr_path).ok().and_then(|x| fusion_lbr::artifact_names(&x).ok()) {+ if n.p3d.is_empty() {+ entry["errorCode"] = json!("no_3d_package_bound"); entry["error"] = json!("No <package3d> is bound in that library, so the only thing to photograph is Fusion's grey Placeholder - refusing to return that as the 3D artifact.");+ entry["_hint"] = json!("Run fusion_build_library_3d (or fusion_bind_library_3d) first, then re-capture. A Placeholder looks like a successful 3D view in a screenshot, which is why this refuses instead.");+ return entry;+ }+ }+ }+ let cp = if lbr_path.is_empty() { json!({"success": false, "error": "no libraryPath"}) } else { create_3d_package(ctx, lbr_path, pass) };+ if cp["success"].as_bool() != Some(true) { entry["errorCode"] = json!("create_3d_package_failed"); entry["error"] = json!(format!("Could not enter the Package3D environment: {}", cp["error"].as_str().unwrap_or("None").chars().take(200).collect::<String>())); return entry; }+ if settle > 0.0 { std::thread::sleep(Duration::from_secs_f64(settle)); }+ entry["path"] = json!(capture_labeled(ctx, &format!("{pkg}_3d")));+ entry["ok"] = json!(!entry["path"].is_null());+ let _ = pass("execute_text_command", json!({"command": "Package3DStop"}));+ std::thread::sleep(Duration::from_millis(600));+ let d = dismiss_dialogs_bg(ctx);+ if !d.is_empty() { eprintln!("[fusion-bridge] Package3DStop raised a dialog ({}) - answered in the background; see skills/fusion-no-modal-dialogs", d.join(", ")); }+ entry+}++/// `fusion_capture_library_views`: symbol, footprint, 3D and component views of each part.+pub fn capture_library_views(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let pkgs: Vec<String> = args["packages"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).filter(|v: &Vec<String>| !v.is_empty()).or_else(|| args["package"].as_str().map(|p| vec![p.to_string()])).unwrap_or_default();+ if pkgs.is_empty() { return json!({"success": false, "error": "packages: [<deviceset name>] (or package: <name>) required.", "_hint": "Open the .lbr first (fusion_open_lbr). Names are the deviceset names, e.g. ESP32-S3FN8."}); }+ fn view_ext(v: &str) -> String { match v { "component" | "dev" => "dev", "symbol" | "sym" => "sym", "footprint" | "pac" => "pac", "3d" | "p3d" | "package3d" => "3d", other => other }.to_string() }+ fn label_of(e: &str) -> String { match e { "dev" => "component", "sym" => "symbol", "pac" => "footprint", "3d" => "3d", o => o }.to_string() }+ let views: Vec<String> = args["views"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).filter(|v: &Vec<String>| !v.is_empty()).unwrap_or_else(|| vec!["symbol".into(), "footprint".into(), "3d".into(), "component".into()]);+ let settle = args["settle"].as_f64().unwrap_or(1.5);+ let flight = build_in_flight(&ctx.state_dir);+ if !flight.is_empty() && args["ignoreBuildInProgress"].as_bool() != Some(true) {+ return json!({"success": false, "errorCode": "build_in_progress", "error": format!("A 3D build is still running for {} - refusing to capture, because the build holds the 3D-package editor on screen and every view would show that editor instead of the view it is labelled with.", flight.keys().cloned().collect::<Vec<_>>().join(", ")),+ "buildsInFlight": flight.iter().map(|(k, v)| json!({"lbrPath": k, "runningSec": (now_f() - v) as i64})).collect::<Vec<_>>(),+ "_hint": "Wait for the build to finish, then re-capture - the same call returns correct content once it is done. Detect completion by polling fusion_get_app_state until the <part>_3d document reports isSaved:true, or read the bound .lbr until it has one <package3d wip_urn=...> per part. Pass ignoreBuildInProgress:true only if you genuinely want a mid-build frame."});+ }+ let lbr_path = args["libraryPath"].as_str().map(|s| s.replace('\\', "/")).filter(|s| !s.is_empty()).or_else(|| remembered_open_library(&ctx.state_dir)).unwrap_or_default();+ let mut resolved: std::collections::BTreeMap<(String, String), String> = Default::default();+ let mut name_errors: Vec<Value> = Vec::new();+ if !lbr_path.is_empty() {+ for pkg in &pkgs { for v in &views { let ext = view_ext(v); if ext == "3d" { continue; }+ match resolve_artifact_name(pkg, &ext, &lbr_path) { Ok(r) => { resolved.insert((pkg.clone(), ext.to_string()), r); }, Err(e) => name_errors.push(json!({"package": pkg, "view": label_of(&ext), "error": e["error"], "available": e["available"], "closestMatches": e["closestMatches"]})) } } }+ }+ let mut out: Vec<Value> = Vec::new();+ for pkg in &pkgs {+ for v in &views {+ let ext = view_ext(v);+ let ext = ext.as_str();+ if ext == "3d" { out.push(capture_3d_view(ctx, pkg, &lbr_path, settle, pass)); continue; }+ if !lbr_path.is_empty() && !resolved.contains_key(&(pkg.clone(), ext.to_string())) {+ let bad = name_errors.iter().find(|e| e["package"].as_str() == Some(pkg) && e["view"].as_str() == Some(label_of(ext).as_str())).cloned().unwrap_or(json!({}));+ out.push(json!({"package": pkg, "view": label_of(ext), "path": Value::Null, "ok": false, "skipped": "name_not_in_library", "error": bad["error"], "available": bad["available"].as_array().map(|a| a.iter().take(20).cloned().collect::<Vec<_>>()).unwrap_or_default(), "closestMatches": bad["closestMatches"],+ "_hint": "No EDIT was issued and no dialog was raised - this name is not in the library, so opening it would have popped Fusion's blocking 'Create new ...?' modal. Pick a name from `available`."}));+ continue;+ }+ let name = resolved.get(&(pkg.clone(), ext.to_string())).cloned().unwrap_or_else(|| pkg.clone());+ let er = pass("electron_run", json!({"command": format!("EDIT {name}.{ext}"), "confirm": "no"}));+ std::thread::sleep(Duration::from_secs_f64(settle));+ let dismissed = dismiss_dialogs_bg(ctx);+ if !dismissed.is_empty() {+ eprintln!("[fusion-bridge] PRE-CHECK HOLE: EDIT {name}.{ext} still raised a dialog (lbr_path={lbr_path:?}). Prevention failed; dismissal should not be load-bearing.");+ crate::fusion_prefs::action_ledger(&ctx.state_dir, "dialog_precheck_hole", json!({"name": name, "ext": ext, "lbrPath": lbr_path, "dismissed": dismissed}));+ }+ let _ = pass("electron_run", json!({"command": "WINDOW FIT"}));+ std::thread::sleep(Duration::from_millis(600));+ let shot = capture_labeled(ctx, &format!("{pkg}_{}", label_of(ext)));+ let st = pass("get_app_state", json!({}));+ let editor_ws = st["data"]["activeWorkspace"].as_str().or(st["activeWorkspace"].as_str()).map(str::to_string);+ let editor_ok = match &editor_ws { Some(w) => w.to_lowercase().contains("electronics library"), None => false };+ let mut entry = json!({"package": pkg, "view": label_of(ext), "path": shot, "dialogDismissed": if dismissed.is_empty() { Value::Null } else { json!(dismissed) }, "capturedInWorkspace": editor_ws,+ "ok": er["success"].as_bool().unwrap_or(true) && !entry_null(&shot) && dismissed.is_empty() && editor_ok});+ if !editor_ok && editor_ws.is_none() { entry["errorCode"] = json!("editor_unverified"); entry["error"] = json!("Could not read which editor was active at capture time (the add-in did not answer), so this frame cannot be confirmed to show the view it is labelled with."); entry["_hint"] = json!("Not necessarily a bad frame - it is an unverified one. Check fusion_addin_status and re-capture."); }+ else if !editor_ok { entry["errorCode"] = json!("wrong_editor_captured"); entry["error"] = json!(format!("Captured while {} was active, not the Electronics Library editor - the frame does not show the {} view it is labelled with.", fusion_lbr::py_repr_str(editor_ws.as_deref().unwrap_or("None")), label_of(ext))); }+ out.push(entry);+ }+ }+ let n_ok = out.iter().filter(|o| o["ok"].as_bool() == Some(true)).count();+ json!({"success": n_ok > 0, "captured": out,+ "_hint": format!("Captured {n_ok}/{} library-editor views to C:/tmp/conduit-screenshots (pull with desktop_pull_file). SYMBOL = full pinout; FOOTPRINT = pads + layer stack; COMPONENT = the Content Manager device view with the footprint<->package Mapped check + pin/pad counts. ⚠️ NAMES: the COMPONENT (.dev) view opens by DEVICESET name (e.g. R-0603-10K). But SYMBOL (.sym) and FOOTPRINT (.pac) open by the SYMBOL / PACKAGE name - in a SHARED-FOOTPRINT library (one symbol/footprint per package, many value devicesets) that is the COMBO name (e.g. 'R-0603'), NOT the deviceset name ('R-0603-10K'). Passing a deviceset name to .sym/.pac makes Fusion pop 'Create new symbol/footprint?'; the bridge now auto-dismisses that in the BACKGROUND (Cancel/No, no focus steal - see entries with dialogDismissed) and marks the entry ok:false. Pass libraryPath so names are resolved from the file up front.", out.len())})+}+fn entry_null(v: &Option<String>) -> bool { v.is_none() }++const INSPECT_BODIES_SCRIPT: &str = include_str!("inspect_bodies_script.py");++/// `fusion_open_cloud_file`: the add-in open, then the classified blocker and the post-open capture.+pub fn open_cloud_file(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut r = pass("open_cloud_file", args.clone());+ let settle = if r["success"].as_bool() == Some(true) { 2.0 } else {+ if let Some(ab) = fusion_core::ab::Loopback::from_env() {+ let dialogs = crate::small_verbs::classify_blocking_dialogs(&ab, &ctx.caller, &ctx.pids);+ if !dialogs.is_empty() { r["dialogBlocking"] = json!(true); r["blockingDialogs"] = json!(dialogs); r["hint"] = json!("A modal dialog is blocking Fusion — see blockingDialogs for the classified cause + resolution. The multi-link 'Select Electronics Design File' picker is a CEF dialog that must be resolved on the desktop (its list isn't keyboard/Win32 navigable); recovery prompts use fusion_dismiss_recovery; an update nag must be cleared on the desktop."); }+ }+ 1.0+ };+ let (shots, _) = crate::lifecycle::post_open_screenshot(ctx, settle);+ if !r["data"].is_object() { r["data"] = json!({}); }+ r["data"]["postOpenScreenshot"] = shots;+ r+}++/// `fusion_save_lbr`: the open library as a .flbr through Document.CopyToDesktop.+pub fn save_lbr(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut out = args["outputPath"].as_str().unwrap_or("").replace('\\', "/");+ if out.is_empty() { return json!({"success": false, "error": "No outputPath specified"}); }+ if !out.to_lowercase().ends_with(".flbr") { out.push_str(".flbr"); }+ if let Some(parent) = std::path::Path::new(&out).parent() { if !parent.as_os_str().is_empty() && !parent.exists() { if let Err(e) = std::fs::create_dir_all(parent) { return json!({"success": false, "error": format!("Cannot create directory {}: {e}", parent.to_string_lossy())}); } } }+ let r = pass("execute_text_command", json!({"command": format!("Document.CopyToDesktop {}", ctx.host_paths_short(&out))}));+ if r["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("Document.CopyToDesktop failed: {}", r["error"].as_str().unwrap_or("unknown")), "data": {"outputPath": out}}); }+ let mut result = match std::fs::metadata(&out) { Ok(m) => json!({"success": true, "output": format!("Saved library to {} ({} bytes)", out.rsplit('/').next().unwrap_or(&out), m.len()), "data": {"outputPath": out, "fileSize": m.len()}}), Err(_) => json!({"success": true, "output": "Document.CopyToDesktop executed (file may still be writing)", "data": {"outputPath": out}}) };+ let (shots, _) = crate::lifecycle::post_open_screenshot(ctx, 1.0);+ result["data"]["postOpenScreenshot"] = shots;+ result+}++/// `fusion_cleanup_cloud_files`: delete an exact list of lineage urns, never by name.+pub fn cleanup_cloud_files(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let ids: Vec<String> = args["fileIds"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+ if ids.is_empty() { return json!({"success": false, "error": "fileIds [lineage urns] required.", "_hint": "Get them from fusion_aps_browse {projectId, folderId} (item .id)."}); }+ let (project_name, folder_path) = (args["projectName"].as_str().unwrap_or(""), args["folderPath"].as_str().unwrap_or(""));+ let (mut deleted, mut failed): (Vec<String>, Vec<Value>) = (vec![], vec![]);+ for fid in &ids {+ let r = pass("delete_cloud_file", json!({"fileId": fid, "projectName": project_name, "folderPath": folder_path}));+ if r["success"].as_bool() == Some(true) { deleted.push(fid.clone()); } else { failed.push(json!({"fileId": fid, "error": r["error"].as_str().unwrap_or("").chars().take(90).collect::<String>()})); }+ }+ json!({"success": !deleted.is_empty() || failed.is_empty(), "deletedCount": deleted.len(), "failedCount": failed.len(), "failed": failed.iter().take(25).cloned().collect::<Vec<_>>(),+ "_hint": format!("Deleted {}/{} cloud files by PRECISE lineage urn (no name-guessing, so teammates' files are untouched). NOTE: a large list takes minutes and the relay request may time out at ~60s while the deletes continue server-side - re-browse the folder to confirm the count dropped. Anything in failed[] usually means the file was already gone or you lack delete permission. See the fusion-cloud-hygiene skill.", deleted.len(), ids.len())})+}++/// `fusion_inspect_bodies`: what actually got built, in millimetres.+pub fn inspect_bodies(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let world = args["worldSpace"].as_bool().unwrap_or(false);+ let occ = args["occurrence"].as_str().unwrap_or("");+ let script = INSPECT_BODIES_SCRIPT.replace("__WORLD__", if world { "True" } else { "False" }).replace("__OCC__", &if occ.is_empty() { "None".to_string() } else { fusion_lbr::py_repr_str(occ) });+ let res = pass("run_modeling_script", json!({"script": script}));+ if res["success"].as_bool() != Some(true) { return res; }+ let data = res["data"].clone();+ let Some(mut payload) = data.get("result").filter(|p| p.is_object()).cloned() else { return json!({"success": false, "error": "inspect_bodies returned no structured result", "errorCode": "inspect_failed", "data": data, "_hint": "The read-back script ran but produced no result dict. Usually means no active Design; open a document and retry."}) };+ let n = payload["bodyCount"].as_i64().unwrap_or(0);+ payload["success"] = json!(true);+ payload["_hint"] = json!(if n > 0 { format!("Bounding boxes, volumes and areas are in MILLIMETRES (Fusion's API is centimetres; converted here so you do not have to). Check bbox Z against where the part SHOULD sit - a wrong offset renders perfectly and is invisible in a screenshot. cylindricalFaceCount is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing fails SILENTLY with no exception, and the count is what surfaces it. {}", if !world { "Pass worldSpace:true to resolve bodies through their occurrence transforms and verify assembly placement." } else { "worldSpace:true - occurrence bodies are proxies already resolved into assembly space." }) } else { "No bodies found. If you expected some, the active document may not be the one you built into (pass expectDocument on mutating verbs to catch that), or the design is empty.".to_string() });+ payload+}++const SUPPORTED_DESIGN_EXT: &[&str] = &[".f3d", ".f3z", ".step", ".stp", ".iges", ".igs", ".sat", ".stl", ".3mf", ".obj"];++/// `fusion_open_design`: a local design file (f3d, STEP, IGES, ...) through the add-in's import.+pub fn open_design(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let file_path = args["filePath"].as_str().unwrap_or("").to_string();+ if ctx.fusion_exe.is_none() { return json!({"success": false, "error": "Fusion 360 is not installed on this machine.", "errorCode": "fusion_not_installed", "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - this claim was simply STALE (the bridge silent-installs Fusion via the fusion-onboarding skill and drives the Autodesk sign-in). OFFER to install it for them and do it on a yes, then retry this open."}); }+ if ctx.host.fusion_processes().is_empty() { return json!({"success": false, "error": "Fusion 360 is installed but not running.", "errorCode": "fusion_not_running", "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry this command."}); }+ if file_path.is_empty() { return json!({"success": true, "output": "Fusion 360 is running."}); }+ let p = std::path::Path::new(&file_path);+ if !p.exists() { return json!({"success": false, "error": format!("File not found: {file_path}"), "errorCode": "file_not_found", "_hint": "Verify the path with `ls` or check if the file is in the cloud instead of local — use fusion_walk_cloud_tree to find cloud files, then fusion_open_cloud_file to open them."}); }+ let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy().to_lowercase())).unwrap_or_default();+ if !SUPPORTED_DESIGN_EXT.contains(&ext.as_str()) { return json!({"success": false, "error": format!("Unsupported file type: {ext}. Supported: {}", { let mut v: Vec<&str> = SUPPORTED_DESIGN_EXT.to_vec(); v.sort(); v.join(", ") }), "errorCode": "unsupported_extension", "_hint": "Use a supported extension, or for electronics files (.fsch/.fbrd/.flbr) use fusion_import_electronics instead."}); }+ if !crate::launch::wait_for_addin(10) { return json!({"success": false, "error": "Fusion 360 is running but AdomBridge add-in not responding.", "errorCode": "fusion_addin_not_responding", "_hint": "Fix it YOURSELF - never ask the user: (1) run install_addin() so the add-in lands in EVERY Fusion add-in dir (Fusion moved it to %APPDATA%\\Autodesk\\FusionAddins on 2025+ builds; an add-in in the legacy API\\AddIns dir is silently ignored - issue #63), then (2) restart Fusion via fusion_stop + fusion_start so it rescans."}); }+ pass("import_step", json!({"filePath": file_path}))+}++/// `fusion_new_electronics_from_eagle`: Fusion's own EAGLE importer, its two Open dialogs driven through ab.+pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let sch = args["schPath"].as_str().or(args["filePath"].as_str()).unwrap_or("").replace('\\', "/");+ if sch.is_empty() || !sch.to_lowercase().ends_with(".sch") { return json!({"success": false, "error": "schPath (a .sch file on the Windows host) is required.", "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both to Windows (send_files), then call with {schPath, brdPath}."}); }+ let brd = args["brdPath"].as_str().map(|s| s.replace('\\', "/")).filter(|s| !s.is_empty()).unwrap_or_else(|| format!("{}.brd", &sch[..sch.len() - 4]));+ let Some(ab) = fusion_core::ab::Loopback::from_env() else {+ return json!({"success": false, "errorCode": "use_open_board_instead", "error": "ab direct API unavailable - and this dialog-driven import FOREGROUNDS Fusion anyway.",+ "_hint": format!("Don't drive the Open dialogs. If the parts are placed in the .brd (elements + embedded library), call `fusion_open_board {{filePath:\"{brd}\"}}` - it instantiates them via newDesignFromLocal with NO dialogs and NO foreground (the background way). For 3D bodies, embed <packages3d> + per-element package3d_urn in the .brd (see this verb's docstring), then fusion_show_3d_board. Only if you truly have a bare .sch that Fusion must import: fire 'Commands.Start ImportSCHAndBRDCmd' via fusion_execute_text_command and drive the two Open dialogs (foreground; last resort).")});+ };+ use fusion_core::ab::AbClient;+ let find_open_dialog = || -> Option<i64> { ab.call("desktop_list_windows", json!({}), &ctx.caller, 10).ok().and_then(|v| v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned()).unwrap_or_default().iter().find(|w| w["title"].as_str().unwrap_or("").trim().to_lowercase() == "open").and_then(|w| w["hwnd"].as_i64()) };+ let (sch_base, brd_base) = (sch.rsplit('/').next().unwrap_or("").to_string(), brd.rsplit('/').next().unwrap_or("").to_string());+ let _ = pass("execute_text_command", json!({"command": "Commands.Start ImportSCHAndBRDCmd"}));+ let mut picked: Vec<String> = Vec::new();+ for (want, base) in [("sch", &sch_base), ("brd", &brd_base)] {+ let mut dlg = None;+ for _ in 0..20 { std::thread::sleep(Duration::from_millis(1500)); dlg = find_open_dialog(); if dlg.is_some() { break; } }+ let Some(h) = dlg else {+ if want == "brd" && !picked.is_empty() { break; }+ return json!({"success": false, "error": format!("The '{want}' Open dialog never appeared."), "_hint": "Screenshot the Fusion hwnd + READ screenshots[] for a blocking modal; the importer may have errored on the EAGLE source. Verify the .sch/.brd are valid EAGLE.", "data": {"picked": picked}});+ };+ let _ = ab.call("desktop_ui_click", json!({"hwnd": h, "name": base}), &ctx.caller, 15); std::thread::sleep(Duration::from_secs(1)); let _ = ab.call("desktop_ui_click", json!({"hwnd": h, "name": "Open"}), &ctx.caller, 15);+ picked.push(base.clone());+ std::thread::sleep(Duration::from_secs(2));+ }+ let mut ready = false;+ for _ in 0..40 {+ std::thread::sleep(Duration::from_secs(3));+ let st = pass("get_app_state", json!({}));+ let d = &st["data"];+ if d["isElectronics"].as_bool() == Some(true) && ["pcb editor", "schematic editor", "board layout", "3d pcb"].contains(&d["activeWorkspace"].as_str().unwrap_or("").to_lowercase().as_str()) { ready = true; break; }+ }+ json!({"success": ready, "imported": picked, "statusVerb": "fusion_board_info",+ "_hint": if ready { "EAGLE design imported via Fusion's own ImportSCHAndBRDCmd (both Open dialogs driven in the background). It is now an electronics design in the PCB editor. NEXT: fusion_show_2d_board, then RATSNEST + 'AUTO ;' (fusion_electron_run) to autoroute, fusion_show_3d_board for the 3D. NOTE: board_info can read 0 right after import (wrong-view query) even though the board is populated - verify by screenshotting the Fusion hwnd (read screenshots[]) or fusion_show_2d_board + WINDOW FIT. If 3D bodies are missing, the placed parts' package3d urns must resolve in THIS project - build the library's 3D into this project with fusion_build_library_3d (do NOT reuse another library's urns; they don't resolve cross-project)." } else { "Import fired + both Open dialogs driven, but the design did not confirm as electronics within the budget (a slow VM import can run longer). Poll fusion_get_app_state, and screenshot the Fusion hwnd reading screenshots[] for a blocking import dialog." }})+}++const READONLY_OPEN_NOTE: &str = "NOTE: a 'Read Only'/expired/trial Fusion still opens+views files — if it never opens, debug the open path (URN/picker/slow assembly), NOT the license. Only save/export are blocked in read-only.";++/// `fusion_aps_open`: search the cloud by name (APS), then open the best match in Fusion.+pub fn aps_open(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let query = args["query"].as_str().or(args["fileName"].as_str()).unwrap_or("").trim().to_string();+ if query.is_empty() { return json!({"success": false, "error": "Missing query/fileName."}); }+ let res = crate::native_aps_search(&json!({"query": query, "limit": 5}));+ let Some(m) = res["data"]["results"].as_array().and_then(|a| a.first().cloned()) else { return json!({"success": false, "error": format!("No cloud file matching '{query}'."), "_hint": format!("Run fusion_aps_search to see candidates, or refine the query. {READONLY_OPEN_NOTE}")}) };+ let name = m["name"].as_str().unwrap_or("").to_string();+ let open_args = json!({"projectName": m["projectName"], "fileName": name});+ if ctx.fusion_exe.is_none() || ctx.host.fusion_processes().is_empty() {+ return json!({"success": true, "output": format!("Found '{name}' in project {}.", crate::fusion_prefs::py_str(&m["projectName"])), "data": {"match": m, "openArgs": open_args}, "_hint": "Fusion isn't running — call fusion_start, then fusion_aps_open again to open it."});+ }+ let urn = m["id"].as_str().unwrap_or("").to_string();+ if args["wait"].as_bool() == Some(true) {+ let mut r = pass("open_by_urn", json!({"urn": urn}));+ if r["success"].as_bool() != Some(true) { let _ = pass("open_cloud_file", open_args.clone()); }+ if !r["data"].is_object() { r["data"] = json!({}); }+ r["data"]["match"] = m;+ if r["success"].as_bool() == Some(true) { let (shots, _) = crate::lifecycle::post_open_screenshot(ctx, 2.0); r["data"]["postOpenScreenshot"] = shots; }+ return r;+ }+ // the background open: a thread with its own context (the borrowed one cannot cross)+ let (sd, caller, urn2, oa) = (ctx.state_dir.clone(), ctx.caller.clone(), urn.clone(), open_args.clone());+ std::thread::spawn(move || {+ let host = crate::host_for_diagnosis();+ let side_url = crate::launch::SIDECAR_URL.lock().unwrap().clone();+ let side = move |verb: &str, a: Value, t: u64| -> Value { match &side_url { Some(u) => ureq::post(u).timeout(Duration::from_secs(t)).send_json(json!({"command": verb, "args": a})).ok().and_then(|r| r.into_json().ok()).unwrap_or(json!({"success": false})), None => json!({"success": false}) } };+ let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx2 = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd, caller, pids, sidecar: &side, dialogs: &dlg };+ let pass2 = |verb: &str, a: Value| crate::addin::pass_through(verb, &a, &ctx2);+ let r = pass2("open_by_urn", json!({"urn": urn2}));+ if r["success"].as_bool() != Some(true) { let _ = pass2("open_cloud_file", oa); }+ });+ json!({"success": true, "output": format!("Found '{name}' in project {} — opening in Fusion.", crate::fusion_prefs::py_str(&m["projectName"])), "data": {"match": m, "opening": true}, "statusVerb": "fusion_get_app_state",+ "_hint": format!("The cloud file is opening in Fusion in the background (first open can take ~60-90s; large ASSEMBLIES download all referenced parts and take longer). Poll fusion_get_app_state until activeDocument is the file. Pass {{\"wait\": true}} to block instead. {READONLY_OPEN_NOTE}")})+}
rust/bridge/src/prove.rsadded+268@@ -0,0 +1,268 @@+//! `fusion_prove_library` and the thumbnail check, native. The verdict is decided by what EXISTS+//! in the .lbr (offline, the lbr crate); the four frames are evidence attached to that verdict:+//! symbol editor, footprint editor, the bound f3d in the 3D canvas (bodies counted, not just+//! photographed), and the ganged Library Manager view. Each frame is measured and its workspace+//! stamped, and streams to `_frameSink` the instant it lands.++use serde_json::{json, Value};+use std::time::Duration;++use crate::addin::Ctx;++fn expected_workspace(view: &str) -> Option<&'static str> { match view { "symbol-editor" | "footprint-editor" | "library-manager" => Some("Electronics Library"), "3d-editor" => Some("Design"), _ => None } }++/// `_image_dimensions`: PNG or WebP, from the header.+pub fn image_dimensions(path: &str) -> Option<(u32, u32)> {+ let mut f = std::fs::File::open(path).ok()?;+ let mut head = [0u8; 64];+ use std::io::Read;+ let n = f.read(&mut head).ok()?;+ if n < 30 { return None; }+ if &head[..8] == b"\x89PNG\r\n\x1a\n" { return Some((u32::from_be_bytes([head[16], head[17], head[18], head[19]]), u32::from_be_bytes([head[20], head[21], head[22], head[23]]))); }+ if &head[..4] == b"RIFF" && &head[8..12] == b"WEBP" {+ match &head[12..16] {+ b"VP8 " => return Some(((u16::from_le_bytes([head[26], head[27]]) & 0x3FFF) as u32, (u16::from_le_bytes([head[28], head[29]]) & 0x3FFF) as u32)),+ b"VP8L" => { let b = &head[21..25]; return Some((1 + (((b[1] as u32 & 0x3F) << 8) | b[0] as u32), 1 + (((b[3] as u32 & 0xF) << 10) | ((b[2] as u32) << 2) | ((b[1] as u32 & 0xC0) >> 6)))); }+ b"VP8X" => return Some((1 + u32::from_le_bytes([head[24], head[25], head[26], 0]), 1 + u32::from_le_bytes([head[27], head[28], head[29], 0]))),+ _ => {}+ }+ }+ None+}++/// `_frame_data_url`: the frame inline when small enough (no downscale without PIL, same as Python).+pub fn frame_data_url(path: &str) -> String {+ let Ok(raw) = std::fs::read(path) else { return String::new() };+ if raw.len() > 300_000 { return String::new(); }+ let mime = if path.to_lowercase().ends_with(".webp") { "image/webp" } else { "image/png" };+ format!("data:{mime};base64,{}", crate::etiquette::b64_pub(&raw))+}++/// `_prove_capture`: settle, capture, MEASURE, stamp the workspace.+pub fn prove_capture(ctx: &Ctx, label: &str, view: &str, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ for _ in 0..40 { if pass("get_app_state", json!({}))["success"].as_bool() == Some(true) { break; } std::thread::sleep(Duration::from_secs(3)); }+ std::thread::sleep(Duration::from_secs(2));+ let mut out = json!({"step": label, "view": view, "ok": false, "screenshot": Value::Null});+ let wsr = pass("execute_text_command", json!({"command": "DebugCommands.ActiveWorkspace"}));+ let wsv = wsr["data"]["output"].as_str().or(wsr["data"]["result"].as_str()).unwrap_or("").trim().to_string();+ if !wsv.is_empty() { out["activeWorkspace"] = json!(wsv.chars().take(60).collect::<String>()); }+ let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller);+ if shot["success"].as_bool() == Some(true) {+ if let Some(p) = shot["savedTo"].as_str().map(str::to_string) {+ out["screenshot"] = json!(p);+ let (mut w, mut h) = (shot["fullWidth"].as_u64().map(|v| v as u32), shot["fullHeight"].as_u64().map(|v| v as u32));+ if w.is_none() || h.is_none() { if let Some((iw, ih)) = image_dimensions(&p) { w = Some(iw); h = Some(ih); } }+ out["width"] = json!(w); out["height"] = json!(h);+ match (w, h) {+ (Some(w), Some(h)) if w >= 400 && h >= 300 => { out["ok"] = json!(true); }+ (Some(w), Some(h)) => { out["error"] = json!(format!("wrong_window_captured: {w}x{h} is not the Fusion editor (a title-bar sliver or a stray dialog)")); }+ _ => { out["error"] = json!("frame could not be measured - not accepted as evidence"); }+ }+ let stamp = out["activeWorkspace"].as_str().map(str::to_string);+ if let Some(exp) = expected_workspace(view) {+ match &stamp {+ Some(s) if out["ok"].as_bool() == Some(true) && s.to_lowercase() != exp.to_lowercase() => { out["ok"] = json!(false); out["errorCode"] = json!("wrong_workspace"); out["error"] = json!(format!("wrong_workspace: the {view} frame was captured in the {} workspace, expected {} - Fusion was showing a different editor when the shot was taken, so this is not evidence of the {label} view.", fusion_lbr::py_repr_str(s), fusion_lbr::py_repr_str(exp))); }+ None if out["ok"].as_bool() == Some(true) => { out["workspaceStamp"] = json!("missing (not gated)"); }+ _ => {}+ }+ }+ let du = frame_data_url(&p);+ if !du.is_empty() { out["thumbnailDataUrl"] = json!(du); }+ }+ }+ out+}++/// `_count_3d_bodies`: how many REAL bodies the active design holds (a state check).+pub fn count_3d_bodies(pass: &dyn Fn(&str, Value) -> Value) -> (i64, f64) {+ let script = "import adsk.core, adsk.fusion\nd = app.activeProduct\nn = 0; vol = 0.0\ntry:\n root = d.rootComponent\n for occ in [None] + list(root.allOccurrences):\n comp = root if occ is None else occ.component\n for b in comp.bRepBodies:\n n += 1\n bb = b.boundingBox\n dx = (bb.maxPoint.x - bb.minPoint.x) * 10\n dy = (bb.maxPoint.y - bb.minPoint.y) * 10\n dz = (bb.maxPoint.z - bb.minPoint.z) * 10\n vol += abs(dx * dy * dz)\nexcept Exception:\n n = -1\nresult = {'bodies': n, 'bboxMm3': round(vol, 2)}\n";+ let r = pass("run_modeling_script", json!({"script": script}));+ let data = if r["data"]["result"].is_object() { r["data"]["result"].clone() } else { r["data"].clone() };+ if r["success"].as_bool() == Some(true) && data.get("bodies").is_some() { return (data["bodies"].as_i64().unwrap_or(-1), data["bboxMm3"].as_f64().unwrap_or(0.0)); }+ (-1, 0.0)+}++fn norm(s: &str) -> String { s.to_lowercase().chars().filter(|c| c.is_alphanumeric()).collect() }+fn vf_token(u: &str) -> String { let u = u.split('?').next().unwrap_or(""); if u.contains("vf.") { u.rsplit("vf.").next().unwrap_or("").to_string() } else { u.to_string() } }++/// `aps.thumbnail_state`: is the cloud thumbnail rendered for the bound file?+pub fn thumbnail_state(project_id: &str, folder_id: &str, wip_urn: &str, file_name: &str) -> Value {+ if project_id.is_empty() || folder_id.is_empty() { return json!({"state": "error", "why": "projectId and folderId required"}); }+ let store = fusion_core::aps::ApsStore::default_location();+ let want_urn = wip_urn.split('?').next().unwrap_or("").trim().to_string();+ let (status, data) = match fusion_core::aps::aps_get(&store, &format!("/data/v1/projects/{project_id}/folders/{folder_id}/contents"), &[("include", "versions")]) { Ok(x) => x, Err(_) => return json!({"state": "not_signed_in"}) };+ if status != 200 || !data.is_object() { return json!({"state": "error", "http": status, "why": data["error"]}); }+ let items = data["data"].as_array().cloned().unwrap_or_default();+ let included = data["included"].as_array().cloned().unwrap_or_default();+ let want_tok = vf_token(&want_urn);+ let fname = norm(file_name);+ let hit_version = included.iter().find(|v| { let vid = vf_token(v["id"].as_str().unwrap_or("")); let vname = norm(v["attributes"]["displayName"].as_str().unwrap_or("")); let name_hit = !fname.is_empty() && (vname.starts_with(&fname) || vname.contains(&fname)); (!want_tok.is_empty() && vid == want_tok) || name_hit }).cloned();+ let hit_item = if hit_version.is_none() && !file_name.is_empty() { items.iter().find(|it| { let nm = norm(it["attributes"]["displayName"].as_str().unwrap_or("")); nm.starts_with(&fname) || nm.contains(&fname) }).cloned() } else { None };+ let Some(src) = hit_version.or(hit_item) else { return json!({"state": "not_found", "searched": {"urn": want_urn, "name": file_name}, "itemCount": items.len()}) };+ let name = src["attributes"]["displayName"].as_str().unwrap_or("").to_string();+ match src["relationships"]["thumbnails"]["data"]["id"].as_str() { Some(tid) => json!({"state": "ready", "thumbnailId": tid, "item": name}), None => json!({"state": "pending", "item": name, "why": "version exists but Fusion's cloud has not rendered a thumbnail yet"}) }+}++/// `fusion_check_3d_thumbnail`.+pub fn check_3d_thumbnail(args: &Value) -> Value {+ let mut urn = args["urn"].as_str().unwrap_or("").trim().to_string();+ let lbr = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ let mut file_name = args["fileName"].as_str().unwrap_or("").to_string();+ if urn.is_empty() && !lbr.is_empty() {+ if let Ok(t) = std::fs::read_to_string(&lbr) {+ for c in regex::Regex::new(r#"<package3d\b([^>]*)>"#).unwrap().captures_iter(&t) {+ let attrs = &c[1];+ let get = |k: &str| regex::Regex::new(&format!(r#"\b{k}="([^"]*)""#)).unwrap().captures(attrs).map(|m| m[1].to_string()).unwrap_or_default();+ let u = get("urn"); let w = get("wip_urn");+ let pick = if !u.trim().is_empty() { u.trim().to_string() } else { w.trim().to_string() };+ if !pick.is_empty() { urn = pick; }+ if file_name.is_empty() { file_name = get("name"); }+ }+ }+ }+ if urn.is_empty() && file_name.is_empty() { return json!({"success": false, "errorCode": "no_3d_reference", "error": "No urn/fileName given and the .lbr carries no package3d urn.", "_hint": "Bind first (fusion_bind_library_3d), then check the thumbnail."}); }+ let (project_id, folder_id) = crate::mint::discover_upload_target_pub(args);+ let Some(folder_id) = folder_id else { return json!({"success": false, "errorCode": "no_work_folder", "error": "Cannot resolve the upload folder to look in (APS signed in?)."}) };+ let deadline = std::time::Instant::now() + Duration::from_secs_f64(args["waitSeconds"].as_f64().unwrap_or(0.0));+ let st = loop { let st = thumbnail_state(&project_id, &folder_id, &urn, &file_name); if st["state"].as_str() == Some("ready") || std::time::Instant::now() >= deadline { break st; } std::thread::sleep(Duration::from_secs(10)); };+ let ready = st["state"].as_str() == Some("ready");+ json!({"success": ready, "thumbnail": st, "urn": if urn.is_empty() { Value::Null } else { json!(urn) },+ "_hint": if ready { "Thumbnail is RENDERED in the cloud - the Library Manager will show the real chip.".to_string() } else { format!("Not rendered yet (state={}). Fusion uploads + renders asynchronously after a bind - re-call with waitSeconds:180 to poll, and treat a still-pending thumbnail as NOT proven.", st["state"].as_str().unwrap_or("None")) }})+}++/// `fusion_prove_library`.+pub fn prove_library(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let sink = args["_frameSink"].as_str().unwrap_or("").trim().to_string();+ let emit = |e: Value| -> Value { if !sink.is_empty() { use std::io::Write; if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&sink) { let _ = writeln!(f, "{e}"); } } e };+ let lbr = args["libraryPath"].as_str().or(args["filePath"].as_str()).map(|s| s.replace('\\', "/")).filter(|s| !s.is_empty()).unwrap_or_else(|| crate::small_verbs::get_pref(&ctx.state_dir, "lastLibraryPath").and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default());+ if lbr.is_empty() { return json!({"success": false, "error": "libraryPath required (or open a library first).", "errorCode": "library_path_required", "_hint": "If a library IS open on screen, the bridge probably respawned since it was opened (ab auto-updates on publish, even mid-run) and forgot it. Pass libraryPath explicitly - for a bundle install it is C:/Users/<user>/Documents/AdomLibraries/<bundle>/<Library>.lbr."}); }+ let xml = std::fs::read_to_string(&lbr).unwrap_or_default();+ let Some(names) = fusion_lbr::artifact_names(&xml).ok().filter(|n| !(n.sym.is_empty() && n.pac.is_empty() && n.dev.is_empty())) else { return json!({"success": false, "error": format!("Could not read or parse that .lbr: {lbr}"), "errorCode": "library_unreadable"}) };+ let want = args["name"].as_str().unwrap_or("").trim().to_string();+ let want = if want.is_empty() { names.dev.first().cloned().unwrap_or_default() } else { want };+ let mut artifacts = json!({+ "symbol": {"present": !names.sym.is_empty(), "names": names.sym.iter().take(10).cloned().collect::<Vec<_>>()},+ "footprint": {"present": !names.pac.is_empty(), "names": names.pac.iter().take(10).cloned().collect::<Vec<_>>()},+ "package3d": {"present": !names.p3d.is_empty(), "names": names.p3d.iter().take(10).cloned().collect::<Vec<_>>()},+ "deviceset": {"present": !names.dev.is_empty(), "names": names.dev.iter().take(10).cloned().collect::<Vec<_>>()}});+ let mut missing: Vec<String> = ["symbol", "footprint", "package3d"].iter().filter(|k| artifacts[*k]["present"].as_bool() != Some(true)).map(|s| s.to_string()).collect();+ let conn = fusion_lbr::check_connects(&xml);+ artifacts["connects"] = json!(conn);+ if artifacts["symbol"]["present"].as_bool() == Some(true) && artifacts["footprint"]["present"].as_bool() == Some(true) && !conn.ok { missing.push(format!("connects({})", conn.why.clone().unwrap_or_else(|| "invalid".into()))); }+ let opened = crate::orchestrations::open_lbr(ctx, &json!({"filePath": lbr}), pass);+ if opened["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("open_lbr failed: {}", opened["error"].as_str().unwrap_or("None")), "errorCode": "library_open_failed", "artifacts": artifacts}); }+ crate::small_verbs::set_pref(&ctx.state_dir, "lastLibraryPath", json!(lbr));+ let mut evidence: Vec<Value> = Vec::new();+ let skip: Vec<String> = args["_skipSteps"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+ let shared_with = args["_sharedWith"].clone();+ let mut pkgs: Vec<String> = names.dev_pac.get(&want).cloned().unwrap_or_default();+ let mut pkg_fallback_note: Option<String> = None;+ if pkgs.is_empty() {+ if names.pac.len() == 1 { pkgs = names.pac.clone(); pkg_fallback_note = Some(format!("deviceset {} is not in this library; it has exactly one package ({}), so the footprint was proved against that", fusion_lbr::py_repr_str(&want), names.pac[0])); }+ else if names.pac.contains(&want) { pkgs = vec![want.clone()]; }+ }+ let pk_iter: Vec<Option<String>> = if pkgs.is_empty() { vec![None] } else { pkgs.iter().cloned().map(Some).collect() };+ if !skip.iter().any(|s| s == "symbol") {+ let r = crate::orchestrations::show_library_view(ctx, &want, "sym", Some(&lbr), pass);+ if r["success"].as_bool() != Some(true) { evidence.push(emit(json!({"step": "symbol", "view": "symbol-editor", "ok": false, "error": r["error"].as_str().unwrap_or("None").chars().take(160).collect::<String>()}))); }+ else { evidence.push(emit(prove_capture(ctx, "symbol", "symbol-editor", pass))); }+ }+ if !skip.iter().any(|s| s == "footprint") {+ for pk in &pk_iter {+ let Some(pk) = pk else {+ evidence.push(emit(json!({"step": "footprint", "view": "footprint-editor", "ok": false, "package": Value::Null, "error": format!("Cannot resolve a package to prove: {} is neither a deviceset nor a package in this library.", fusion_lbr::py_repr_str(&want)),+ "_hint": format!("Pass the DEVICESET name (the part), not the library name. This library has devicesets {} and packages {}.", py_list(&names.dev, 8), py_list(&names.pac, 8))})));+ continue;+ };+ let r = crate::orchestrations::show_library_view(ctx, pk, "pac", Some(&lbr), pass);+ if r["success"].as_bool() != Some(true) { evidence.push(emit(json!({"step": "footprint", "view": "footprint-editor", "ok": false, "package": pk, "error": r["error"].as_str().unwrap_or("None").chars().take(160).collect::<String>()}))); continue; }+ let mut ef = prove_capture(ctx, "footprint", "footprint-editor", pass); ef["package"] = json!(pk);+ if let Some(n) = &pkg_fallback_note { ef["_note"] = json!(n); }+ evidence.push(emit(ef));+ }+ }+ let p3d_present = artifacts["package3d"]["present"].as_bool() == Some(true);+ if p3d_present && !skip.iter().any(|s| s == "package3d") {+ for pk in &pk_iter {+ let (mut p3d_urn, mut p3d_pkg) = (String::new(), String::new());+ if let Some(p) = pk { p3d_pkg = p.clone(); } else if let Ok(doc) = roxmltree::Document::parse_with_options(&xml, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) {+ if let Some(ds) = doc.descendants().find(|n| n.has_tag_name("deviceset") && n.attribute("name").map(|v| v.to_lowercase() == want.to_lowercase()).unwrap_or(false)) {+ if let Some(dv) = ds.descendants().find(|n| n.has_tag_name("device") && n.attribute("package").map(|p| !p.is_empty()).unwrap_or(false)) { p3d_pkg = dv.attribute("package").unwrap_or("").to_string(); }+ }+ }+ if let Ok(doc) = roxmltree::Document::parse_with_options(&xml, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) {+ let all3: Vec<roxmltree::Node> = doc.descendants().filter(|n| n.has_tag_name("package3d")).collect();+ for p3 in &all3 {+ let insts: Vec<String> = p3.descendants().filter(|n| n.has_tag_name("packageinstance")).map(|n| n.attribute("name").unwrap_or("").to_lowercase()).collect();+ if !p3d_pkg.is_empty() && insts.contains(&p3d_pkg.to_lowercase()) { p3d_urn = p3.attribute("urn").filter(|u| !u.trim().is_empty()).or(p3.attribute("wip_urn")).unwrap_or("").trim().to_string(); break; }+ }+ if p3d_urn.is_empty() && all3.len() == 1 { p3d_urn = all3[0].attribute("urn").filter(|u| !u.trim().is_empty()).or(all3[0].attribute("wip_urn")).unwrap_or("").trim().to_string(); }+ }+ if p3d_urn.is_empty() && p3d_pkg.is_empty() { if let Some(u) = args["f3dUrn"].as_str().map(str::trim).filter(|s| !s.is_empty()) { p3d_urn = u.to_string(); } }+ let mut opened3d = Value::Null;+ if !p3d_urn.is_empty() {+ for attempt in 0..3 { opened3d = pass("open_by_urn", json!({"urn": p3d_urn})); if opened3d["success"].as_bool() == Some(true) { break; } if attempt < 2 { eprintln!("[fusion-bridge] bound f3d not openable yet (attempt {}) - waiting 15s for the lineage to commit", attempt + 1); std::thread::sleep(Duration::from_secs(15)); } }+ }+ if opened3d["success"].as_bool() == Some(true) {+ let wsfix = pass("run_modeling_script", json!({"script": "import adsk.core, time\napp=adsk.core.Application.get()\ndef _ws():\n try: return app.executeTextCommand('DebugCommands.ActiveWorkspace').strip()\n except Exception: return ''\nbefore=_ws()\nif 'design' not in before.lower():\n try:\n w=app.userInterface.workspaces.itemById('FusionSolidEnvironment')\n if w: w.activate()\n adsk.doEvents(); time.sleep(2)\n except Exception: pass\ntry:\n app.activeViewport.goHome(False)\n app.activeViewport.fit()\nexcept Exception: pass\nresult={'before': before, 'after': _ws()}\n"}));+ let ws_after = wsfix["data"]["result"]["after"].as_str().unwrap_or("").to_string();+ let doc3 = pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").replace('*', "").trim().to_string();+ let mut e3 = prove_capture(ctx, "package3d", "3d-editor", pass);+ e3["package"] = json!(pk); e3["activeDocument"] = json!(doc3);+ e3["expectedDocument"] = if p3d_pkg.is_empty() { Value::Null } else { json!(format!("{p3d_pkg}_3d")) };+ if !p3d_pkg.is_empty() && !doc3.is_empty() && !doc3.to_lowercase().contains(&format!("{p3d_pkg}_3d").to_lowercase()) { e3["ok"] = json!(false); e3["error"] = json!(format!("wrong_document_captured: the 3D frame shows {}, not this part's design {}", fusion_lbr::py_repr_str(&doc3), fusion_lbr::py_repr_str(&format!("{p3d_pkg}_3d")))); }+ e3["activeWorkspace"] = json!(ws_after);+ if !ws_after.is_empty() && !ws_after.to_lowercase().contains("design") { e3["ok"] = json!(false); e3["error"] = json!(format!("package3d frame captured in workspace {}, not the Design 3D canvas - a 2D package sheet is not evidence of a 3D chip (the workspace switch did not take).", fusion_lbr::py_repr_str(&ws_after))); }+ let (bodies, bbox) = count_3d_bodies(pass);+ e3["bodies"] = json!(bodies); e3["bboxMm3"] = json!(bbox);+ if bodies == 0 { e3["ok"] = json!(false); e3["error"] = json!("The bound f3d opened but contains NO bodies - an empty viewport is not evidence of a 3D chip."); }+ else if bodies < 0 { e3["bodiesUnverified"] = json!(true); }+ evidence.push(emit(e3));+ let _ = pass("run_modeling_script", json!({"script": "import adsk.core, time\napp=adsk.core.Application.get()\nfor _ in range(24):\n if not app.hasActiveJobs: break\n time.sleep(5)\napp.activeDocument.close(False)\nresult={}"}));+ } else {+ evidence.push(emit(json!({"step": "package3d", "view": "3d-editor", "ok": false, "error": format!("Could not open the bound f3d ({}): {}", if p3d_urn.is_empty() { "no urn".to_string() } else { p3d_urn.chars().rev().take(30).collect::<Vec<_>>().into_iter().rev().collect() }, opened3d["error"].as_str().unwrap_or("None").chars().take(100).collect::<String>())})));+ }+ }+ }+ if !p3d_present && !skip.iter().any(|s| s == "package3d") { evidence.push(emit(json!({"step": "package3d", "view": "3d-editor", "ok": false, "error": "No package3d bound - the 3D editor would only show Fusion's grey Placeholder, which is not evidence."}))); }+ let reopen = crate::orchestrations::open_lbr(ctx, &json!({"filePath": lbr}), pass);+ if reopen["success"].as_bool() == Some(true) { let _ = crate::orchestrations::ensure_library_active(ctx, &lbr, 45, pass); }+ let r = crate::orchestrations::show_library_view(ctx, &want, "dev", Some(&lbr), pass);+ if r["success"].as_bool() == Some(true) { evidence.push(emit(prove_capture(ctx, "ganged", "library-manager", pass))); }+ else { evidence.push(json!({"step": "ganged", "view": "library-manager", "ok": false, "error": r["error"].as_str().unwrap_or("None").chars().take(160).collect::<String>()})); }+ let mut thumb_unverified: Option<&str> = None;+ if p3d_present {+ let thumb = check_3d_thumbnail(&json!({"libraryPath": lbr, "waitSeconds": 180}));+ artifacts["package3d"]["thumbnail"] = thumb["thumbnail"]["state"].clone();+ if thumb["success"].as_bool() != Some(true) {+ if thumb["errorCode"].as_str() == Some("no_work_folder") { thumb_unverified = Some("aps_unavailable"); artifacts["package3d"]["thumbnail"] = json!("unverified(aps_unavailable)"); }+ else { missing.push(format!("thumbnail({})", thumb["thumbnail"]["state"].as_str().or(thumb["errorCode"].as_str()).unwrap_or("unknown"))); }+ }+ }+ let captured: Vec<&Value> = evidence.iter().filter(|e| e["ok"].as_bool() == Some(true) && e["screenshot"].is_string()).collect();+ for e in &evidence { if !(e["ok"].as_bool() == Some(true) && e["screenshot"].is_string()) { missing.push(format!("frame:{}({})", e["step"].as_str().unwrap_or(""), crate::fusion_prefs::py_str(&e["error"]))); } }+ let npk = pkgs.len().max(1);+ let has = |s: &str| skip.iter().any(|x| x == s);+ let expected = (if has("symbol") { 0 } else { 1 }) + (if has("ganged") { 0 } else { 1 }) + (if has("footprint") { 0 } else { npk }) + (if has("package3d") { 0 } else { npk });+ let proven = missing.is_empty() && captured.len() == expected;+ let n_captured = captured.len();+ let mut skip_sorted = skip.clone(); skip_sorted.sort();+ let mut out = json!({"success": proven, "proven": proven, "libraryPath": lbr, "deviceset": want, "artifacts": artifacts, "missing": missing, "stepsSkipped": skip_sorted, "sharedPackageProof": shared_with, "evidence": evidence, "imagesCaptured": n_captured, "imagesRequired": 4});+ if let Some(t) = thumb_unverified { out["thumbnailUnverified"] = json!(t); out["_thumbnailNote"] = json!("The cloud thumbnail could not be observed (APS not signed in on this box) - the 4-frame local evidence stands, but verify the Library Manager preview by eye or sign in APS and run fusion_check_3d_thumbnail."); }+ let stem = lbr.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();+ crate::chains::note_bridge_doc_pub(&ctx.state_dir, &stem);+ out["scratchDocClosed"] = json!(crate::orchestrations::close_bridge_doc_verified(ctx, &stem, 30, pass)["closed"].as_bool().unwrap_or(false));+ let _ = std::fs::write(std::env::temp_dir().join("fusion-prove-last.json"), out.to_string());+ if proven { out["_hint"] = json!("PROVEN: all three artifacts exist and all FOUR evidence frames were captured app-sized - symbol editor, footprint editor, 3D editor, and the ganged Library Manager view with pin connections. Attach evidence[].screenshot in that order."); }+ else {+ let mut why = Vec::new();+ let m: Vec<String> = out["missing"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+ if !m.is_empty() { why.push(format!("missing artifacts: {}", m.join(", "))); }+ if n_captured != 4 { why.push(format!("only {n_captured} of 4 evidence frames captured")); }+ out["error"] = json!(format!("NOT proven - {}", why.join("; "))); out["errorCode"] = json!("library_not_proven");+ out["_hint"] = json!("Do NOT present these frames as proof. A missing package3d renders as a grey Placeholder that photographs like a real chip, and a frame that failed its size check is not evidence of anything. Bind the 3D with fusion_show_3d_package {stepPath} and re-run.");+ }+ out+}++fn py_list(v: &[String], n: usize) -> String { format!("[{}]", v.iter().take(n).map(|s| fusion_lbr::py_repr_str(s)).collect::<Vec<_>>().join(", ")) }
rust/bridge/src/readiness_exec.rsadded+170@@ -0,0 +1,170 @@+//! The readiness executors, native: the seat resolver, the recovery dismissal and the hang+//! verdict. Ported from server.py (`_resolve_seat_dialog`, `_confirmed_hung`), handlers/+//! dismiss_recovery.py and handlers/fusion_ui.py (`is_fusion_hung`). UIA and window closes go+//! through ab; the two Win32 reads live in os-win behind HostProcess.++use serde_json::{json, Value};+use std::time::{Duration, Instant};++use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};++fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++/// `_owned_popup_count`: ab's screenshot signal, -1 when unavailable.+fn owned_popup_count(ab: &dyn AbClient, ctx: &Ctx, main_hwnd: i64) -> i64 {+ ab.call("desktop_screenshot_window", json!({"hwnd": main_hwnd}), &ctx.caller, 8).ok().and_then(|r| r["ownedPopupCount"].as_i64().or(r["data"]["ownedPopupCount"].as_i64())).unwrap_or(-1)+}++/// `_resolve_seat_dialog`: Continue (or Check again) through UIA in the background, verified by+/// the parent's owned-popup count. {sawDialog, clicks, verified}.+pub fn resolve_seat_dialog(ctx: &Ctx, max_clicks: u32) -> Value {+ let (mut clicks, mut saw, mut verified) = (0u32, false, false);+ let Some(ab) = Loopback::from_env() else { return json!({"sawDialog": false, "clicks": 0, "verified": false}) };+ for _ in 0..max_clicks {+ let facts = fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller);+ let Some(dlg) = facts.licensing_dialog else { verified = true; break };+ saw = true;+ let hwnd = dlg["hwnd"].as_i64().unwrap_or(0);+ let parent = dlg["owner"].as_i64().unwrap_or(0);+ let mut landed = false;+ if hwnd != 0 {+ for btn in ["Continue", "Check again"] {+ if let Ok(r) = ab.call("desktop_ui_click", json!({"hwnd": hwnd, "name": btn}), &ctx.caller, 4) {+ if r["success"].as_bool() == Some(true) || r["status"].as_str() == Some("ok") {+ if btn == "Check again" { eprintln!("[fusion-bridge] seat: clicked 'Check again' to REGRAB the suspended session (Session Suspended variant)"); }+ landed = true; break;+ }+ }+ }+ }+ if landed { clicks += 1; }+ std::thread::sleep(Duration::from_millis(1500));+ if parent != 0 && owned_popup_count(&ab, ctx, parent) == 0 { verified = true; break; }+ }+ json!({"sawDialog": saw, "clicks": clicks, "verified": verified})+}++/// The verb-shaped answer (`fusion_resolve_seat_dialog`).+pub fn resolve_seat_dialog_verb(ctx: &Ctx, args: &Value) -> Value {+ let r = resolve_seat_dialog(ctx, args["maxClicks"].as_u64().unwrap_or(3).max(1) as u32);+ let still = Loopback::from_env().map(|ab| fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller).licensing_dialog.is_some()).unwrap_or(false);+ json!({"success": true, "clicks": r["clicks"], "resolved": !still, "detail": r, "_hint": "Readiness executor: the seat dialog is clicked Continue (Suspend pre-selected) and verified gone by screenshot; poll fusion_readiness."})+}++/// `relocate_recovery_files`: move CrashRecovery contents to ~/.adom/recovery/fusion/<stamp>/.+pub fn relocate_recovery_files() -> Value {+ let base = std::path::Path::new(&std::env::var("LOCALAPPDATA").unwrap_or_default()).join("Autodesk").join("Autodesk Fusion 360");+ let Ok(entries) = std::fs::read_dir(&base) else { return json!({"moved": 0, "dest": Value::Null, "sources": []}) };+ let cr_dirs: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path().join("CrashRecovery")).filter(|d| d.is_dir() && std::fs::read_dir(d).map(|mut r| r.next().is_some()).unwrap_or(false)).collect();+ if cr_dirs.is_empty() { return json!({"moved": 0, "dest": Value::Null, "sources": []}); }+ let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();+ let dest = std::path::Path::new(&home).join(".adom").join("recovery").join("fusion").join(chrono::Local::now().format("%Y%m%d_%H%M%S").to_string());+ let _ = std::fs::create_dir_all(&dest);+ let mut moved = 0i64;+ let mut sources = Vec::new();+ for cr in &cr_dirs {+ sources.push(cr.to_string_lossy().replace('\\', "/"));+ if let Ok(items) = std::fs::read_dir(cr) {+ for it in items.flatten() {+ let src = it.path();+ let mut dst = dest.join(it.file_name());+ if dst.exists() { let stem = src.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); dst = dest.join(format!("{stem}_{moved}{ext}")); }+ if std::fs::rename(&src, &dst).is_ok() { moved += 1; continue; }+ let copied = if src.is_dir() { copy_dir(&src, &dst).is_ok() } else { std::fs::copy(&src, &dst).is_ok() };+ if copied { let _ = if src.is_dir() { std::fs::remove_dir_all(&src) } else { std::fs::remove_file(&src) }; moved += 1; }+ }+ }+ let _ = std::fs::remove_dir(cr); // only when empty+ }+ json!({"moved": moved, "dest": if moved > 0 { json!(dest.to_string_lossy().replace('\\', "/")) } else { Value::Null }, "sources": sources})+}++fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {+ std::fs::create_dir_all(dst)?;+ for e in std::fs::read_dir(src)? { let e = e?; let p = e.path(); let d = dst.join(e.file_name()); if p.is_dir() { copy_dir(&p, &d)?; } else { std::fs::copy(&p, &d)?; } }+ Ok(())+}++fn windows(ab: &dyn AbClient, ctx: &Ctx) -> Vec<Value> {+ ab.call("desktop_enum_windows", json!({}), &ctx.caller, 10).ok().map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default()).unwrap_or_default()+}+fn find_by_title(rows: &[Value], sub: &str) -> Option<i64> { rows.iter().find(|w| w["visible"].as_bool() != Some(false) && w["title"].as_str().map(|t| t.contains(sub)).unwrap_or(false)).and_then(|w| w["hwnd"].as_i64()) }+/// `_find_fusion_dialog_windows`: small Qt windows titled exactly "Fusion360" (the recovery list).+fn recovery_list_windows(rows: &[Value]) -> Vec<i64> {+ rows.iter().filter(|w| {+ let cls = w["className"].as_str().unwrap_or("");+ if w["visible"].as_bool() == Some(false) || !cls.contains("QWindowIcon") || cls.contains("ToolSaveBits") || w["title"].as_str() != Some("Fusion360") { return false; }+ let r = fusion_core::dialogs::rect_of(&w["rect"]);+ r["width"].as_i64().unwrap_or(0) < 700 && r["height"].as_i64().unwrap_or(0) < 500+ }).filter_map(|w| w["hwnd"].as_i64()).collect()+}++/// `dismiss_recovery_dialog`: relocate the files, then answer every recovery prompt in the+/// background (No through UIA where Python used Tab+Enter after a foreground grab; WM_CLOSE+/// for the list). True when anything was dismissed.+pub fn dismiss_recovery_dialog(ctx: &Ctx, max_wait: f64, relocate: bool) -> (bool, Value) {+ let reloc = if relocate { relocate_recovery_files() } else { json!({"moved": 0, "dest": Value::Null}) };+ let Some(ab) = Loopback::from_env() else { return (false, reloc) };+ let deadline = Instant::now() + Duration::from_secs_f64(max_wait.max(0.5));+ let mut dismissed_any = false;+ while Instant::now() < deadline {+ let rows = windows(&ab, ctx);+ if let Some(h) = find_by_title(&rows, "Open recovery document instead?") {+ if ab.call("desktop_ui_click", json!({"hwnd": h, "name": "No"}), &ctx.caller, 8).is_err() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 8); }+ dismissed_any = true; std::thread::sleep(Duration::from_millis(500)); continue;+ }+ let lst = recovery_list_windows(&rows);+ if !lst.is_empty() {+ for h in &lst { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 8); }+ crate::fusion_prefs::action_ledger(&ctx.state_dir, "recovery_wm_close", json!({"windows": lst}));+ dismissed_any = true; std::thread::sleep(Duration::from_millis(500)); continue;+ }+ if let Some(h) = find_by_title(&rows, "Recovered Documents") {+ if ab.call("desktop_ui_click", json!({"hwnd": h, "name": "Close"}), &ctx.caller, 8).is_err() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 8); }+ dismissed_any = true; std::thread::sleep(Duration::from_millis(500)); continue;+ }+ if dismissed_any { std::thread::sleep(Duration::from_millis(500)); let rows = windows(&ab, ctx); if find_by_title(&rows, "Open recovery document instead?").is_none() && recovery_list_windows(&rows).is_empty() && find_by_title(&rows, "Recovered Documents").is_none() { break; } continue; }+ break;+ }+ (dismissed_any, reloc)+}++static HUNG_SINCE: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);++/// `is_fusion_hung`: IsHungAppWindow on the main window plus a visible "Ghost" window naming Fusion.+pub fn is_fusion_hung(ctx: &Ctx) -> Value {+ let mut out = json!({"hung": false, "signals": [], "hwnd": Value::Null});+ if let Some(ab) = Loopback::from_env() {+ let facts = fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller);+ if let Some(h) = facts.main_hwnd { out["hwnd"] = json!(h); if ctx.host.window_hung(h) { out["hung"] = json!(true); out["signals"].as_array_mut().unwrap().push(json!("IsHungAppWindow")); } }+ let ghosts: Vec<i64> = windows(&ab, ctx).iter().filter(|w| w["className"].as_str() == Some("Ghost") && w["visible"].as_bool() != Some(false) && w["title"].as_str().map(|t| t.contains("Fusion")).unwrap_or(false)).filter_map(|w| w["hwnd"].as_i64()).collect();+ if !ghosts.is_empty() { out["hung"] = json!(true); out["signals"].as_array_mut().unwrap().push(json!("GhostWindow")); out["ghostHwnds"] = json!(ghosts); }+ }+ out+}++/// `fusion_using_cpu`: did the family's CPU time advance over 0.7s?+pub fn fusion_using_cpu(ctx: &Ctx) -> bool {+ let t0 = ctx.host.family_cpu_ticks(); std::thread::sleep(Duration::from_millis(700)); let t1 = ctx.host.family_cpu_ticks();+ t1.saturating_sub(t0) > 50_000+}++/// `_confirmed_hung`: kill-grade only when the OS says hung AND a 20s probe gets nothing AND it+/// has persisted 180s; a long op in flight (under 30 minutes, or with CPU still moving) protects.+pub fn confirmed_hung(ctx: &Ctx) -> Value {+ if let Some(lo) = crate::long_op_marker() {+ let el = now_f() - lo.1;+ let protected = el < 1800.0 || fusion_using_cpu(ctx);+ if protected { return json!({"hung": false, "phase": "long-op-in-flight", "longOp": lo.0, "longOpElapsedSec": (el * 10.0).round() / 10.0, "keptBy": if el < 1800.0 { "under-ceiling" } else { "fusion-cpu-active" }}); }+ }+ let h = is_fusion_hung(ctx);+ if h["hung"].as_bool() != Some(true) { *HUNG_SINCE.lock().unwrap() = 0.0; return json!({"hung": false, "phase": "pumping"}); }+ if crate::addin::addin_status_probe(Duration::from_secs(20)).is_some() { *HUNG_SINCE.lock().unwrap() = 0.0; return json!({"hung": false, "phase": "busy-not-hung", "signals": h["signals"]}); }+ let now = now_f();+ let mut since = HUNG_SINCE.lock().unwrap();+ if *since == 0.0 { *since = now; }+ let held = now - *since;+ if held < 180.0 { return json!({"hung": false, "phase": "suspect", "heldSeconds": (held * 10.0).round() / 10.0, "signals": h["signals"]}); }+ json!({"hung": true, "phase": "confirmed", "heldSeconds": (held * 10.0).round() / 10.0, "signals": h["signals"]})+}
rust/bridge/src/services.rsadded+245@@ -0,0 +1,245 @@+//! The service-backed orchestrations, native: the board stackup read, the optimized-GLB export+//! through service-step2glb (sync, fetch, and the detached form), and the design-rule packs+//! from the wiki page adom/pcb-design-rules. Every Fusion hop goes through the pass-through.++use serde_json::{json, Value};+use std::time::{Duration, Instant};++use crate::addin::Ctx;++fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }+fn step2glb_url() -> String { std::env::var("ADOM_STEP2GLB_URL").ok().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "https://step2glb-gmdoncpxdwx0.adom.cloud".into()).trim_end_matches('/').to_string() }+fn service_ua() -> String { format!("adom-fusion-bridge/{}", crate::VERSION) }+const DESIGN_RULES_PAGE: &str = "https://wiki.adom.inc/api/v1/pages/adom/pcb-design-rules/files";++/// `fusion_board_stackup`: copper count and thicknesses from the .brd design rules, FR4 from the 3D body.+pub fn board_stackup(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let _ = args;+ let _ = pass("show_2d_board", json!({}));+ let brd = std::env::temp_dir().join("adom_stackup.brd").to_string_lossy().replace('\\', "/");+ let r = pass("export_eagle_source", json!({"outputPath": brd}));+ if r["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("could not export .brd for stackup: {}", r["error"].as_str().or(r["message"].as_str()).unwrap_or("None")), "_hint": "Open the PROJECT and switch to the board (fusion_show_2d_board) first."}); }+ let txt = match std::fs::read(&brd) { Ok(b) => b.iter().map(|&c| c as char).collect::<String>(), Err(e) => return json!({"success": false, "error": format!("could not read .brd: {e}")}) };+ let p = |name: &str| regex::Regex::new(&format!(r#"<param name="{name}" value="([^"]*)""#)).unwrap().captures(&txt).map(|c| c[1].to_string());+ let layer_setup = p("layerSetup").unwrap_or_default();+ let mt_copper: Vec<String> = p("mtCopper").unwrap_or_default().split_whitespace().map(str::to_string).collect();+ let mt_isolate: Vec<String> = p("mtIsolate").unwrap_or_default().split_whitespace().map(str::to_string).collect();+ let copper_layers: Vec<String> = regex::Regex::new(r"\d+").unwrap().find_iter(&layer_setup).map(|m| m.as_str().to_string()).collect();+ let ncu = copper_layers.len();+ let bonds: Vec<&str> = layer_setup.chars().filter(|c| *c == '+' || *c == '*').map(|c| if c == '*' { "core" } else { "prepreg" }).collect();+ let _ = pass("show_3d_board", json!({}));+ let mm = pass("run_modeling_script", json!({"script": "import adsk.core, adsk.fusion\napp=adsk.core.Application.get(); des=adsk.fusion.Design.cast(app.activeProduct)\nbest=None; ba=-1.0\nfor occ in des.rootComponent.allOccurrences:\n for b in occ.component.bRepBodies:\n if b.name.lower()=='board':\n bb=b.boundingBox; mn=bb.minPoint; mx=bb.maxPoint\n a=(mx.x-mn.x)*(mx.y-mn.y)\n if a>ba: ba=a; best=((mx.x-mn.x)*10,(mx.y-mn.y)*10,(mx.z-mn.z)*10)\nprint('FR4 %.4f %.4f %.4f'%best if best else 'FR4 none')"}));+ let msg: String = match mm["output"].as_str() { Some(o) if o.starts_with('{') => serde_json::from_str::<Value>(o).ok().and_then(|v| v["message"].as_str().map(str::to_string)).unwrap_or_default(), Some(o) => mm["message"].as_str().unwrap_or(o).to_string(), None => mm["message"].as_str().unwrap_or("").to_string() };+ let fr4 = regex::Regex::new(r"FR4\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)").unwrap().captures(&msg).map(|c| json!({"x_mm": c[1].parse::<f64>().unwrap_or(0.0), "y_mm": c[2].parse::<f64>().unwrap_or(0.0), "dielectric_mm": c[3].parse::<f64>().unwrap_or(0.0)}));+ let num = |s: &str| s.replace("mm", "").parse::<f64>().ok();+ json!({"success": true, "layerSetup": layer_setup, "copperLayers": copper_layers, "copperCount": ncu,+ "copperThickness_mm": if mt_copper.is_empty() { Value::Null } else { json!(mt_copper.iter().take(ncu).map(|t| num(t)).collect::<Vec<_>>()) },+ "dielectricBonds": bonds, "dielectricThickness_raw": mt_isolate, "fr4": fr4, "brdPath": brd,+ "_hint": "Physical stack (see pcb-stackup skill): the FR4 is NOT one slab - it is prepreg/core/prepreg with copper between. Build the table + exploded view per that skill. If mtIsolate looks like 2-layer defaults, fit a symmetric split to fr4.dielectric_mm."})+}++fn multipart(files: &[(&str, String, Vec<u8>, &str)]) -> (Vec<u8>, String) {+ let boundary = format!("----AdomBridgeGLB{}{}", (now_f() * 1000.0) as u64, std::process::id());+ let mut out: Vec<u8> = Vec::new();+ for (field, filename, data, ctype) in files {+ out.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{filename}\"\r\nContent-Type: {ctype}\r\n\r\n").as_bytes());+ out.extend_from_slice(data); out.extend_from_slice(b"\r\n");+ }+ out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());+ (out, boundary)+}++fn glb_submit(step_path: &str, silk_top: Option<&str>, silk_bottom: Option<&str>, pin: &str, job_name: &str) -> Value {+ let Ok(step) = std::fs::read(step_path) else { return json!({"ok": false, "error": format!("could not read STEP: {step_path}")}) };+ let mut files: Vec<(&str, String, Vec<u8>, &str)> = vec![("step", std::path::Path::new(step_path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), step, "application/step")];+ for (field, p) in [("silk_top", silk_top), ("silk_bottom", silk_bottom)] { if let Some(p) = p { if let Ok(b) = std::fs::read(p) { files.push((field, std::path::Path::new(p).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), b, "image/png")); } } }+ let (body, boundary) = multipart(&files);+ let url = format!("{}/convert?molecule=true&pin={}", step2glb_url(), fusion_core::aps::urlencode(pin));+ let resp = ureq::post(&url).timeout(Duration::from_secs(90)).set("Content-Type", &format!("multipart/form-data; boundary={boundary}")).set("X-Client", "fusion-bridge/adom").set("X-Job-Name", job_name).set("User-Agent", &service_ua()).send_bytes(&body);+ let queued: Value = match resp { Ok(r) => r.into_json().unwrap_or(json!({})), Err(e) => return json!({"ok": false, "error": format!("service POST failed: {e}"), "_hint": format!("Is {} reachable from this box? Override with ADOM_STEP2GLB_URL.", step2glb_url())}) };+ match queued["job_id"].as_str() { Some(j) => json!({"ok": true, "jobId": j}), None => json!({"ok": false, "error": "service did not return a job_id", "raw": queued}) }+}++fn glb_fetch(job_id: &str, out_path: Option<&str>, poll_s: u64) -> Value {+ let ua = service_ua();+ let deadline = Instant::now() + Duration::from_secs(poll_s);+ let mut stats = json!({});+ let mut first = true;+ while first || Instant::now() < deadline {+ first = false;+ match ureq::get(&format!("{}/jobs/{job_id}", step2glb_url())).timeout(Duration::from_secs(20)).set("User-Agent", &ua).call().ok().and_then(|r| r.into_json::<Value>().ok()) { Some(s) => stats = s, None => { std::thread::sleep(Duration::from_secs(4)); continue; } }+ match stats["status"].as_str() { Some("complete") => break, Some("error") => return json!({"ok": false, "status": "error", "error": if stats["error"].is_null() { stats.clone() } else { stats["error"].clone() }, "stats": stats}), _ => {} }+ if Instant::now() >= deadline { return json!({"ok": true, "status": stats["status"].as_str().unwrap_or("processing"), "stats": stats}); }+ std::thread::sleep(Duration::from_secs(6));+ }+ if stats["status"].as_str() != Some("complete") { return json!({"ok": true, "status": stats["status"].as_str().unwrap_or("processing"), "stats": stats}); }+ let (code, glb) = fusion_core::aps::http_bytes(&format!("{}/jobs/{job_id}/result", step2glb_url()));+ if code != 200 { return json!({"ok": false, "status": "complete", "error": format!("download failed: HTTP {code}"), "stats": stats}); }+ let mut wrote = Value::Null;+ if let Some(p) = out_path { match std::fs::write(p, &glb) { Ok(_) => wrote = json!(p), Err(e) => return json!({"ok": false, "status": "complete", "error": format!("could not write GLB: {e}"), "stats": stats}) } }+ json!({"ok": true, "status": "complete", "stats": stats, "glbBytes": glb.len(), "wrote": wrote})+}++/// `fusion_export_optimized_glb` (the synchronous form).+pub fn export_optimized_glb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let Some(mut out) = args["outputPath"].as_str().or(args["output_path"].as_str()).map(str::to_string).filter(|s| !s.is_empty()) else { return json!({"success": false, "error": "No outputPath specified.", "_hint": "Usage: fusion_export_optimized_glb {\"outputPath\":\"C:/tmp/board.glb\", \"silkscreen\":true, \"pin\":\"medium\"}"}) };+ if !out.to_lowercase().ends_with(".glb") { out.push_str(".glb"); }+ let pin = args["pin"].as_str().unwrap_or("medium").to_string();+ let want_silk = args["silkscreen"].as_bool().unwrap_or(true);+ let _ = pass("show_3d_board", json!({}));+ let step_path = format!("{}.step", &out[..out.len() - 4]);+ let sr = pass("export_step", json!({"outputPath": step_path}));+ if sr["success"].as_bool() != Some(true) { return json!({"success": false, "error": format!("STEP export failed: {}", sr["error"].as_str().or(sr["message"].as_str()).unwrap_or("None")), "step": sr, "_hint": "The active design must be a 3D Design product (fusion_show_3d_board first)."}); }+ let (mut silk_top, mut silk_bottom, mut silk_note): (Option<String>, Option<String>, Option<&str>) = (None, None, None);+ if want_silk {+ let st = format!("{}_silk_top.png", &out[..out.len() - 4]); let sb = format!("{}_silk_bottom.png", &out[..out.len() - 4]);+ if pass("take_silkscreen_screenshot", json!({"outputPath": st, "layer": "top"}))["success"].as_bool() == Some(true) { silk_top = Some(st); }+ if pass("take_silkscreen_screenshot", json!({"outputPath": sb, "layer": "bottom"}))["success"].as_bool() == Some(true) { silk_bottom = Some(sb); }+ if silk_top.is_none() && silk_bottom.is_none() { silk_note = Some("silkscreen capture unavailable on this design; GLB built without a baked silk texture."); }+ }+ let job_name = std::path::Path::new(&out).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ let sub = glb_submit(&step_path, silk_top.as_deref(), silk_bottom.as_deref(), &pin, &job_name);+ if sub["ok"].as_bool() != Some(true) { return json!({"success": false, "error": sub["error"], "_hint": sub["_hint"], "stepPath": step_path, "note": "STEP exported OK; optimize submit failed."}); }+ let job_id = sub["jobId"].as_str().unwrap_or("").to_string();+ let wait_s = args["wait"].as_u64().unwrap_or(15);+ let fetched = if wait_s > 0 { glb_fetch(&job_id, Some(&out), wait_s) } else { json!({"ok": true, "status": "processing"}) };+ let (status_url, result_url) = (format!("{}/jobs/{job_id}", step2glb_url()), format!("{}/jobs/{job_id}/result", step2glb_url()));+ if fetched["status"].as_str() == Some("complete") && !fetched["wrote"].is_null() {+ let stats = &fetched["stats"]; let size = fetched["glbBytes"].as_u64().unwrap_or(0);+ return json!({"success": true, "status": "complete", "glbPath": out, "stepPath": step_path, "jobId": job_id, "silkTop": silk_top, "silkBottom": silk_bottom, "meshesBefore": stats["meshes_before"], "meshesAfter": stats["meshes_after"], "sizeBytes": size, "moleculeAnchored": stats["molecule_anchored"], "silkscreenApplied": stats["silkscreen_applied"], "note": silk_note,+ "message": format!("Optimized GLB written ({} KB, meshes {}->{}, anchored={}, silk={}) to {out}", size / 1024, crate::fusion_prefs::py_str(&stats["meshes_before"]), crate::fusion_prefs::py_str(&stats["meshes_after"]), crate::fusion_prefs::py_str(&stats["molecule_anchored"]), crate::fusion_prefs::py_str(&stats["silkscreen_applied"])),+ "_hint": "Pull it with pull_file, then set it as component.parts.model_3d on a wiki component page. Same optimizer as the molecule GLBs (Colby's pipeline)."});+ }+ if fetched["ok"].as_bool() != Some(true) { return json!({"success": false, "error": fetched["error"], "jobId": job_id, "stepPath": step_path, "statusUrl": status_url, "resultUrl": result_url}); }+ json!({"success": true, "status": fetched["status"].as_str().unwrap_or("processing"), "pending": true, "jobId": job_id, "stepPath": step_path, "glbPath": out, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note, "statusUrl": status_url, "resultUrl": result_url,+ "message": format!("Optimize job {job_id} submitted; still processing after {wait_s}s. Finish it with fusion_fetch_optimized_glb {{\"jobId\":\"{job_id}\",\"outputPath\":\"{out}\"}} (re-call until complete)."),+ "_hint": "Big boards tessellate for a few minutes. Either re-call fusion_fetch_optimized_glb with this jobId (writes the GLB on the box when ready), or GET the resultUrl directly."})+}++/// `fusion_fetch_optimized_glb` (by jobId).+pub fn fetch_optimized_glb(args: &Value) -> Value {+ let Some(job_id) = args["jobId"].as_str().filter(|s| !s.is_empty()) else { return json!({"success": false, "error": "No jobId.", "_hint": "Usage: fusion_fetch_optimized_glb {\"jobId\":\"...\",\"outputPath\":\"C:/tmp/board.glb\"}"}) };+ let mut out = args["outputPath"].as_str().or(args["output_path"].as_str()).map(str::to_string);+ if let Some(o) = &mut out { if !o.to_lowercase().ends_with(".glb") { o.push_str(".glb"); } }+ let wait_s = args["wait"].as_u64().unwrap_or(15);+ let res = glb_fetch(job_id, out.as_deref(), wait_s);+ let (status_url, result_url) = (format!("{}/jobs/{job_id}", step2glb_url()), format!("{}/jobs/{job_id}/result", step2glb_url()));+ if res["status"].as_str() == Some("complete") && !res["wrote"].is_null() {+ let stats = &res["stats"]; let size = res["glbBytes"].as_u64().unwrap_or(0);+ return json!({"success": true, "status": "complete", "glbPath": out, "jobId": job_id, "sizeBytes": size, "meshesAfter": stats["meshes_after"], "moleculeAnchored": stats["molecule_anchored"], "silkscreenApplied": stats["silkscreen_applied"], "message": format!("Optimized GLB written ({} KB) to {}", size / 1024, out.as_deref().unwrap_or("None"))});+ }+ if res["ok"].as_bool() != Some(true) { return json!({"success": false, "error": res["error"], "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url}); }+ json!({"success": true, "status": res["status"].as_str().unwrap_or("processing"), "pending": true, "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url, "message": format!("Job {job_id} still {}; re-call fusion_fetch_optimized_glb to finish.", res["status"].as_str().unwrap_or("processing"))})+}++static GLB_ASYNC: std::sync::Mutex<Vec<(String, Value)>> = std::sync::Mutex::new(Vec::new());+fn glb_state_set(id: &str, v: Value) { let mut g = GLB_ASYNC.lock().unwrap(); if let Some(e) = g.iter_mut().find(|(k, _)| k == id) { e.1 = v; } else { g.push((id.to_string(), v)); } }+fn glb_state_get(id: &str) -> Option<Value> { GLB_ASYNC.lock().unwrap().iter().find(|(k, _)| k == id).map(|(_, v)| v.clone()) }++/// `_start_glb_export_async`: the detached form, polled by fetch_optimized_glb {exportId}.+pub fn start_glb_export_async(ctx: &Ctx, args: &Value) -> Value {+ let export_id = format!("glbx-{:08x}", (now_f() * 1000.0) as u64 & 0xffff_ffff);+ let started = now_f();+ glb_state_set(&export_id, json!({"exportId": export_id, "phase": "exporting-step", "done": false, "failed": false, "started": started}));+ let mut inner = args.clone(); if let Some(o) = inner.as_object_mut() { o.remove("async"); if !o.contains_key("wait") { o.insert("wait".into(), json!(20)); } }+ let (sd, caller, eid) = (ctx.state_dir.clone(), ctx.caller.clone(), export_id.clone());+ std::thread::spawn(move || {+ let host = crate::host_for_diagnosis();+ host.background_envelope(400.0, false);+ let side_url = crate::launch::SIDECAR_URL.lock().unwrap().clone();+ let side = move |verb: &str, a: Value, t: u64| -> Value { match &side_url { Some(u) => ureq::post(u).timeout(Duration::from_secs(t)).send_json(json!({"command": verb, "args": a})).ok().and_then(|r| r.into_json().ok()).unwrap_or(json!({"success": false, "error": "sidecar unreachable"})), None => json!({"success": false, "error": "sidecar absent"}) } };+ let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx2 = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd, caller, pids, sidecar: &side, dialogs: &dlg };+ let pass2 = |verb: &str, a: Value| crate::addin::pass_through(verb, &a, &ctx2);+ let mut st = glb_state_get(&eid).unwrap_or(json!({}));+ let res = export_optimized_glb(&inner, &pass2);+ st["result"] = res.clone();+ if res["glbPath"].is_string() && res["success"].as_bool() == Some(true) && res["pending"].as_bool() != Some(true) { st["phase"] = json!("complete"); st["done"] = json!(true); glb_state_set(&eid, st); return; }+ let Some(job) = res["jobId"].as_str().filter(|_| res["success"].as_bool() == Some(true)).map(str::to_string) else { st["phase"] = json!("failed"); st["failed"] = json!(true); st["done"] = json!(true); st["error"] = json!(res["error"].as_str().unwrap_or("None").chars().take(200).collect::<String>()); glb_state_set(&eid, st); return; };+ st["phase"] = json!("optimizing"); st["jobId"] = json!(job); glb_state_set(&eid, st.clone());+ let deadline = Instant::now() + Duration::from_secs(600);+ while Instant::now() < deadline {+ let f = fetch_optimized_glb(&json!({"jobId": job, "outputPath": inner["outputPath"], "wait": 20}));+ st["result"] = f.clone();+ if f["glbPath"].is_string() && f["pending"].as_bool() != Some(true) { st["phase"] = json!("complete"); st["done"] = json!(true); glb_state_set(&eid, st); return; }+ if f["success"].as_bool() != Some(true) && f["pending"].as_bool() != Some(true) { st["phase"] = json!("failed"); st["failed"] = json!(true); st["done"] = json!(true); st["error"] = json!(f["error"].as_str().unwrap_or("None").chars().take(200).collect::<String>()); glb_state_set(&eid, st); return; }+ glb_state_set(&eid, st.clone());+ std::thread::sleep(Duration::from_secs(5));+ }+ st["phase"] = json!("failed"); st["failed"] = json!(true); st["done"] = json!(true); st["error"] = json!("optimizer did not finish within 10 min"); glb_state_set(&eid, st);+ });+ json!({"success": true, "pending": true, "exportId": export_id, "statusVerb": "fusion_fetch_optimized_glb", "_hint": format!("Export running ASYNC (the sync form can outlive the relay window on a cold Fusion session). Poll fusion_fetch_optimized_glb {{\"exportId\": \"{export_id}\"}} every few seconds: phase walks exporting-step -> optimizing -> complete, and the final poll carries glbPath/sizeBytes.")})+}++/// `_glb_export_async_status`.+pub fn glb_export_async_status(args: &Value) -> Value {+ let id = args["exportId"].as_str().unwrap_or("");+ let Some(st) = glb_state_get(id) else { return json!({"success": false, "error": format!("unknown exportId {id}"), "_hint": "Start one with fusion_export_optimized_glb {async: true}; its response carries the exportId."}) };+ let mut out = json!({"success": st["failed"].as_bool() != Some(true), "exportId": id, "phase": st["phase"], "done": st["done"], "elapsedSec": (now_f() - st["started"].as_f64().unwrap_or(now_f())) as i64});+ if st["jobId"].is_string() { out["jobId"] = st["jobId"].clone(); }+ if st["error"].is_string() { out["error"] = st["error"].clone(); }+ if st["done"].as_bool() == Some(true) && st["result"].is_object() { for k in ["glbPath", "sizeBytes", "meshesAfter", "moleculeAnchored", "silkscreenApplied"] { if !st["result"][k].is_null() { out[k] = st["result"][k].clone(); } } }+ out+}++fn design_rules_index(state_dir: &std::path::Path) -> Result<Value, String> {+ let cache = state_dir.join("design-rules-index.json");+ if let Ok(m) = std::fs::metadata(&cache) { if m.modified().ok().and_then(|t| t.elapsed().ok()).map(|e| e.as_secs() < 3600).unwrap_or(false) { if let Some(v) = std::fs::read_to_string(&cache).ok().and_then(|t| serde_json::from_str(&t).ok()) { return Ok(v); } } }+ let idx: Value = ureq::get(&format!("{DESIGN_RULES_PAGE}/rules/index.json")).timeout(Duration::from_secs(20)).set("User-Agent", &service_ua()).call().map_err(|e| e.to_string())?.into_json().map_err(|e| e.to_string())?;+ let _ = std::fs::create_dir_all(state_dir); let _ = std::fs::write(&cache, idx.to_string());+ Ok(idx)+}++fn slug(s: &str) -> String { s.to_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect() }++fn design_rules_pick(idx: &Value, fab: &str, process: &str, layers: i64) -> Result<Value, Value> {+ let want = slug(fab);+ let vendors = idx["vendors"].as_array().cloned().unwrap_or_default();+ let rows: Vec<&Value> = vendors.iter().filter(|v| { let s = slug(v["vendorSlug"].as_str().unwrap_or("")); s == want || (!want.is_empty() && s.contains(&want)) }).collect();+ if rows.is_empty() { let mut all: Vec<String> = vendors.iter().filter_map(|v| v["vendorSlug"].as_str().map(str::to_string)).collect::<std::collections::BTreeSet<_>>().into_iter().collect(); all.sort(); return Err(json!(all)); }+ let mut procs: Vec<String> = rows.iter().filter_map(|v| v["process"].as_str().map(str::to_string)).collect::<std::collections::BTreeSet<_>>().into_iter().collect(); procs.sort();+ let proc_ = if !process.is_empty() { process.to_lowercase() } else if procs.iter().any(|p| p == "standard") { "standard".into() } else { procs.first().cloned().unwrap_or_default() };+ let hit = rows.iter().find(|v| v["process"].as_str() == Some(&proc_) && v["layerCount"].as_i64().unwrap_or(0) == layers);+ match hit { Some(h) => Ok((*h).clone()), None => { let mut lc: Vec<i64> = rows.iter().filter(|v| v["process"].as_str() == Some(&proc_)).map(|v| v["layerCount"].as_i64().unwrap_or(0)).collect::<std::collections::BTreeSet<_>>().into_iter().collect(); lc.sort(); Err(json!({"processes": procs, "layerCounts": lc})) } }+}++/// `fusion_set_design_rules`: a fab's rule pack from the wiki into the open board.+pub fn set_design_rules(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut a = args.clone(); if !a.is_object() { a = json!({}); }+ let action = a["action"].as_str().unwrap_or("apply").to_lowercase();+ let fab = a["fab"].as_str().unwrap_or("").trim().to_string();+ let mut dru_path = a["druPath"].as_str().unwrap_or("").replace('\\', "/");+ if action == "list" {+ let idx = match design_rules_index(&ctx.state_dir) { Ok(i) => i, Err(e) => return json!({"success": false, "errorCode": "design_rules_page_unreachable", "error": e.chars().take(200).collect::<String>(), "_hint": "The wiki page adom/pcb-design-rules did not answer; the bundled Adom InstaPCB rules still apply with no fab argument."}) };+ let rows: Vec<Value> = idx["vendors"].as_array().cloned().unwrap_or_default().iter().map(|v| json!({"fab": v["vendorSlug"], "vendor": v["vendor"], "process": v["process"], "layers": v["layerCount"], "available": v["filesGenerated"].as_bool().unwrap_or(false)})).collect();+ return json!({"success": true, "rules": rows, "count": rows.len(), "page": "https://wiki.adom.inc/adom/pcb-design-rules", "_hint": "Pick a row and call fusion_set_design_rules {fab, process, layers}. Layers auto-detects when omitted (2 or 4 from the open board)."});+ }+ if action == "show" || action == "export" || (fab.is_empty() && dru_path.is_empty()) { return pass("set_design_rules", a); }+ let mut pack = Value::Null;+ if dru_path.is_empty() {+ let mut layers = match &a["layers"] { Value::Null => "auto".to_string(), v => crate::fusion_prefs::py_str(v) };+ if layers == "auto" { let det = pass("detect_layers", json!({})); layers = det["data"]["layerCount"].as_i64().or(det["layerCount"].as_i64()).unwrap_or(2).to_string(); }+ let layers_n: i64 = layers.parse().unwrap_or(2);+ let idx = match design_rules_index(&ctx.state_dir) { Ok(i) => i, Err(e) => return json!({"success": false, "errorCode": "design_rules_page_unreachable", "error": e.chars().take(200).collect::<String>(), "_hint": "Could not fetch the rule packs from wiki.adom.inc; pass druPath to a local .edru, or omit fab for the bundled Adom InstaPCB rules."}) };+ let row = match design_rules_pick(&idx, &fab, a["process"].as_str().unwrap_or(""), layers_n) { Ok(r) => r, Err(alt) => return json!({"success": false, "errorCode": "design_rules_not_found", "fab": fab, "layers": layers_n, "available": alt, "_hint": "No pack for that fab/process/layer count; `available` lists what the page has. fusion_set_design_rules {action:'list'} shows every row."}) };+ if row["filesGenerated"].as_bool() != Some(true) { return json!({"success": false, "errorCode": "design_rules_not_generated", "fab": fab, "process": row["process"], "layers": layers_n, "_hint": "The page has this vendor's record but no generated .edru yet (the vendor publishes no usable limits for it). Pick another process or fab."}); }+ let rel = row["files"]["fusionEdru"].as_str().unwrap_or("").to_string();+ let (code, edru) = fusion_core::aps::http_bytes(&format!("{DESIGN_RULES_PAGE}/{rel}"));+ if code != 200 { return json!({"success": false, "errorCode": "design_rules_download_failed", "error": format!("HTTP {code}"), "file": rel}); }+ if !edru.windows(12).any(|w| w == b"<designrules") { return json!({"success": false, "errorCode": "design_rules_invalid", "file": rel, "error": "The downloaded file is not an EAGLE design rules document."}); }+ let stage = std::env::var("TEMP").ok().filter(|s| !s.is_empty()).map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir).join("adom-fusion").join("design-rules");+ let _ = std::fs::create_dir_all(&stage);+ dru_path = stage.join(rel.rsplit('/').next().unwrap_or(&rel)).to_string_lossy().replace('\\', "/");+ if let Err(e) = std::fs::write(&dru_path, &edru) { return json!({"success": false, "errorCode": "design_rules_stage_failed", "error": e.to_string()}); }+ pack = json!({"fab": row["vendorSlug"], "vendor": row["vendor"], "process": row["process"], "layers": row["layerCount"], "file": rel, "record": row["files"]["record"]});+ a["_pack"] = pack.clone();+ }+ a["druPath"] = json!(dru_path); a["action"] = json!("apply");+ let mut res = pass("set_design_rules", a);+ if res.is_object() {+ if pack.is_object() { if !res["data"].is_object() { res["data"] = json!({}); } res["data"]["pack"] = pack; res["data"]["source"] = json!("https://wiki.adom.inc/adom/pcb-design-rules"); }+ res["_hint"] = json!(format!("{} Confirm with fusion_alerts {{sinceSec:60}}: an empty list means Fusion accepted the rules; a toast means it refused the file.", res["_hint"].as_str().unwrap_or("")).trim().to_string());+ }+ res+}
rust/bridge/src/signin.rsadded+491@@ -0,0 +1,491 @@+//! `fusion_signin`, `fusion_signin_2fa`, `fusion_web_signin`: sign Fusion in through the RIGHT+//! browser profile. Fusion fires its OAuth at the OS-default browser; the full authorize URL+//! lands in that browser's History, and completing it in ANY profile hands the token back to+//! the running Fusion (idmgr callback + autodesk:// handoff matched by request_id).+use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Loopback};+use serde_json::{json, Value};+use std::sync::Mutex;+use std::time::{Duration, Instant};++pub const AUTODESK_SIGNIN_HOST: &str = "signin.autodesk.com";+const SSO_BUTTONS: &[(&str, &str)] = &[("google", "Continue with Google"), ("apple", "Continue with Apple"), ("microsoft", "Continue with Microsoft"), ("facebook", "Continue with Facebook")];+const CONSUMER: &[&str] = &["gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com"];+/// Sign-in windows this bridge opened: {sessionId, profile, kind, openedAt}.+pub static SIGNIN_WINDOWS: Mutex<Vec<Value>> = Mutex::new(Vec::new());++/// Everything the sign-in needs from the bridge around it.+pub struct Env<'a> { pub readiness: &'a dyn Fn() -> Value, pub start: &'a dyn Fn() -> Value, pub aps_state: &'a dyn Fn() -> (bool, bool), pub aps_signin: &'a dyn Fn(&str) -> Value }++fn now() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }+fn sleep_s(s: f64) { std::thread::sleep(Duration::from_secs_f64(s)); }+fn ab_call(ctx: &Ctx, verb: &str, args: Value, timeout: u64) -> Value {+ match Loopback::from_env() { Some(ab) => match ab.call(verb, args, &ctx.caller, timeout) { Ok(v) => v, Err(e) => json!({"_adCallError": format!("{e:?}")}) }, None => json!({"_adCallError": "ab loopback unavailable"}) }+}+/// `_unwrap`: ab responses come back flat or nested under `data`; look through both.+pub fn unwrap(r: &Value) -> Value { match r["data"].as_object() { Some(o) if !o.is_empty() => r["data"].clone(), _ => if r.is_object() { r.clone() } else { json!({}) } } }+/// `_find_coordmap`: a coordMap (with shotId) anywhere in a verb response.+pub fn find_coordmap(v: &Value, depth: u8) -> Value {+ if depth > 5 { return json!({}); }+ if let Some(s) = v.as_str() { if s.starts_with('{') || s.starts_with('[') { if let Ok(p) = serde_json::from_str::<Value>(s) { return find_coordmap(&p, depth + 1); } } return json!({}); }+ if let Some(o) = v.as_object() {+ if o.get("coordMap").map(|c| c.is_object() && !c["shotId"].is_null()).unwrap_or(false) { return o["coordMap"].clone(); }+ if o.get("shotId").map(|s| !s.is_null()).unwrap_or(false) { return v.clone(); }+ for k in ["data", "output", "result", "response"] { if let Some(x) = o.get(k) { let got = find_coordmap(x, depth + 1); if got.as_object().map(|m| !m.is_empty()).unwrap_or(false) { return got; } } }+ }+ json!({})+}+fn windows(ctx: &Ctx) -> Vec<Value> { unwrap(&ab_call(ctx, "desktop_list_windows", json!({}), 40))["windows"].as_array().cloned().unwrap_or_default() }+fn truthy(v: &Value) -> bool { v.as_bool() == Some(true) || v.as_str() == Some("ok") }+/// `_announce_foreground`: tell the user WHY, right before stealing their foreground (a caption).+pub fn announce_foreground(ctx: &Ctx, reason: &str) -> bool { ab_call(ctx, "desktop_caption", json!({"text": format!("Adom: {reason}"), "id": "fusion-bridge-foreground", "expiresInMs": 2500}), 15)["_adCallError"].is_null() }++/// `_detect_signin_wall`: what the Autodesk sign-in is waiting on, from BROWSER WINDOW TITLES.+/// `ignore` = windows that existed before we opened this sign-in (stale tabs lie).+pub fn detect_signin_wall(ctx: &Ctx, ignore: &[i64]) -> Value {+ let mut best = json!({});+ for w in windows(ctx) {+ if w["hwnd"].as_i64().map(|h| ignore.contains(&h)).unwrap_or(false) { continue; }+ let t = w["title"].as_str().unwrap_or("").to_string(); let tl = t.to_lowercase();+ if tl.contains("2-step verification") || tl.contains("confirm sign-in") { return json!({"wall": "twofactor_email_code", "hwnd": w["hwnd"], "title": t}); }+ if tl.contains("identity manager") { best = json!({"wall": "protocol_handoff", "hwnd": w["hwnd"], "title": t}); }+ else if tl.contains("sign in - autodesk") && best["wall"].is_null() { best = json!({"wall": "credentials", "hwnd": w["hwnd"], "title": t}); }+ }+ best+}+/// `_click_background_first`: UIA Invoke (no focus steal) first; SendInput only with an announcement.+pub fn click_background_first(ctx: &Ctx, hwnd: i64, name: Option<&str>, shot: Option<(&Value, i64, i64)>, reason: Option<&str>) -> Value {+ if let Some(n) = name {+ let r = unwrap(&ab_call(ctx, "desktop_ui_click", json!({"hwnd": hwnd, "name": n}), 30));+ if truthy(&r["ok"]) || truthy(&r["clicked"]) || truthy(&r["invoked"]) { return json!({"clicked": true, "via": "uia_background", "announced": false, "why": format!("UIA Invoke on {} (background, no focus steal)", fusion_lbr::py_repr_str(n))}); }+ }+ if let Some((sid, x, y)) = shot {+ let announced = announce_foreground(ctx, reason.map(str::to_string).unwrap_or_else(|| format!("clicking {} for you", name.unwrap_or("a button"))).as_str());+ let _ = ab_call(ctx, "desktop_click", json!({"space": "image", "shotId": sid, "x": x, "y": y, "hwnd": hwnd}), 30);+ return json!({"clicked": true, "via": "foreground_click", "announced": announced, "why": "no UIA node; used SendInput (DOES foreground - user was told why)"});+ }+ json!({"clicked": false, "via": null, "announced": false, "why": "no UIA node and no shot coords given"})+}+/// `_advance_credentials_page`: drive the Autodesk credentials page as far as code can go+/// WITHOUT secrets: set the email, invoke Next, classify what the page became. Never a password.+fn advance_credentials_page(ctx: &Ctx, hwnd: i64, email: Option<&str>) -> Value {+ let mut out = json!({"advanced": false, "wall": "credentials", "detail": ""});+ let found = crate::etiquette::find_controls_any(&ab_call(ctx, "desktop_find_control", json!({"hwnd": hwnd, "role": "edit"}), 40), 0);+ let mut ctrls = found["controls"].as_array().cloned().unwrap_or_default();+ if ctrls.is_empty() && found["best"].is_object() { ctrls = vec![found["best"].clone()]; }+ let nm = |c: &Value| format!("{}{}", c["name"].as_str().unwrap_or(""), c["automationId"].as_str().unwrap_or("")).to_lowercase();+ if ctrls.iter().any(|c| nm(c).contains("password")) { out["wall"] = json!("password"); out["detail"] = json!("password field present - the human step"); return out; }+ let emailish: Vec<Value> = { let e: Vec<Value> = ctrls.iter().filter(|c| ["email", "username", "user id", "userid"].iter().any(|k| nm(c).contains(k))).cloned().collect(); if e.is_empty() { ctrls.iter().take(1).cloned().collect() } else { e } };+ if let (Some(email), Some(c)) = (email, emailish.first()) {+ let r = unwrap(&ab_call(ctx, "desktop_ui_set", json!({"hwnd": hwnd, "automationId": c["automationId"], "name": c["name"], "value": email}), 30));+ if truthy(&r["success"]) || truthy(&r["ok"]) {+ let mut detail = format!("typed email {} into {} (background UIA)", fusion_lbr::py_repr_str(email), fusion_lbr::py_repr_str(c["name"].as_str().or(c["automationId"].as_str()).unwrap_or("None")));+ for btn in ["Next", "NEXT", "Sign in", "Sign In", "Continue"] {+ let rb = unwrap(&ab_call(ctx, "desktop_ui_click", json!({"hwnd": hwnd, "name": btn}), 25));+ if truthy(&rb["success"]) || truthy(&rb["ok"]) || truthy(&rb["status"]) { out["advanced"] = json!(true); detail += &format!("; invoked {}", fusion_lbr::py_repr_str(btn)); break; }+ }+ if out["advanced"].as_bool() == Some(true) {+ sleep_s(4.0);+ let f2 = unwrap(&ab_call(ctx, "desktop_find_control", json!({"hwnd": hwnd, "role": "edit"}), 40));+ let mut c2 = f2["controls"].as_array().or(f2["matches"].as_array()).cloned().unwrap_or_default();+ if c2.is_empty() && f2["best"].is_object() { c2 = vec![f2["best"].clone()]; }+ if c2.iter().any(|c| nm(c).contains("password")) { out["wall"] = json!("password"); detail += "; page advanced to the PASSWORD step"; }+ }+ out["detail"] = json!(detail);+ }+ }+ out+}+fn resolve_and_advance_wall(ctx: &Ctx, mut wall: Value, email: Option<&str>) -> Value {+ if wall["wall"].as_str() == Some("credentials") { if let Some(h) = wall["hwnd"].as_i64() {+ let adv = advance_credentials_page(ctx, h, email);+ if adv["wall"].is_string() { wall["wall"] = adv["wall"].clone(); }+ wall["advance"] = adv["detail"].clone(); wall["advanced"] = adv["advanced"].clone();+ } }+ wall+}+fn wall_copy(wall: &str) -> (&'static str, &'static str) {+ match wall {+ "twofactor_email_code" => ("Autodesk needs your 6-digit code", "Autodesk emailed a verification code to finish signing Fusion in. Enter it in the Chrome window, or tell your AI to fetch it for you."),+ "credentials" => ("Autodesk sign-in needs you", "The Autodesk sign-in is asking for credentials. I never type your password or 2FA - please complete it in the Chrome window."),+ "password" => ("Just your password, everything else is done", "Adom drove the Fusion sign-in all the way: email entered, page advanced. The ONLY step left is your password (and I never type secrets). Type it in the browser window and everything continues automatically."),+ "protocol_handoff" => ("Allow Fusion to finish signing in", "Chrome is asking permission to open Autodesk Identity Manager. Click Open to hand the login back to Fusion."),+ _ => ("Fusion sign-in needs you", "Please check the sign-in window."),+ }+}+/// `_notify_signin_wall`: toast the user's real desktop about a human wall; true when delivered.+fn notify_signin_wall(ctx: &Ctx, wall: &str) -> bool {+ let (t, b) = wall_copy(wall);+ crate::small_verbs::notify_owner(&ctx.state_dir, &json!({"title": t, "body": b, "level": "warning"}), &ctx.caller)["success"].as_bool() == Some(true)+}+/// `_wall_hint`: the AI-facing instruction for a wall (do as much as possible IN CODE).+pub fn wall_hint(wall: &str, notified: bool) -> String {+ let common = format!("I ALREADY TOASTED THE USER about this (notifyDelivered={}) - you do not need to ask me to, and do not nag them again. ", if notified { "True" } else { "False" });+ let rest = match wall {+ "twofactor_email_code" => "WALL: Autodesk emailed a 6-DIGIT CODE. THE USER WANTS YOU TO DO THIS FOR THEM if you can, so TRY THIS FIRST, do not just hand it back to them: if `adom-google` is available in YOUR container, read the code out of their Gmail and submit it - adom-google gmail search 'from:autodesk verification code' --limit 1 \nthen pull the 6 digits and call fusion_signin_2fa {\"code\":\"123456\"} which types it in the BACKGROUND (no foreground steal) and submits. If `adom-google` is NOT installed, TELL THE USER that installing adom-google would let you finish logins like this automatically next time, then let them type the code. NEVER ask them for the code if you can read it yourself.",+ "password" => "WALL: PASSWORD ONLY. The bridge already typed the user's email and advanced the page (see steps[]) - the single remaining human act is typing their password in the browser window. Do NOT ask them for the password, do NOT try to type it, and do NOT re-drive the page. After they type it, the flow continues automatically - poll fusion_readiness until ready:true (a 2FA wall may follow; if it does you may be able to finish it yourself via fusion_signin_2fa + adom-google Gmail).",+ "protocol_handoff" => "WALL: Chrome's native 'Open Autodesk Identity Manager?' dialog. Try to clear it YOURSELF in the background: desktop_find_control on that dialog then desktop_ui_click {name:'Open'} (UIA, no focus steal). Only if UIA finds no node, fall back to a foregrounded desktop_click. Then poll fusion_readiness.",+ _ => "WALL: the sign-in wants credentials. NEVER type their password or 2FA. If the browser autofills, you may submit it. Otherwise let the user finish in the window I opened.",+ };+ common + rest+}+/// `_describe_profile`: a human, UNAMBIGUOUS name for a browser profile, never "your browser".+pub fn describe_profile(p: &Value) -> String {+ let b = p["browser"].as_str().unwrap_or("browser").trim().to_string();+ let browser = match b.to_lowercase().as_str() { "chrome" => "Chrome".to_string(), "edge" => "Edge".to_string(), "brave" => "Brave".to_string(), _ => { let mut c = b.chars(); match c.next() { Some(f) => f.to_uppercase().collect::<String>() + &c.as_str().to_lowercase(), None => b.clone() } } };+ let name = p["displayName"].as_str().unwrap_or("").trim(); let email = p["email"].as_str().unwrap_or("").trim();+ if !name.is_empty() && !email.is_empty() && !email.to_lowercase().contains(&name.to_lowercase()) { format!("{browser} - {name} ({email})") }+ else if !email.is_empty() { format!("{browser} - {email}") } else if !name.is_empty() { format!("{browser} - {name}") }+ else if let Some(d) = p["profileDir"].as_str() { format!("{browser} - {d}") } else { browser }+}+fn signin_cfg_path() -> std::path::PathBuf { let d = dirs_home().join(".adom").join("fusion-signin"); let _ = std::fs::create_dir_all(&d); d.join("profile.json") }+fn dirs_home() -> std::path::PathBuf { std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).map(std::path::PathBuf::from).unwrap_or_else(|_| std::path::PathBuf::from(".")) }+fn remember_signin_profile(p: &str) { let _ = std::fs::write(signin_cfg_path(), json!({"profile": p}).to_string()); }+pub fn remembered_signin_profile_pub() -> Option<String> { remembered_signin_profile() }+fn remembered_signin_profile() -> Option<String> { std::fs::read_to_string(signin_cfg_path()).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v["profile"].as_str().filter(|s| !s.is_empty()).map(str::to_string)) }+pub fn track_signin_window(session: &str, profile: &str, kind: &str) { if !session.is_empty() { SIGNIN_WINDOWS.lock().unwrap().push(json!({"sessionId": session, "profile": profile, "kind": kind, "openedAt": now()})); } }++/// `_chrome_authorize_urls`: recent Autodesk-desktop OAuth authorize URLs from every Chrome and+/// Edge profile's History (copied first; Chrome keeps it locked). Newest first.+pub fn chrome_authorize_urls(max_age_min: u64) -> Vec<Value> {+ let Some(la) = std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from) else { return vec![] };+ let roots = [("chrome", la.join("Google").join("Chrome").join("User Data")), ("edge", la.join("Microsoft").join("Edge").join("User Data"))];+ let t = now();+ let mut found = Vec::new();+ for (browser, root) in roots {+ let Ok(rd) = std::fs::read_dir(&root) else { continue };+ for e in rd.flatten() {+ let hist = e.path().join("History");+ if !hist.is_file() { continue; }+ let prof = e.file_name().to_string_lossy().to_string();+ let tmp = std::env::temp_dir().join(format!("adom_hist_{browser}_{prof}.db"));+ if std::fs::copy(&hist, &tmp).is_err() { continue; }+ for ext in ["-wal", "-shm"] { let side = hist.with_file_name(format!("History{ext}")); if side.exists() { let _ = std::fs::copy(&side, tmp.with_file_name(format!("adom_hist_{browser}_{prof}.db{ext}"))); } }+ let Ok(con) = rusqlite::Connection::open(&tmp) else { continue };+ let _ = con.execute_batch("PRAGMA journal_mode=WAL");+ let rows: Vec<(String, i64)> = match con.prepare("SELECT url, last_visit_time FROM urls WHERE url LIKE '%developer.api.autodesk.com/authentication/v2/authorize%' OR url LIKE '%idp.auth.autodesk.com/as/authorize%' ORDER BY last_visit_time DESC LIMIT 6") {+ Ok(mut st) => st.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))).map(|it| it.flatten().collect()).unwrap_or_default(),+ Err(_) => continue,+ };+ for (url, cts) in rows {+ if !url.contains("idsdk") && !url.contains("idmgr") { continue; }+ let epoch = (cts as f64 / 1_000_000.0) - 11644473600.0;+ let age = t - epoch;+ if age <= (max_age_min * 60) as f64 { found.push(json!({"url": url, "sourceProfileDir": prof, "browser": browser, "ageSec": age as i64})); }+ }+ }+ }+ found.sort_by_key(|x| x["ageSec"].as_i64().unwrap_or(0));+ found+}+fn capture_fresh_authorize(wait_sec: u64, max_age: i64) -> Option<Value> {+ let deadline = Instant::now() + Duration::from_secs(wait_sec.max(1));+ loop {+ let urls = chrome_authorize_urls(15);+ if let Some(u) = urls.first() { if u["ageSec"].as_i64().unwrap_or(0) <= max_age { return Some(u.clone()); } }+ if Instant::now() >= deadline { return urls.first().cloned(); }+ sleep_s(2.0);+ }+}+/// `_fusion_window`: the main Fusion window row (prefers the sign-in/welcome window).+fn fusion_window(ctx: &Ctx) -> Value {+ let mut cand = json!({});+ for w in windows(ctx) {+ let tl = w["title"].as_str().unwrap_or("").to_lowercase();+ if tl.contains("autodesk fusion") || tl.starts_with("signing in") {+ if tl.contains("signing in") || tl.contains("welcome") { return w; }+ if cand.as_object().map(|o| o.is_empty()).unwrap_or(true) { cand = w; }+ }+ }+ cand+}+/// `_fusion_click_signin`: click Fusion's Welcome -> Sign In button ON THE USER'S BEHALF, always.+fn fusion_click_signin(ctx: &Ctx, env: &Env) -> Value {+ let win = fusion_window(ctx);+ let hwnd = win["hwnd"].as_i64().or_else(|| unwrap(&(env.readiness)())["hwnd"].as_i64());+ let Some(hwnd) = hwnd else { return json!({"clicked": false, "why": "could not find the Fusion window"}) };+ let rect = &win["rect"];+ let (wd, ht) = (rect["width"].as_i64().unwrap_or(0), rect["height"].as_i64().unwrap_or(0));+ if rect["left"].as_i64().map(|l| l <= -30000).unwrap_or(false) || (0 < wd && wd < 600) || (0 < ht && ht < 400) {+ let _ = ab_call(ctx, "desktop_set_window_state", json!({"hwnd": hwnd, "state": "restore"}), 30);+ sleep_s(1.5);+ }+ let raw = ab_call(ctx, "desktop_screenshot_window", json!({"hwnd": hwnd}), 45);+ let shot = unwrap(&raw);+ let cm = find_coordmap(&raw, 0);+ if cm["shotId"].is_null() {+ if let (Some(l), Some(t)) = (rect["left"].as_i64(), rect["top"].as_i64()) { if wd > 400 && ht > 300 {+ let (sx, sy) = (l + wd / 2, (t as f64 + ht as f64 * 0.57) as i64);+ let rr = unwrap(&ab_call(ctx, "desktop_click", json!({"x": sx, "y": sy, "hwnd": hwnd, "reason": "signing Fusion in for you (clicking Sign In, screen-space fallback)"}), 30));+ let okc = truthy(&rr["success"]) || truthy(&rr["ok"]) || truthy(&rr["status"]);+ return json!({"clicked": okc, "why": format!("screen-space fallback click at ({sx},{sy}) rect {wd}x{ht}: {}", if okc { "ok".to_string() } else { rr["error"].to_string().chars().take(80).collect() }), "via": "screen-fallback"});+ } }+ return json!({"clicked": false, "why": format!("screenshot returned no shotId (hwnd={hwnd} err={})", raw["error"].to_string().chars().take(100).collect::<String>())});+ }+ let img = &cm["image"];+ let w = shot["width"].as_f64().or(img["w"].as_f64()).or(img["width"].as_f64()).or(cm["imageWidth"].as_f64()).unwrap_or(1400.0);+ let h = shot["height"].as_f64().or(img["h"].as_f64()).or(img["height"].as_f64()).or(cm["imageHeight"].as_f64()).unwrap_or(860.0);+ let r = click_background_first(ctx, hwnd, Some("Sign In"), Some((&cm["shotId"], (w * 0.5) as i64, (h * 0.57) as i64)), Some("signing Fusion in for you (clicking Sign In)"));+ json!({"clicked": r["clicked"], "why": format!("{} (image {}x{})", r["why"].as_str().unwrap_or(""), w as i64, h as i64), "via": r["via"]})+}+/// `_drive_sso_signin`: finish the Autodesk sign-in over CDP using SSO - no password, no 2FA,+/// no foreground. Must be a FRESH flow, and the provider is clicked BEFORE any email is typed.+fn drive_sso_signin(ctx: &Ctx, profile: &str, tab: &Value, email: Option<&str>, provider: &str) -> Value {+ let mut steps: Vec<String> = Vec::new();+ let click = |text: &str| -> bool { let r = unwrap(&ab_call(ctx, "nbrowser_click", json!({"profile": profile, "tabId": tab, "text": text}), 45)); truthy(&r["ok"]) || truthy(&r["success"]) || truthy(&r["changed"]) };+ let btn = SSO_BUTTONS.iter().find(|b| b.0 == provider.to_lowercase()).map(|b| b.1).unwrap_or("Continue with Google");+ if !click(btn) { return json!({"done": false, "steps": steps, "why": format!("{} not on the page - this is probably NOT a fresh flow (a resumed flowId goes straight to the password screen).", fusion_lbr::py_repr_str(btn))}); }+ steps.push(format!("clicked {}", fusion_lbr::py_repr_str(btn)));+ sleep_s(7.0);+ if let Some(e) = email { if click(e) { steps.push(format!("picked the {e} account")); sleep_s(8.0); } }+ let ev = unwrap(&ab_call(ctx, "nbrowser_eval", json!({"profile": profile, "tabId": tab, "expression": "JSON.stringify({u:location.href.slice(0,120),b:document.body.innerText.slice(0,200)})"}), 45));+ let state = ev["result"].as_str().or(ev["value"].as_str()).map(str::to_string).unwrap_or_else(|| ev["result"].to_string());+ if state.contains("You're signed in") || state.to_lowercase().contains("signed in") {+ steps.push("provider returned 'You're signed in'".into());+ if click("Open Product") { steps.push("clicked 'Open Product' to hand the token to Fusion".into()); }+ return json!({"done": true, "steps": steps, "state": state.chars().take(200).collect::<String>()});+ }+ json!({"done": false, "steps": steps, "state": state.chars().take(200).collect::<String>(), "why": "did not reach the signed-in page; a provider password/2FA may be required"})+}++/// `fusion_signin`.+pub fn signin(ctx: &Ctx, args: &Value, env: &Env) -> Value {+ let mut steps: Vec<String> = Vec::new();+ let auto = args["auto"].as_bool().unwrap_or(true);+ let wait_sec = args["waitSec"].as_u64().filter(|w| *w > 0).unwrap_or(32);+ let mut target: Option<String> = args["profile"].as_str().filter(|s| !s.is_empty()).map(str::to_string);+ if let Some(t) = &target { remember_signin_profile(t); }+ if target.is_none() { target = remembered_signin_profile(); }+ let mut rd = (env.readiness)();+ if rd["running"].as_bool() != Some(true) {+ steps.push("Fusion was not running - launching it (background) as part of sign-in".into());+ let s = (env.start)();+ if s["success"].as_bool() == Some(false) { steps.push(format!("launch failed: {}", s["error"].to_string().chars().take(80).collect::<String>())); }+ let deadline = Instant::now() + Duration::from_secs(120);+ while Instant::now() < deadline { sleep_s(8.0); rd = (env.readiness)(); if rd["running"].as_bool() == Some(true) { break; } }+ if rd["running"].as_bool() != Some(true) {+ return json!({"success": false, "stage": "launch_failed", "done": false, "steps": steps, "error": "Fusion did not come up within 120s of launch.", "_hint": "Check fusion_readiness; the box may be mid-update. Retry fusion_signin.", "statusVerb": "fusion_readiness"});+ }+ sleep_s(5.0);+ }+ if rd["needsSignin"].as_bool() != Some(true) {+ return json!({"success": true, "stage": "already", "done": true, "steps": steps, "narrate": "Fusion is already signed in.", "_hint": format!("Already signed in (ready:{}). Nothing to do.", if rd["ready"].as_bool() == Some(true) { "True" } else { "False" }), "statusVerb": "fusion_signin"});+ }+ let profs = ab_call(ctx, "nbrowser_profiles", json!({}), 40);+ let pdata = if profs["data"].is_object() { profs["data"].clone() } else { profs.clone() };+ let mut choices: Vec<Value> = pdata["profiles"].as_array().cloned().unwrap_or_default().into_iter().filter(|p| p.is_object() && p["profile"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).collect();+ let mut urls = chrome_authorize_urls(15);+ let fresh = urls.first().filter(|u| u["ageSec"].as_i64().unwrap_or(999) <= 150).cloned();+ if auto && fresh.is_none() {+ let ck = fusion_click_signin(ctx, env);+ steps.push(if ck["clicked"].as_bool() == Some(true) { "clicked Fusion 'Sign In' for the user".to_string() } else { format!("could not click Fusion 'Sign In' ({})", ck["why"].as_str().unwrap_or("")) });+ urls = capture_fresh_authorize(wait_sec, 140).map(|g| vec![g]).unwrap_or_default();+ }+ let Some(auth) = urls.first().cloned() else {+ return json!({"success": true, "stage": "click_signin", "done": false, "steps": steps, "narrate": "I clicked Fusion's Sign In for you, but Fusion has not written its sign-in URL to a browser yet. Give it a few seconds.",+ "_hint": "THIS VERB CLICKS FUSION'S 'Sign In' FOR THE USER - never tell them to go click it themselves, and do not click it yourself. It just did (see steps[]), but no Fusion OAuth URL has landed in any browser's history yet. Simply CALL fusion_signin AGAIN in a few seconds; it will click if needed, wait on-box for the URL, and re-open it in the right profile. If steps[] says the click FAILED, that is a bridge bug worth reporting - the usual causes are a minimized Fusion window (this verb restores it) or the sign-in wall not being up yet (check fusion_readiness needsSignin:true).", "statusVerb": "fusion_signin"});+ };+ let age = auth["ageSec"].as_i64().unwrap_or(0);+ let src_dir = auth["sourceProfileDir"].as_str().unwrap_or("").to_string();+ steps.push(format!("captured Fusion OAuth URL from {} profile '{}' ({}s old)", auth["browser"].as_str().unwrap_or(""), src_dir, age));+ if age > 150 {+ return json!({"success": true, "stage": "stale", "done": false, "steps": steps, "signinAuthUrl": auth["url"], "signinAgeSec": age,+ "_hint": format!("The newest Fusion sign-in URL is {age}s old - Fusion expires the request in ~2 min, so completing it yields 'Sign-in request expired' and NO handoff. Get a FRESH request: fusion_stop then fusion_start (resets Fusion to a clean 'Welcome to Fusion'), click Fusion's Sign In, and call fusion_signin again PROMPTLY. Completion is near-instant when the target profile is already signed into Autodesk (auto-consents). Skill: fusion-multiprofile-signin."), "statusVerb": "fusion_signin"});+ }+ let mut reason = String::new();+ if target.is_none() {+ for c in choices.iter_mut() {+ if c["live"].as_bool() != Some(true) { continue; }+ let ls = ab_call(ctx, "nbrowser_login_state", json!({"profile": c["profile"], "url": "https://accounts.autodesk.com/"}), 40);+ let lsd = if ls["data"].is_object() { ls["data"].clone() } else { ls.clone() };+ c["_ad"] = json!(if lsd["loggedIn"].as_bool() == Some(true) { "signed-in" } else if !lsd["ok"].is_null() { "signed-out" } else { "unprobed" });+ c["_conf"] = lsd["confidence"].clone(); c["_cookies"] = lsd["cookieCount"].clone();+ }+ let mut signed: Vec<Value> = choices.iter().filter(|c| c["_ad"].as_str() == Some("signed-in")).cloned().collect();+ if !signed.is_empty() {+ signed.sort_by_key(|c| std::cmp::Reverse((match c["_conf"].as_str() { Some("high") => 3, Some("medium") => 2, Some("low") => 1, _ => 0 }, c["_cookies"].as_i64().unwrap_or(0))));+ target = signed[0]["profile"].as_str().map(str::to_string); reason = "it holds a live Autodesk session".into();+ } else if let Some(w) = choices.iter().find(|c| c["live"].as_bool() == Some(true) && c["email"].as_str().map(|e| !e.is_empty() && !CONSUMER.iter().any(|d| e.to_lowercase().ends_with(&format!("@{d}")))).unwrap_or(false)) {+ target = w["profile"].as_str().map(str::to_string); reason = "it is a work/corporate profile (no live Autodesk session detected - CONFIRM)".into();+ }+ }+ if let Some(t) = &target { remember_signin_profile(t); if reason.is_empty() { reason = "you told me to use it".into(); } }+ let Some(target) = target else {+ return json!({"success": true, "stage": "ask_profile", "done": false, "steps": steps, "signinAuthUrl": auth["url"], "profileChoices": choices,+ "_hint": "Captured Fusion's OAuth URL but cannot tell which profile is your Autodesk account. ASK the user which profile their Autodesk login belongs to, then call fusion_signin {profile:'chrome:<their-account>'} - I will remember it. profileChoices lists every profile.", "statusVerb": "fusion_signin"});+ };+ let chosen = choices.iter().find(|c| c["profile"].as_str() == Some(&target)).cloned();+ let tdesc = chosen.as_ref().map(describe_profile).unwrap_or_else(|| target.clone());+ let sess = format!("fusion-signin-{}", now() as i64);+ let target_dir = chosen.as_ref().and_then(|c| c["profileDir"].as_str().or(c["dir"].as_str()).map(str::to_string));+ let email = args["email"].as_str().map(str::to_string).or_else(|| chosen.as_ref().and_then(|c| c["email"].as_str().filter(|e| !e.is_empty()).map(str::to_string)));+ if let Some(td) = &target_dir { if !src_dir.is_empty() && td.trim().to_lowercase() == src_dir.trim().to_lowercase() {+ steps.push(format!("Fusion already opened the sign-in in {} (profile dir {}) - no re-open needed", target, fusion_lbr::py_repr_str(td)));+ let mut wall0 = detect_signin_wall(ctx, &[]);+ if wall0["wall"].is_string() { wall0 = resolve_and_advance_wall(ctx, wall0, email.as_deref()); }+ let notified0 = wall0["wall"].as_str().map(|w| notify_signin_wall(ctx, w)).unwrap_or(false);+ let wh = wall0["wall"].as_str().map(|w| wall_hint(w, notified0));+ return json!({"success": true, "stage": "already_right_profile", "done": false, "steps": steps, "signinProfile": target, "signinProfileDir": td, "signinWhere": target, "signinWall": wall0["wall"], "signinWallHwnd": wall0["hwnd"], "notifyDelivered": notified0, "signinWallHint": wh, "signinAuthUrl": auth["url"],+ "narrate": format!("Fusion opened its sign-in in the RIGHT profile already ({target}), so I did not open a second window."),+ "_hint": format!("NO re-open was needed - Fusion's OS-default browser IS the user's Autodesk profile here (dir {}). Do NOT open another window; that just litters their screen with a duplicate sign-in. The existing window is the one to finish. {}", fusion_lbr::py_repr_str(td), wh.unwrap_or_else(|| "Poll fusion_readiness until ready:true.".into())), "statusVerb": "fusion_readiness"});+ } }+ let pre: Vec<i64> = windows(ctx).iter().filter(|w| { let t = w["title"].as_str().unwrap_or("").to_lowercase(); t.contains("autodesk") && !t.contains("fusion") }).filter_map(|w| w["hwnd"].as_i64()).collect();+ let open_args = json!({"sessionId": sess, "url": auth["url"], "background": true, "profile": target, "thread": "fusion-signin", "purpose": "Fusion Autodesk sign-in (correct profile)"});+ let mut r = ab_call(ctx, "nbrowser_open_window", open_args.clone(), 60);+ let rd_open = if r["data"].is_object() { r["data"].clone() } else { r.clone() };+ let mut opened = !rd_open["sessionId"].is_null() || truthy(&r["ok"]) || truthy(&r["success"]) || truthy(&rd_open["opened"]) || r["_hint"].as_str().unwrap_or("").contains("opened in the BACKGROUND");+ let mut open_via: Option<&str> = if opened { Some("extension") } else { None };+ if !opened {+ let r2 = ab_call(ctx, "nbrowser_open_os_window", open_args, 60);+ if r2["_adCallError"].is_null() && (truthy(&r2["ok"]) || !r2["hwnd"].is_null() || r2["_hint"].as_str().unwrap_or("").contains("Launch fired")) { opened = true; open_via = Some("extension_free"); r = r2; }+ }+ steps.push(if opened { format!("re-opened it in {}{}", tdesc, if open_via == Some("extension_free") { " (extension-free)" } else { "" }) } else { format!("could not open {} (raw: {})", tdesc, r.to_string().chars().take(160).collect::<String>()) });+ if opened { track_signin_window(&sess, &target, "fusion"); }+ if opened && open_via == Some("extension") && args["driveSso"].as_bool().unwrap_or(true) {+ let tabs = unwrap(&ab_call(ctx, "nbrowser_list_tabs", json!({"profile": target}), 45))["tabs"].as_array().cloned().unwrap_or_default();+ let my_tab = tabs.iter().filter(|t| t["url"].as_str().unwrap_or("").to_lowercase().contains("autodesk") && t["owner"].as_str() == Some("agent-opened")).last().map(|t| if t["tabId"].is_null() { t["id"].clone() } else { t["tabId"].clone() });+ if let Some(tab) = my_tab {+ let sso = drive_sso_signin(ctx, &target, &tab, email.as_deref(), args["ssoProvider"].as_str().unwrap_or("google"));+ for s in sso["steps"].as_array().cloned().unwrap_or_default() { if let Some(x) = s.as_str() { steps.push(x.to_string()); } }+ if sso["done"].as_bool() == Some(true) {+ for _ in 0..6 {+ sleep_s(5.0);+ if unwrap(&(env.readiness)())["ready"].as_bool() == Some(true) {+ steps.push("Fusion reports ready:true - SIGNED IN".into());+ let (conf, signed) = (env.aps_state)();+ if conf && !signed {+ let _ = (env.aps_signin)(&target);+ steps.push("APS not signed in - started its consent on the SAME warm SSO session (no second login for the user)".into());+ for _ in 0..5 { sleep_s(4.0); if (env.aps_state)().1 { steps.push("APS signed in too - one login covered both".into()); break; } }+ }+ let (conf, signed) = (env.aps_state)();+ return json!({"success": true, "stage": "signed_in", "done": true, "steps": steps, "signinProfile": target, "signinWhere": tdesc, "signinOpenedVia": open_via, "aps": {"configured": conf, "signedIn": signed},+ "narrate": format!("Fusion is signed in as {}. No password or 2FA was needed - I used the browser profile's existing SSO session.{}", email.clone().unwrap_or_else(|| target.clone()), if signed { " APS cloud search is set up too, from the same login." } else { "" }),+ "_hint": format!("DONE - Fusion is signed in (ready:true). Nothing else to do; do not tell the user to sign in. {} Drive Fusion normally now.", if signed { "APS is signed in as well, so fusion_aps_search is live." } else { "APS is NOT signed in - run fusion_aps_signin NOW while the browser SSO session is still warm so it consents silently." }), "statusVerb": "fusion_readiness"});+ }+ }+ }+ }+ }+ let mut wall = if opened { detect_signin_wall(ctx, &pre) } else { json!({}) };+ if wall["wall"].is_string() { wall = resolve_and_advance_wall(ctx, wall, email.as_deref()); }+ let wall_kind = wall["wall"].as_str().map(str::to_string);+ let mut notified = false;+ if let Some(wk) = &wall_kind { notified = notify_signin_wall(ctx, wk); steps.push(format!("hit {wk} wall; toasted the user (delivered={})", if notified { "True" } else { "False" })); }+ if !opened {+ let mut live = detect_signin_wall(ctx, &[]);+ if live["wall"].is_string() {+ live = resolve_and_advance_wall(ctx, live, args["email"].as_str());+ let wk = live["wall"].as_str().unwrap_or("").to_string();+ let notified_fb = if wk.is_empty() { false } else { notify_signin_wall(ctx, &wk) };+ let adv = live["advance"].as_str().map(str::to_string).unwrap_or_else(|| format!("wall {}", fusion_lbr::py_repr_str(&wk)));+ steps.push(format!("no extension on this box - drove the EXISTING browser window instead: {adv}"));+ return json!({"success": true, "stage": "driving_existing_window", "done": false, "steps": steps, "signinWall": wk, "signinWallHwnd": live["hwnd"], "advanced": live["advanced"], "notifyDelivered": notified_fb, "signinWallHint": wall_hint(&wk, notified_fb), "signinAuthUrl": auth["url"],+ "narrate": format!("This machine has no Adom browser extension, so I drove the sign-in window Fusion already opened: {adv}."),+ "_hint": format!("No ABE here, so the EXISTING browser window was driven directly (email typed via background UIA where possible). {}", wall_hint(&wk, notified_fb)), "statusVerb": "fusion_readiness"});+ }+ }+ json!({"success": true, "stage": if opened { "opened" } else { "open_failed" }, "done": false, "steps": steps, "signinWhere": tdesc, "signinProfile": target, "signinOpenedVia": open_via, "signinWall": wall_kind, "signinWallHwnd": wall["hwnd"], "notifyDelivered": notified,+ "signinWallHint": wall_kind.as_ref().map(|w| wall_hint(w, notified)), "signinProfileReason": reason, "signinSession": if opened { json!(sess) } else { Value::Null }, "signinWrongBrowser": format!("{} / {}", auth["browser"].as_str().unwrap_or(""), src_dir), "signinAuthUrl": auth["url"],+ "narrate": if opened { format!("Fusion opened its sign-in in the WRONG browser ({src_dir}), so I moved the real Autodesk sign-in URL into {tdesc} - I picked that because {reason}. Finish it there, or I can drive it, and Fusion will pick up the login automatically.") } else { "I captured Fusion's sign-in URL but could not open the target profile.".to_string() },+ "_hint": if opened { format!("The REAL Fusion OAuth URL is now open in {tdesc} (session 'fusion-signin'). It completes back to the running Fusion via the idmgr/callback + autodesk:// protocol handoff (request_id match), so the browser profile no longer has to be the OS default - THIS is the fix for Fusion signing into the wrong account. TELL the user the exact profile (never 'your browser'), say WHY it was chosen (signinProfileReason), and offer: they finish it, you foreground it, or (with their OK) you drive 'Continue with Google/Apple/Microsoft'. NEVER type their password/2FA. Then poll fusion_readiness until ready:true. The wrong-browser tab ({src_dir}) can be closed. If the wrong ACCOUNT completes anyway, sign Fusion out and re-run. Skill: fusion-multiprofile-signin.") } else { format!("Open failed: {}. Retry, or open auth url yourself with nbrowser_open_window {{profile:'{}', url:<signinAuthUrl>}}.", r["_adCallError"].as_str().unwrap_or("None"), target) },+ "statusVerb": "fusion_signin"})+}++/// `fusion_signin_2fa`: submit Autodesk's emailed code IN THE BACKGROUND (UIA SetValue + Invoke).+pub fn signin_2fa(ctx: &Ctx, args: &Value) -> Value {+ let code = args["code"].as_str().map(str::to_string).or_else(|| args["code"].as_i64().map(|c| c.to_string())).unwrap_or_default();+ let digits: String = code.chars().filter(|c| c.is_ascii_digit()).collect();+ if digits.len() < 4 {+ return json!({"success": false, "errorCode": "bad_code", "error": "Pass the emailed code, e.g. fusion_signin_2fa {\"code\":\"123456\"}.", "_hint": "Read it from the user's Gmail if you can: adom-google gmail search 'from:autodesk verification code' --limit 1 -> take the 6 digits -> call this verb again. If adom-google is not installed, tell the user it would let you automate this.", "statusVerb": "fusion_signin_2fa"});+ }+ let wall = detect_signin_wall(ctx, &[]);+ let Some(hwnd) = args["hwnd"].as_i64().or_else(|| wall["hwnd"].as_i64()) else {+ return json!({"success": false, "errorCode": "no_2fa_window", "error": "No Autodesk 2-step-verification window found.", "_hint": "Nothing is waiting on a code right now. Check fusion_readiness - if needsSignin is still true, run fusion_signin again to restart the flow.", "statusVerb": "fusion_readiness"});+ };+ let found = unwrap(&ab_call(ctx, "desktop_find_control", json!({"hwnd": hwnd, "role": "edit"}), 40));+ let ctrls = found["controls"].as_array().or(found["matches"].as_array()).cloned().unwrap_or_default();+ let mut steps: Vec<String> = Vec::new();+ let mut filled = false;+ if ctrls.len() >= digits.len() && ctrls.len() >= 4 {+ for (i, ch) in digits.chars().take(ctrls.len()).enumerate() { let _ = ab_call(ctx, "desktop_ui_set", json!({"hwnd": hwnd, "automationId": ctrls[i]["automationId"], "name": ctrls[i]["name"], "value": ch.to_string()}), 20); }+ filled = true; steps.push(format!("typed {} digits into {} segmented boxes (background UIA)", digits.len(), ctrls.len()));+ } else if let Some(c) = ctrls.first() {+ let r = unwrap(&ab_call(ctx, "desktop_ui_set", json!({"hwnd": hwnd, "automationId": c["automationId"], "name": c["name"], "value": digits}), 20));+ filled = truthy(&r["ok"]) || truthy(&r["set"]) || r.is_object();+ steps.push(format!("typed the code into {} (background UIA)", fusion_lbr::py_repr_str(c["name"].as_str().or(c["automationId"].as_str()).unwrap_or("None"))));+ }+ if !filled {+ return json!({"success": false, "errorCode": "code_field_not_found", "error": "Could not find the code field via UIA.", "controlsSeen": ctrls.iter().take(8).collect::<Vec<_>>(),+ "_hint": format!("The code input was not in the UIA tree (shadow DOM). Run desktop_find_control {{hwnd:{hwnd}}} to see what IS exposed. Last resort is a FOREGROUNDED desktop_click on the field then desktop_type - avoid that if you can, foregrounding is disruptive to the user."), "statusVerb": "fusion_signin_2fa"});+ }+ let mut sub = click_background_first(ctx, hwnd, Some("Next"), None, None);+ if sub["clicked"].as_bool() != Some(true) { sub = click_background_first(ctx, hwnd, Some("Verify"), None, None); }+ steps.push(format!("submitted via {}", sub["via"].as_str().unwrap_or("no submit control found")));+ json!({"success": true, "submitted": true, "steps": steps, "narrate": "I read the code in and submitted it for you, without touching your screen.",+ "_hint": "Code submitted in the BACKGROUND. Now poll fusion_readiness until ready:true. If a Chrome 'Open Autodesk Identity Manager?' dialog appears, clear it with desktop_ui_click {name:'Open'} (background) - fusion_signin reports that wall too. If it says the code was wrong, the email may have a NEWER code.", "statusVerb": "fusion_readiness"})+}++/// `fusion_web_signin`: sign a pup window into an Autodesk web surface (Google SSO by default);+/// never guesses a credential.+pub fn web_signin(ctx: &Ctx, args: &Value) -> Value {+ let url = args["url"].as_str().unwrap_or("").trim().to_string();+ if url.is_empty() { return json!({"success": false, "error": "url is required (e.g. https://library.io/ or https://<hub>.autodesk360.com/)"}); }+ let sess = args["sessionId"].as_str().filter(|s| !s.is_empty()).unwrap_or("autodesk-web").to_string();+ let provider = args["provider"].as_str().filter(|s| !s.is_empty()).unwrap_or("google").to_lowercase();+ let user = args["username"].as_str().unwrap_or("").to_string();+ let mut steps: Vec<Value> = Vec::new();+ let pup = |verb: &str, a: Value, t: u64| ab_call(ctx, &format!("pup_{verb}"), a, t);+ let state = || { let r = pup("eval", json!({"sessionId": sess, "expression": "({url:location.href,body:document.body.innerText.replace(/\\s+/g,' ').slice(0,400)})"}), 60); if r["result"].is_object() { r["result"].clone() } else { json!({}) } };+ let _ = pup("open_window", json!({"sessionId": sess, "url": url, "reason": args["reason"].as_str().unwrap_or("sign in to an Autodesk web surface")}), 120);+ sleep_s(8.0);+ let st = state(); steps.push(json!({"step": "open", "url": st["url"].as_str().unwrap_or("").chars().take(120).collect::<String>()}));+ let body = st["body"].as_str().unwrap_or("").to_string();+ if !st["url"].as_str().unwrap_or("").contains(AUTODESK_SIGNIN_HOST) && !body.contains("Sign in to Autodesk") {+ return json!({"success": true, "alreadySignedIn": true, "sessionId": sess, "steps": steps, "_hint": "That surface was already authenticated in this pup profile."});+ }+ if provider == "google" {+ let r = pup("eval", json!({"sessionId": sess, "expression": "(()=>{const b=[...document.querySelectorAll('button,a,div[role=button]')].find(e=>/Continue with Google/i.test(e.textContent));if(!b)return {found:false};b.click();return {clicked:true};})()"}), 60);+ steps.push(json!({"step": "google-sso", "result": r["result"]}));+ sleep_s(9.0);+ let st = state();+ if !st["url"].as_str().unwrap_or("").contains("accounts.google.com") {+ return json!({"success": false, "errorCode": "google_sso_unavailable", "steps": steps, "state": st["body"].as_str().unwrap_or("").chars().take(200).collect::<String>(), "_hint": "The page did not hand off to Google. Retry with provider:'password'."});+ }+ }+ let lg = pup("login", json!({"sessionId": sess, "url": if provider == "google" { "https://accounts.google.com/".to_string() } else { url.clone() }, "username": if user.is_empty() { Value::Null } else { json!(user) }, "reason": "sign the user in so Adom can drive their Autodesk web surfaces"}), 120);+ steps.push(json!({"step": "vault-login", "used": lg["used"], "ok": lg["success"]}));+ sleep_s(8.0);+ let st = state();+ let at = st["url"].as_str().unwrap_or("").to_string(); let body = st["body"].as_str().unwrap_or("").to_string();+ if at.contains("accounts.google.com") || at.contains(AUTODESK_SIGNIN_HOST) {+ let bl = body.to_lowercase();+ let needs_2fa = ["verify it", "2-step", "verification", "authenticator", "code was sent"].iter().any(|k| bl.contains(k));+ return json!({"success": false, "errorCode": if needs_2fa { "second_factor_required" } else { "human_credential_required" }, "sessionId": sess, "steps": steps, "at": at.chars().take(120).collect::<String>(), "page": body.chars().take(200).collect::<String>(),+ "error": if needs_2fa { "Google is asking for the second factor, which only the user can supply." } else { "Stopped at the sign-in page: pup has no stored credential for this user" },+ "_hint": "ONE-TIME setup, run by the USER so no password ever reaches the AI:\n adom-bridge credential_set '{\"host\":\"accounts.google.com\",\"username\":\"<you>@<domain>\",\"password\":\"<your Google password>\"}'\nthen re-run this verb. For 2FA, approve the prompt on your phone; the session then persists in pup's profile and later runs are silent."});+ }+ json!({"success": true, "signedIn": true, "sessionId": sess, "steps": steps, "at": at.chars().take(120).collect::<String>(), "_hint": "Signed in. The session persists in pup's profile, so library.io, the hub portal and Autodesk Account are now drivable for this user."})+}++const JANITOR_MAX_AGE: f64 = 900.0;+/// `_signin_janitor_loop` (the sign-in half): poll until each tracked sign-in window can be+/// closed, then close it. `ready` = Fusion ready:true; `aps_signed` = APS signedIn.+pub fn start_janitor(ready: std::sync::Arc<dyn Fn() -> bool + Send + Sync>, aps_signed: std::sync::Arc<dyn Fn() -> bool + Send + Sync>, caller: fusion_core::ab::Caller) {+ std::thread::spawn(move || loop {+ sleep_s(20.0);+ let pending = SIGNIN_WINDOWS.lock().unwrap().clone();+ if pending.is_empty() { continue; }+ let mut goals: std::collections::HashMap<String, bool> = std::collections::HashMap::new();+ for w in pending {+ let kind = w["kind"].as_str().unwrap_or("fusion").to_string();+ let met = *goals.entry(kind.clone()).or_insert_with(|| if kind == "aps" { aps_signed() } else { ready() });+ let aged = now() - w["openedAt"].as_f64().unwrap_or(0.0) > JANITOR_MAX_AGE;+ if met || aged {+ let closed = Loopback::from_env().map(|ab| ab.call("nbrowser_close_window", json!({"sessionId": w["sessionId"], "profile": w["profile"]}), &caller, 30).is_ok()).unwrap_or(false);+ if closed { eprintln!("[fusion-bridge] janitor: closed {} sign-in window {} ({})", kind, w["sessionId"], if met { "signed in" } else { "expired" }); }+ SIGNIN_WINDOWS.lock().unwrap().retain(|x| x["sessionId"] != w["sessionId"]);+ }+ }+ });+}
rust/bridge/src/small_verbs.rsadded+464@@ -0,0 +1,464 @@+//! State-only verbs the exe answers natively: the sticky preference toggles, cancel_run,+//! addin_status and plan_library_import. Each mirrors its `_handle_*` in server.py to the+//! word (hints included), so a caller cannot tell which shell answered except by `shell`.++use serde_json::{json, Value};+use std::path::Path;++fn prefs_path(state_dir: &Path) -> std::path::PathBuf { state_dir.join("prefs.json") }++pub fn get_pref(state_dir: &Path, key: &str) -> Option<Value> {+ let t = std::fs::read_to_string(prefs_path(state_dir)).ok()?;+ serde_json::from_str::<Value>(t.trim_start_matches('\u{feff}')).ok()?.get(key).cloned()+}++fn pref_bool(state_dir: &Path, key: &str, default: bool) -> bool {+ match get_pref(state_dir, key) { Some(Value::Bool(b)) => b, Some(Value::Null) | None => default, Some(v) => !(v == json!(0) || v == json!("")) }+}++/// `_set_bridge_pref`: read-modify-write of prefs.json (indent 2, BOM-free, atomic).+pub fn set_pref(state_dir: &Path, key: &str, value: Value) -> bool {+ let p = prefs_path(state_dir);+ let mut cur = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str::<Value>(t.trim_start_matches('\u{feff}')).ok()).unwrap_or(json!({}));+ if !cur.is_object() { cur = json!({}); }+ cur[key] = value;+ let _ = std::fs::create_dir_all(state_dir);+ let tmp = p.with_extension("json.tmp");+ std::fs::write(&tmp, serde_json::to_string_pretty(&cur).unwrap_or_default()).is_ok() && std::fs::rename(&tmp, &p).is_ok()+}++fn on_off(b: bool) -> &'static str { if b { "ON" } else { "OFF" } }++pub fn set_auto_update(state_dir: &Path, args: &Value) -> Value {+ if args.get("enabled").is_none() {+ let cur = pref_bool(state_dir, "autoUpdateFusion", true);+ return json!({"success": true, "autoUpdateFusion": cur,+ "_hint": format!("Fusion auto-update is currently {}. It is ON BY DEFAULT: the bridge clicks Fusion's 'Update Now' for the user in the BACKGROUND so they always run the latest build and never see the nag. To opt out: fusion_set_auto_update {{\"enabled\": false}} - that choice is STICKY.", on_off(cur)),+ "statusVerb": "fusion_readiness"});+ }+ let enabled = truthy(&args["enabled"]);+ set_pref(state_dir, "autoUpdateFusion", json!(enabled));+ json!({"success": true, "autoUpdateFusion": enabled,+ "narrate": if enabled { "I'll keep Fusion updated automatically in the background." } else { "I'll stop auto-updating Fusion. You'll see Autodesk's own update prompts." },+ "_hint": format!("Saved and sticky (~/.adom/fusion-bridge/prefs.json). {}", if enabled {+ "Auto-update ON: the bridge silently clicks 'Update Now' whenever Fusion offers one, so the user always runs the newest build. Tell them it is handled and they never need to click it."+ } else { "Auto-update OFF: the bridge will NOT touch update prompts; the user handles Autodesk's nag themselves. Do not override this." }),+ "statusVerb": "fusion_readiness"})+}++pub fn set_seat_takeover(state_dir: &Path, args: &Value) -> Value {+ if args.get("enabled").is_none() {+ let cur = pref_bool(state_dir, "autoTakeOverSeat", true);+ return json!({"success": true, "autoTakeOverSeat": cur,+ "_hint": format!("Seat takeover is currently {}. ON BY DEFAULT: when Autodesk pops the 'Active Sessions Exceeded' dialogs mid-session, the bridge clicks Continue through BOTH in the background - this machine takes the seat, the other machine's Fusion suspends RESUMABLY, one toast tells the user. Opt out: fusion_set_seat_takeover {{\"enabled\": false}} - STICKY.", on_off(cur)),+ "statusVerb": "fusion_readiness"});+ }+ let enabled = truthy(&args["enabled"]);+ set_pref(state_dir, "autoTakeOverSeat", json!(enabled));+ json!({"success": true, "autoTakeOverSeat": enabled,+ "narrate": if enabled { "I'll automatically take the Fusion seat on this machine whenever Autodesk flags the session limit." } else { "I'll leave Autodesk seat-conflict dialogs for you to resolve yourself." },+ "_hint": format!("Sticky (~/.adom/fusion-bridge/prefs.json). {}", if enabled { "Takeover ON: mid-session seat dialogs are clicked through silently." } else { "Takeover OFF: the sweep will NOT touch seat dialogs; fusion_start still grabs the seat when the user asks for Fusion." }),+ "statusVerb": "fusion_readiness"})+}++pub fn set_window_sizing(state_dir: &Path, args: &Value) -> Value {+ if args.get("enabled").is_none() {+ let cur = pref_bool(state_dir, "sizeFusionWindow", true);+ return json!({"success": true, "sizeFusionWindow": cur,+ "_hint": format!("Fusion window auto-sizing is {}. ON BY DEFAULT: after a launch the bridge grows a small Fusion window to ~92% of the monitor work area, centered, WITHOUT activating it (SetWindowPos NOACTIVATE, never ShowWindow/maximize, so your focus is never stolen). A window you maximized or already sized large is left alone. Opt out: fusion_set_window_sizing {{\"enabled\": false}} - STICKY.", on_off(cur)),+ "statusVerb": "fusion_state"});+ }+ let enabled = truthy(&args["enabled"]);+ set_pref(state_dir, "sizeFusionWindow", json!(enabled));+ json!({"success": true, "sizeFusionWindow": enabled,+ "narrate": if enabled { "I'll size Fusion's window to fill most of your screen when it launches." } else { "I'll leave Fusion's window size exactly as Fusion opens it." },+ "statusVerb": "fusion_state"})+}++/// The read path and the pref write are native; turning the badge OFF also clears the+/// live overlay, which still lives in the sidecar's taskbar handler (`clear` is the proxy).+pub fn set_driving_badge(state_dir: &Path, args: &Value, clear: &dyn Fn()) -> Value {+ if args.get("enabled").is_none() {+ let cur = pref_bool(state_dir, "showDrivingBadge", true);+ return json!({"success": true, "showDrivingBadge": cur,+ "_hint": format!("The driving badge is currently {}. ON BY DEFAULT: while Adom drives Fusion, its taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds after 5 minutes idle. Opt out: fusion_set_driving_badge {{\"enabled\": false}} - STICKY.", on_off(cur)),+ "statusVerb": "fusion_state"});+ }+ let enabled = truthy(&args["enabled"]);+ set_pref(state_dir, "showDrivingBadge", json!(enabled));+ if !enabled { clear(); }+ json!({"success": true, "showDrivingBadge": enabled,+ "narrate": if enabled { "I'll badge Fusion's taskbar button while I'm driving it." } else { "I'll stop badging Fusion's taskbar button." },+ "_hint": "Sticky (~/.adom/fusion-bridge/prefs.json). Cleared immediately when turned off.",+ "statusVerb": "fusion_state"})+}++fn truthy(v: &Value) -> bool {+ match v { Value::Bool(b) => *b, Value::Null => false, Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0, Value::String(s) => !s.is_empty(), Value::Array(a) => !a.is_empty(), Value::Object(o) => !o.is_empty() }+}++/// `fusion_cancel_run`: cooperative stop at the next part boundary; shared run store.+pub fn cancel_run(args: &Value, banner: &dyn Fn(Value)) -> Value {+ let terminal = ["done", "failed", "cancelled"];+ let mut runs = crate::chains::read_runs();+ let mut rid = args["runId"].as_str().unwrap_or("").trim().to_string();+ if rid.is_empty() {+ let mut live: Vec<&Value> = runs.values().filter(|r| !terminal.contains(&r["stage"].as_str().unwrap_or(""))).collect();+ live.sort_by(|a, b| a["startedAt"].as_f64().unwrap_or(0.0).partial_cmp(&b["startedAt"].as_f64().unwrap_or(0.0)).unwrap());+ if let Some(r) = live.last() { rid = r["runId"].as_str().unwrap_or("").to_string(); }+ }+ if rid.is_empty() {+ return json!({"success": false, "errorCode": "no_run_in_flight", "error": "No import is running on this box, so there is nothing to cancel.", "_hint": "fusion_prove_result {} shows the latest run and its stage."});+ }+ let Some(mut st) = runs.get(&rid).cloned() else {+ return json!({"success": false, "errorCode": "unknown_run_id", "error": format!("no run with runId {} on this box", fusion_lbr::py_repr_str(&rid))});+ };+ let stage = st["stage"].clone();+ if terminal.contains(&stage.as_str().unwrap_or("")) {+ return json!({"success": true, "runId": rid, "alreadyFinished": true, "stage": stage, "_hint": format!("That run had already ended ({}); nothing to cancel.", stage.as_str().unwrap_or(""))});+ }+ let by: String = args["caller"]["aiThread"].as_str().or(args["by"].as_str()).unwrap_or("user").chars().take(60).collect();+ st["cancelRequested"] = json!(true);+ st["cancelledBy"] = json!(by);+ st["stageLabel"] = json!(format!("stopping at the next part boundary ({})", st["stageLabel"].as_str().or(st["stage"].as_str()).unwrap_or("")));+ runs.insert(rid.clone(), st.clone());+ let p = std::env::temp_dir().join("fusion-prove-runs.json");+ let _ = std::fs::write(&p, serde_json::to_string(&runs).unwrap_or_default());+ let lp = std::env::temp_dir().join("fusion-prove-last.json");+ if let Some(mut last) = std::fs::read_to_string(&lp).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {+ if last["runId"].as_str() == Some(&rid) { last["cancelRequested"] = json!(true); let _ = std::fs::write(&lp, last.to_string()); }+ }+ banner(json!({"action": "update", "title": "Stopping the import", "detail": "Finishing the current part, then stopping", "percent": Value::Null, "etaSec": Value::Null, "tone": "working"}));+ json!({"success": true, "runId": rid, "cancelRequested": true, "stage": stage, "statusVerb": "fusion_prove_result",+ "_hint": "The run stops at its next part boundary (a STEP import in progress finishes first, up to ~60s). Poll fusion_prove_result {runId}: stage becomes 'cancelled' with cancelledAt and what was completed. Parts already bound keep their 3D; the library stays open in Fusion, unsaved to the cloud."})+}++/// The bundled add-in's manifest version (beside the exe, `addin/AdomBridge/AdomBridge.manifest`).+pub fn expected_addin_version() -> String {+ let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).unwrap_or_default();+ std::fs::read_to_string(dir.join("addin").join("AdomBridge").join("AdomBridge.manifest")).ok()+ .and_then(|t| serde_json::from_str::<Value>(t.trim_start_matches('\u{feff}')).ok())+ .and_then(|v| v["version"].as_str().map(str::to_string)).unwrap_or_else(|| "unknown".into())+}++/// `_addin_staleness`.+pub fn addin_staleness(reported: Option<&str>) -> Value {+ let expected = expected_addin_version();+ let reported_s = reported.unwrap_or("unknown");+ let stale = reported.map(|r| r != "unknown").unwrap_or(false) && expected != "unknown" && reported_s != expected;+ let mut v = json!({"addinVersion": reported_s, "expectedAddinVersion": expected, "addinStale": stale});+ if stale {+ v["_staleHint"] = json!(format!("STALE add-in: Fusion is running add-in v{reported_s} but this bridge bundles v{expected}. Newer verbs (e.g. fusion_aps_open/open_by_urn) can fail silently. Fix: fusion_stop then fusion_start - the bridge re-syncs the add-in from its cache on start (only possible with Fusion CLOSED)."));+ }+ v+}++/// `_addin_start_failure`: the add-in's own start-failure marker.+pub fn addin_start_failure() -> Option<Value> {+ let base = std::env::var("TEMP").ok().filter(|s| !s.is_empty()).map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir);+ let fp = base.join("adom-fusion").join("addin-start-failed.json");+ let t = std::fs::read_to_string(&fp).ok()?;+ let mut m: Value = serde_json::from_str(&t).ok()?;+ if !m.is_object() { return None; }+ m["markerPath"] = json!(fp.to_string_lossy().replace('\\', "/"));+ 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("None")));+ Some(m)+}++/// `fusion_addin_status`: GET /status on the add-in, staleness, the start-failure marker.+pub fn addin_status(fusion_running: bool) -> Value {+ let status: Option<Value> = ureq::get("http://127.0.0.1:8774/status").timeout(std::time::Duration::from_secs(3)).call().ok().and_then(|r| r.into_json().ok());+ let Some(mut status) = status else {+ return json!({"success": false, "status": "unreachable", "errorCode": "addin_not_answering",+ "error": format!("The Adom add-in did not answer on port 8774. Fusion is {}running.", if fusion_running { "" } else { "NOT " }),+ "_hint": "If Fusion is running, the add-in was installed or updated after launch (or its server stopped): fusion_restart_for_addin {save:true} asks and restarts. If Fusion is not running, fusion_start."});+ };+ let stale = addin_staleness(status["version"].as_str());+ if let (Some(so), Some(st)) = (status.as_object_mut(), stale.as_object()) { for (k, v) in st { if k != "_staleHint" { so.insert(k.clone(), v.clone()); } } }+ let mut result = json!({"success": true, "output": serde_json::to_string(&status).unwrap_or_default()});+ if let (Some(ro), Some(so)) = (result.as_object_mut(), status.as_object()) { for (k, v) in so { ro.insert(k.clone(), v.clone()); } }+ if stale["addinStale"].as_bool() == Some(true) { result["_hint"] = stale["_staleHint"].clone(); }+ if let Some(asf) = addin_start_failure() { result["addinStartFailed"] = asf; }+ result+}++fn human_secs(v: f64) -> String {+ let v = v.round() as i64;+ if v < 60 { return format!("{v}s"); }+ let (m, s) = (v / 60, v % 60);+ if s != 0 { format!("{m}m {s:02}s") } else { format!("{m}m") }+}++/// `fusion_plan_library_import`: the pre-flight summary, no work done.+pub fn plan_library_import(state_dir: &Path, installed: bool, running: bool, addin_ok: bool, args: &Value) -> Value {+ let parts = args["parts"].as_array().cloned().unwrap_or_default();+ let n_parts = parts.len().max(1);+ let has_steps = parts.iter().any(|p| p["modelPath"].as_str().map(|s| !s.is_empty()).unwrap_or(false)) || args["stepPath"].as_str().map(|s| !s.is_empty()).unwrap_or(false);+ let inst = installed || running;+ let (status, note) = if !inst { ("not_installed", "Fusion does not look installed on this machine. I can install it for you - it is a silent install plus an Autodesk sign-in.") }+ else if !running { ("installed_not_running", "Fusion is installed but not running. I can launch it, which usually takes about a minute, and then carry on.") }+ else if !addin_ok { ("addin_restart_required", "Fusion is running but the Adom add-in is not loaded - it needs a quick restart to finish setup. I can save your documents and do it.") }+ else { ("ready", "Fusion is running and ready - I can start straight away.") };+ let est = |k: &str, f: f64| { let (v, c) = crate::chains::estimate(state_dir, k, f); ((v * 10.0).round() / 10.0, c) };+ let mut steps: Vec<Value> = Vec::new();+ if !inst { let (v, c) = est("stage:install_fusion", 1200.0); steps.push(json!({"stage": "install", "label": "install Fusion (streams the app)", "estimatedSec": v, "confidence": c, "needsUserConsent": true, "note": "10-30 minutes depending on connection, plus an Autodesk sign-in"})); }+ if inst && !running { let (v, c) = est("stage:launch", 60.0); steps.push(json!({"stage": "launch", "label": "open Fusion", "estimatedSec": v, "confidence": c})); }+ if status == "addin_restart_required" { let (v, c) = est("stage:launch", 60.0); steps.push(json!({"stage": "restart", "label": "restart Fusion to load the add-in", "estimatedSec": v, "confidence": c, "needsUserConsent": true})); }+ if has_steps { let (per, c) = est("stage:bind_per_part", 60.0); steps.push(json!({"stage": "bind", "label": format!("attach 3D to {} part{}", n_parts, if n_parts == 1 { "" } else { "s" }), "estimatedSec": ((per * n_parts as f64) * 10.0).round() / 10.0, "confidence": c, "parts": n_parts, "perPartSec": per, "note": "one STEP import per part - the slowest stage"})); }+ { let (v, c) = est("stage:prove", 300.0); steps.push(json!({"stage": "prove", "label": "capture 4 evidence views", "estimatedSec": v, "confidence": c})); }+ let total = (steps.iter().map(|s| s["estimatedSec"].as_f64().unwrap_or(0.0)).sum::<f64>() * 10.0).round() / 10.0;+ let ready_total = (steps.iter().filter(|s| !matches!(s["stage"].as_str(), Some("launch") | Some("restart"))).map(|s| s["estimatedSec"].as_f64().unwrap_or(0.0)).sum::<f64>() * 10.0).round() / 10.0;+ let conf = if steps.iter().all(|s| s["confidence"].as_str() == Some("measured")) { "measured" } else { "typical" };+ let tail = if ready_total != total { format!("Had Fusion already been open, about {}.", human_secs(ready_total)) } else { String::new() };+ json!({"success": true,+ "fusion": {"installed": inst, "running": running, "addinReady": addin_ok, "status": status, "message": note},+ "plan": steps, "partsTotal": n_parts, "estimatedSec": total, "estimatedSecIfAlreadyOpen": ready_total, "confidence": conf,+ "summary": format!("{} This will take about {}. {}", note, human_secs(total), tail).trim().to_string(),+ "_hint": "Pre-flight only - NOTHING was launched, imported or changed. Render `summary` plus `plan[]` as the progress bar's steps, then call fusion_create_library (or open_lbr + build_library_3d) and poll fusion_prove_result, which reports the SAME stage names so one renderer draws both. Estimates marked confidence:'measured' come from this machine's own recorded runs; 'typical' means seeded and not yet observed here."})+}++/// The host app's CLI (`adom-bridge-cli.exe` beside the app, else `adom-bridge` on PATH). The+/// Rust exe knows only the current name: the naming guard keeps retired names out of rust/.+fn find_ab_cli() -> Option<std::path::PathBuf> {+ let la = std::env::var("LOCALAPPDATA").unwrap_or_default();+ if !la.is_empty() {+ let p = std::path::Path::new(&la).join("Adom Bridge").join("adom-bridge-cli.exe");+ if p.is_file() { return Some(p); }+ }+ for name in ["adom-bridge-cli", "adom-bridge"] {+ if let Ok(path) = std::env::var("PATH") { for d in std::env::split_paths(&path) { for ext in ["", ".exe"] { let p = d.join(format!("{name}{ext}")); if p.is_file() { return Some(p); } } } }+ }+ None+}++/// `_cli_notify_all`: a sticky toast through the CLI when the loopback API is down.+fn cli_notify_all(state_dir: &Path, title: &str, body: &str, level: &str) -> Value {+ let Some(exe) = find_ab_cli() else { return json!({"delivered": false, "error": "adom-bridge CLI not found"}) };+ let payload = json!({"title": title, "body": body, "level": level, "scenario": "reminder", "durationLong": true, "buttons": [{"label": "Got it"}]}).to_string();+ let mut cmd = std::process::Command::new(&exe);+ if pref_bool(state_dir, "notifyBroadcast", false) { cmd.args(["--target", "all"]); }+ cmd.arg("notify_user").arg(&payload);+ #[cfg(windows)]+ { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); }+ match cmd.output() {+ Ok(o) => {+ let out = format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));+ let delivered = o.status.success() || out.contains("displayed") || out.contains("\"status\": \"ok\"") || out.contains("status':'ok");+ let nid = regex::Regex::new(r#""id"\s*:\s*"([0-9a-f]{8,})""#).ok().and_then(|re| re.captures(&out).map(|c| c[1].to_string()));+ json!({"delivered": delivered, "targets": "all", "id": nid, "error": if delivered { Value::Null } else { json!(out.chars().take(200).collect::<String>()) }})+ }+ Err(e) => json!({"delivered": false, "error": e.to_string()}),+ }+}++/// `fusion_notify_owner`: the last-resort toast to the human, wherever they are.+pub fn notify_owner(state_dir: &Path, args: &Value, caller: &fusion_core::ab::Caller) -> Value {+ let title = args["title"].as_str().filter(|s| !s.is_empty()).unwrap_or("Fusion bridge needs you").to_string();+ let body = args["body"].as_str().or(args["message"].as_str()).filter(|s| !s.is_empty()).unwrap_or("A human step is required to continue.").to_string();+ let level = args["level"].as_str().filter(|s| !s.is_empty()).unwrap_or("warning").to_string();+ let via_api = fusion_core::ab::Loopback::from_env().map(|ab| { use fusion_core::ab::AbClient; ab.call("notify_user", json!({"title": title, "body": body, "level": level, "target": "all"}), caller, 20) });+ match via_api {+ Some(Ok(res)) => {+ let summary_ok = res["summary"]["ok"].as_f64().unwrap_or(0.0) > 0.0;+ let ok = res["success"].as_bool() == Some(true) || res["status"].as_str() == Some("ok") || summary_ok;+ let reached = if res["targets"].is_null() { if ok { json!(["self"]) } else { json!([]) } } else { res["targets"].clone() };+ json!({"success": ok, "via": "ab_client:direct(all)", "targets": reached,+ "_hint": "Toast fanned out to ALL connected desktops (the user's machine included) via the in-process ab direct API. This is the LAST RESORT - only use after exhausting programmatic options (UIA background clicks, seat auto-resolve, warm-SSO sign-in), and only when the box is ACTUALLY ready for the user to act. Now WAIT and poll fusion_readiness for the state to clear; do not re-toast within a few minutes."})+ }+ _ => {+ let cli = cli_notify_all(state_dir, &title, &body, &level);+ if cli["delivered"].as_bool() == Some(true) {+ return json!({"success": true, "via": "cli:--target all", "targets": cli["targets"],+ "_hint": "Toast fanned out to ALL connected desktops (the user's main machine included) via the adom-bridge CLI, because the bridge's in-process ab API was unavailable (this VM). WAIT and poll fusion_readiness; do not re-toast within a few minutes. Only notify when the box is ACTUALLY ready for the user to act - do not toast for a step you can do yourself."});+ }+ json!({"success": false,+ "error": format!("ab in-process API unavailable AND the adom-bridge CLI fallback failed: {}", cli["error"].as_str().unwrap_or("None").chars().take(200).collect::<String>()),+ "notifyUser": {"title": title, "body": body, "level": level},+ "_hint": "Relay the notifyUser payload yourself: `adom-bridge --target all notify_user {title, body, level}` (or `--target <the user's host>`)."})+ }+ }+}++/// `fusion_close_window`: WM_CLOSE to a dialog, through ab's graceful close (never a kill).+pub fn close_window(args: &Value, caller: &fusion_core::ab::Caller) -> Value {+ let Some(hwnd) = args["hwnd"].as_i64().or_else(|| args["hwnd"].as_str().and_then(|s| s.trim().parse().ok())).filter(|h| *h != 0) else {+ return json!({"success": false, "error": "Missing required arg: hwnd"});+ };+ let Some(ab) = fusion_core::ab::Loopback::from_env() else {+ return json!({"success": false, "error": format!("HWND {hwnd} could not be closed: the ab loopback API is not available to this bridge."), "_hint": "The exe was not spawned by ab (ADOM_DIRECT_API_URL unset). Let ab spawn it, or use desktop_close_window {hwnd} directly."});+ };+ use fusion_core::ab::AbClient;+ match ab.call("desktop_close_window", json!({"hwnd": hwnd}), caller, 15) {+ Ok(r) => {+ if r["resolvedBy"].is_null() && r["ok"].as_bool() != Some(true) && r["success"].as_bool() != Some(true) {+ return json!({"success": false, "error": format!("HWND {hwnd} is not a valid window.")});+ }+ json!({"success": true, "hwnd": hwnd, "action": "WM_CLOSE sent", "output": format!("Sent WM_CLOSE to hwnd {hwnd}."), "closed": r["closed"]})+ }+ Err(fusion_core::ab::AbError::Refused { message, .. }) if message.to_lowercase().contains("not found") || message.to_lowercase().contains("no window") || message.to_lowercase().contains("invalid") => json!({"success": false, "error": format!("HWND {hwnd} is not a valid window.")}),+ Err(e) => json!({"success": false, "error": format!("HWND {hwnd} could not be closed: {e:?}")}),+ }+}++/// `fusion_screenshot_fusion`: the main window (or a dialog by hwnd), background PrintWindow via ab.+pub fn screenshot_fusion(args: &Value, caller: &fusion_core::ab::Caller) -> Value {+ let Some(ab) = fusion_core::ab::Loopback::from_env() else {+ return json!({"success": false, "error": "Screenshot unavailable: the ab loopback API is not available to this bridge.", "_hint": "The exe was not spawned by ab (ADOM_DIRECT_API_URL unset). Let ab spawn it, or use desktop_screenshot_window directly."});+ };+ use fusion_core::ab::AbClient;+ let asked = args["hwnd"].as_i64().or(args["dialogHwnd"].as_i64()).or_else(|| args["hwnd"].as_str().and_then(|s| s.trim().parse().ok())).filter(|h| *h != 0);+ let hwnd = match asked {+ Some(h) => h,+ None => {+ let facts = fusion_core::windows_facts::facts_from_ab(&ab, caller);+ match facts.main_hwnd {+ Some(h) => h,+ None => return json!({"success": false, "error": "Fusion 360 window not found (no window with 'Autodesk Fusion' in title).", "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry."}),+ }+ }+ };+ match ab.call("desktop_screenshot_window", json!({"hwnd": hwnd}), caller, 40) {+ Ok(r) => {+ let saved = r["fullPath"].as_str().or(r["safePath"].as_str()).map(|s| s.replace('\\', "/"));+ let Some(saved) = saved else { return json!({"success": false, "error": "ab captured nothing for that window (no fullPath in the answer)", "hwnd": hwnd}) };+ let bytes = r["fullBytesHost"].as_f64().or(r["fullBytes"].as_f64()).unwrap_or(0.0);+ let size_kb = (bytes / 1024.0 * 10.0).round() / 10.0;+ let (w, h) = (r["fullWidth"].clone(), r["fullHeight"].clone());+ json!({"success": true, "savedTo": saved, "sizeKB": size_kb, "hwnd": hwnd,+ "fullPath": saved, "safePath": r["safePath"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| saved.clone()), "fullPathHost": saved, "safePathHost": saved,+ "fullWidth": w, "fullHeight": h, "dimensions": {"width": w, "height": h}, "ownedPopupCount": r["ownedPopupCount"], "screenshots": r["screenshots"],+ "output": json!({"savedTo": saved, "sizeKB": size_kb, "fullPath": saved, "safePath": saved, "fullPathHost": saved, "safePathHost": saved, "fullWidth": w, "fullHeight": h}).to_string()})+ }+ Err(e) => json!({"success": false, "error": format!("Screenshot failed: {e:?}"), "hwnd": hwnd}),+ }+}++/// `classify_blocking_dialogs`: Fusion's Qt dialogs (from ab's window rows), classified by+/// title and, for a generic "Fusion" title, by the body text UIA exposes.+pub fn classify_blocking_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Vec<Value> {+ let mut args = json!({});+ if !fusion_pids.is_empty() { args["pids"] = json!(fusion_pids); }+ let rows = match ab.call("desktop_enum_windows", args, caller, 10) { Ok(v) => v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default(), Err(_) => return vec![] };+ let mut out = Vec::new();+ for dlg in fusion_core::dialogs::qt_dialog_rows(&rows) {+ let title = dlg["title"].as_str().unwrap_or("").to_string();+ let mut classified = fusion_core::dialogs::category_for_title(&title);+ let mut matched_on: Option<&str> = None;+ if classified.as_ref().map(|(c, _)| c == "generic_modal_read_screenshot" || c == "unknown").unwrap_or(true) {+ if let Some(h) = dlg["hwnd"].as_i64() {+ let body: String = ab.call("desktop_find_controls", json!({"hwnd": h}), caller, 10).ok()+ .map(|r| { let d = if r["data"].is_object() { r["data"].clone() } else { r }; let ctrls = d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default();+ ctrls.iter().filter_map(|c| c["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Vec<_>>().join(" ").chars().take(400).collect() })+ .unwrap_or_default();+ if !body.is_empty() { if let Some(bt) = fusion_core::dialogs::category_for_title(&body) { classified = Some(bt); matched_on = Some("bodyText"); } }+ }+ }+ let Some((category, resolution)) = classified else { continue };+ let mut e = json!({"hwnd": dlg["hwnd"], "title": title, "category": category, "resolution": resolution});+ if let Some(m) = matched_on { e["matchedOn"] = json!(m); }+ out.push(e);+ }+ out+}++/// `fusion_dismiss_blocking_dialogs`: answer every blocking dialog SAFELY in the background+/// (WM_CLOSE = No/Cancel, never Yes), reporting what each one was.+pub fn dismiss_blocking_dialogs(state_dir: &Path, args: &Value, caller: &fusion_core::ab::Caller, fusion_pids: &[u32], taskbar: &dyn Fn(Option<f64>)) -> Value {+ let Some(ab) = fusion_core::ab::Loopback::from_env() else {+ return json!({"success": false, "errorCode": "ab_loopback_unavailable", "error": "Cannot see Fusion's dialogs: the ab loopback API is not available to this bridge.", "_hint": "The exe was not spawned by ab (ADOM_DIRECT_API_URL unset). Let ab spawn it."});+ };+ use fusion_core::ab::AbClient;+ let mut classified = classify_blocking_dialogs(&ab, caller, fusion_pids);+ let want: Vec<String> = args["categories"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())).collect()).unwrap_or_default();+ if !want.is_empty() { classified.retain(|d| d["category"].as_str().map(|c| want.iter().any(|w| w == c)).unwrap_or(false)); }+ if classified.is_empty() {+ return json!({"success": true, "dismissed": [], "count": 0, "_hint": if want.is_empty() { "No blocking Fusion dialog is open." } else { "No blocking dialog in the requested categories." }});+ }+ let mut answered = Vec::new();+ let mut upload_active = false;+ for d in &classified {+ let cat = d["category"].as_str().unwrap_or("");+ let mut safe = "No/Cancel (WM_CLOSE, background)";+ if cat == "upload_in_progress" { upload_active = true; safe = "No - declined close, Hub upload left to drain (NEVER Yes)"; }+ let ok = d["hwnd"].as_i64().map(|h| ab.call("desktop_close_window", json!({"hwnd": h}), caller, 15).map(|r| r["ok"].as_bool().or(r["success"].as_bool()).unwrap_or(true)).unwrap_or(false)).unwrap_or(false);+ // what was closed is evidence (2026-09-03: a post-drill dismiss closed an 'unknown' window+ // and the add-in went silent; nothing had recorded which window it was)+ eprintln!("[fusion-bridge] dismiss_blocking_dialogs: {} {:?} category={cat} closed={ok}", d["hwnd"], d["title"].as_str().unwrap_or(""));+ crate::fusion_prefs::action_ledger(state_dir, "dialog_dismissed", json!({"title": d["title"], "category": cat, "hwnd": d["hwnd"], "closed": ok, "how": "WM_CLOSE"}));+ answered.push(json!({"title": d["title"], "category": cat, "answeredWith": safe, "closed": ok, "resolution": d["resolution"]}));+ }+ let mut hint = format!("Answered {} blocking dialog(s) in the background (No/Cancel - never Yes, never focus-stolen).", answered.len());+ if upload_active { hint.push_str(" One was a Hub-UPLOAD 'lose these changes?' prompt: it was declined (No), but the upload is STILL in flight - poll app.hasActiveJobs (fusion_state) until it drains before any close/saveAs, or you WILL lose the 3D package."); }+ taskbar(if upload_active { Some(1.0) } else { None });+ json!({"success": true, "count": answered.len(), "dismissed": answered, "uploadStillActive": upload_active, "_hint": hint})+}++fn screenshot_hwnd(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> Option<String> {+ use serde_json::json as j;+ ab.call("desktop_screenshot_window", j!({"hwnd": hwnd}), caller, 40).ok().and_then(|r| r["fullPath"].as_str().or(r["safePath"].as_str()).map(|s| s.replace('\\', "/")))+}++/// `_capture_dialog_array`: after a mutating op, every Fusion dialog with a screenshot, or None.+pub fn capture_dialog_array(caller: &fusion_core::ab::Caller, fusion_pids: &[u32], settle_ms: u64) -> Option<Value> {+ let ab = fusion_core::ab::Loopback::from_env()?;+ use fusion_core::ab::AbClient;+ if settle_ms > 0 { std::thread::sleep(std::time::Duration::from_millis(settle_ms)); }+ let mut args = json!({});+ if !fusion_pids.is_empty() { args["pids"] = json!(fusion_pids); }+ let rows = ab.call("desktop_enum_windows", args, caller, 10).ok().map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default())?;+ let raw = fusion_core::dialogs::qt_dialog_rows(&rows);+ if raw.is_empty() { return None; }+ // ab's rows do not carry IsWindowEnabled for the main window; Python treats an unreadable+ // state as enabled, which is the conservative branch (generic docked panels are suppressed)+ let main_enabled = true;+ let classified: std::collections::HashMap<i64, Value> = classify_blocking_dialogs(&ab, caller, fusion_pids).into_iter().filter_map(|c| c["hwnd"].as_i64().map(|h| (h, c))).collect();+ let mut out = Vec::new();+ for d in raw {+ let (w, h) = (d["rect"]["width"].as_i64().unwrap_or(0), d["rect"]["height"].as_i64().unwrap_or(0));+ if w < 80 || h < 50 { continue; }+ let hwnd = d["hwnd"].as_i64().unwrap_or(0);+ let title = d["title"].as_str().unwrap_or("").to_string();+ let c = classified.get(&hwnd);+ let cat = c.and_then(|c| c["category"].as_str());+ if main_enabled && matches!(cat, None | Some("generic_modal_read_screenshot") | Some("unknown")) { continue; }+ let mut entry = json!({"hwnd": hwnd, "title": title});+ if let Some(cat) = cat { entry["category"] = json!(cat); entry["resolution"] = c.map(|c| c["resolution"].clone()).unwrap_or(Value::Null); }+ if let Some(p) = screenshot_hwnd(&ab, caller, hwnd) { entry["screenshot"] = json!(p); }+ out.push(entry);+ }+ if out.is_empty() { return None; }+ let n = out.len();+ Some(json!({"dialogsDetected": n, "dialogs": out, "mainWindowEnabled": main_enabled,+ "_hint": format!("⚠️ {n} Fusion dialog(s)/owned popup(s) are up after this operation. STOP and ANALYZE before the next call: READ each dialogs[].screenshot and use its category/resolution. Do NOT blind-dismiss - some confirms LOSE work (clicking Yes on a Hub-upload 'are you sure you want to close?' destroys the uploaded packages). Take the work-preserving action (often: click No, then wait for the upload to drain). See the fusion-driving skill.")}))+}++/// `_apply_failure_dialogs`: an Error dialog after an op means the op FAILED, whatever the API said.+pub fn apply_failure_dialogs(mut result: Value, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Value {+ let Some(ab) = fusion_core::ab::Loopback::from_env() else { return result };+ use fusion_core::ab::AbClient;+ let failure_cats = fusion_core::dialogs::category_set("FAILURE");+ let failures: Vec<Value> = classify_blocking_dialogs(&ab, caller, fusion_pids).into_iter().filter(|d| d["category"].as_str().map(|c| failure_cats.iter().any(|f| f == c)).unwrap_or(false)).collect();+ if failures.is_empty() || !result.is_object() { return result; }+ let f = &failures[0];+ let shots: Vec<String> = failures.iter().filter_map(|d| d["hwnd"].as_i64().and_then(|h| screenshot_hwnd(&ab, caller, h))).collect();+ let prev = result["error"].as_str().unwrap_or("").to_string();+ result["success"] = json!(false);+ result["errorCode"] = json!("fusion_operation_error");+ result["error"] = json!(format!("Operation FAILED - Fusion '{}' dialog is up{}", f["title"].as_str().filter(|t| !t.is_empty()).unwrap_or("Error"), if prev.is_empty() { String::new() } else { format!(" (prior: {prev})") }));+ result["_hint"] = json!(f["resolution"].as_str().filter(|r| !r.is_empty()).unwrap_or("Fusion shows an Error dialog; the operation failed. Read the screenshot."));+ if !result["data"].is_object() { result["data"] = json!({}); }+ result["data"]["errorDialog"] = json!({"title": f["title"], "category": f["category"], "resolution": f["resolution"], "screenshots": shots});+ for d in &failures { if let Some(h) = d["hwnd"].as_i64() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), caller, 15); } }+ result+}++/// `_merge_dialog_array`: the dialog array onto a mutating verb's result, then the failure guard.+pub fn merge_dialog_array(mut result: Value, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Value {+ if !result.is_object() { return result; }+ let Some(arr) = capture_dialog_array(caller, fusion_pids, 600) else { return result };+ if !result["data"].is_object() { result["data"] = json!({}); }+ result["data"]["dialogArray"] = arr.clone();+ let existing = result["_hint"].as_str().map(str::to_string);+ result["_hint"] = json!(format!("{}{}", arr["_hint"].as_str().unwrap_or(""), existing.map(|e| format!(" | {e}")).unwrap_or_default()));+ apply_failure_dialogs(result, caller, fusion_pids)+}
rust/bridge/src/testproject.rsadded+186@@ -0,0 +1,186 @@+//! `fusion_make_test_project`, native: a minimal .sch/.brd pair generated from NOTHING but the+//! part's .lbr (no cloud, no sign-in), opened in Fusion through the dialog-free+//! Document.newDesignFromLocal path. The generated XML is pinned to a Python-generated golden.++use serde_json::{json, Value};+use std::time::Duration;++use crate::addin::Ctx;++fn py_float(v: f64) -> String { let s = format!("{v}"); if s.contains('.') || s.contains('e') { s } else { format!("{s}.0") } }+fn round_to(v: f64, d: i32) -> f64 { let m = 10f64.powi(d); (v * m).round() / m }++pub struct Extract { pub deviceset: String, pub symbol: String, pub package: String, pub connects: Vec<(String, String)>, pub sym_name: String, pub gate_name: String, pub pkg_name: String, pub device: String, pub prefix: String, pub layers: String }++/// `_mtp_extract`: the pieces the generator needs out of the .lbr.+pub fn mtp_extract(lbr: &str, deviceset: &str, device: Option<&str>) -> Result<Extract, String> {+ let block_of = |tag: &str, name: &str| -> Option<String> { regex::Regex::new(&format!(r#"(?s)<{tag} name="{}"[\s>].*?</{tag}>"#, regex::escape(name))).unwrap().find(lbr).map(|m| m.as_str().to_string()) };+ let Some(mut ds) = block_of("deviceset", deviceset) else { return Err(format!("deviceset {} not found in the library", fusion_lbr::py_repr_str(deviceset))) };+ let gate = regex::Regex::new(r#"<gate name="([^"]+)" symbol="([^"]+)""#).unwrap().captures(&ds).map(|c| (c[1].to_string(), c[2].to_string()));+ let prefix = regex::Regex::new(r#"<deviceset name="[^"]+"[^>]*prefix="([^"]+)""#).unwrap().captures(&ds).map(|c| c[1].to_string());+ let dev = match device {+ Some(d) => match regex::Regex::new(&format!(r#"<device name="{}" package="([^"]+)""#, regex::escape(d))).unwrap().captures(&ds) { Some(c) => Some(c[1].to_string()), None => return Err(format!("deviceset {} has no device named {}", fusion_lbr::py_repr_str(deviceset), fusion_lbr::py_repr_str(d))) },+ None => regex::Regex::new(r#"<device name="[^"]*" package="([^"]+)""#).unwrap().captures(&ds).map(|c| c[1].to_string()),+ };+ let (Some((gate_name, sym_name)), Some(pkg_name)) = (gate, dev) else { return Err(format!("deviceset {} has no gate/package device mapping", fusion_lbr::py_repr_str(deviceset))) };+ let connects: Vec<(String, String)> = regex::Regex::new(r#"<connect gate="[^"]+" pin="([^"]+)" pad="([^"]+)""#).unwrap().captures_iter(&ds).map(|c| (c[1].to_string(), c[2].to_string())).collect();+ ds = regex::Regex::new(r"(?s)<package3dinstances>.*?</package3dinstances>").unwrap().replace_all(&ds, "").to_string();+ let (sym, pkg) = (block_of("symbol", &sym_name), block_of("package", &pkg_name));+ let (Some(symbol), Some(package)) = (sym, pkg) else { return Err(format!("library is missing the symbol/package/connects for {}", fusion_lbr::py_repr_str(deviceset))) };+ if connects.is_empty() { return Err(format!("library is missing the symbol/package/connects for {}", fusion_lbr::py_repr_str(deviceset))); }+ let layers = regex::Regex::new(r"(?s)<layers>.*?</layers>").unwrap().find(lbr).map(|m| m.as_str().to_string()).unwrap_or_default();+ Ok(Extract { deviceset: ds, symbol, package, connects, sym_name, gate_name, pkg_name, device: device.unwrap_or("").to_string(), prefix: prefix.unwrap_or_else(|| "U".into()), layers })+}++const R_SYM: &str = r#"<symbol name="ADOMR"><wire x1="-2.54" y1="1.016" x2="2.54" y2="1.016" width="0.254" layer="94"/><wire x1="2.54" y1="1.016" x2="2.54" y2="-1.016" width="0.254" layer="94"/><wire x1="2.54" y1="-1.016" x2="-2.54" y2="-1.016" width="0.254" layer="94"/><wire x1="-2.54" y1="-1.016" x2="-2.54" y2="1.016" width="0.254" layer="94"/><pin name="1" x="-5.08" y="0" visible="off" length="short"/><pin name="2" x="5.08" y="0" visible="off" length="short" rot="R180"/><text x="-2.54" y="1.524" size="1.778" layer="95">>NAME</text><text x="-2.54" y="-3.302" size="1.778" layer="96">>VALUE</text></symbol>"#;+const R_PKG: &str = r#"<package name="ADOMR0603"><description>Resistor 0603</description><smd name="1" x="-0.85" y="0" dx="1" dy="1" layer="1"/><smd name="2" x="0.85" y="0" dx="1" dy="1" layer="1"/><wire x1="-0.4" y1="0.5" x2="0.4" y2="0.5" width="0.127" layer="21"/><wire x1="-0.4" y1="-0.5" x2="0.4" y2="-0.5" width="0.127" layer="21"/><text x="-1" y="0.8" size="0.8" layer="25">>NAME</text></package>"#;+const R_DS: &str = r#"<deviceset name="ADOMR" prefix="R"><description>generated test resistor</description><gates><gate name="G$1" symbol="ADOMR" x="0" y="0"/></gates><devices><device name="" package="ADOMR0603"><connects><connect gate="G$1" pin="1" pad="1"/><connect gate="G$1" pin="2" pad="2"/></connects><technologies><technology name=""/></technologies></device></devices></deviceset>"#;+const SCH_LAYERS: &str = r#"<layers><layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/><layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/><layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/><layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/><layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/><layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/><layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/><layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/></layers>"#;+const HEAD: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n<eagle version=\"9.6.2\">\n<drawing>\n<settings><setting alwaysvectorfont=\"no\"/></settings><grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>";++/// `_pkg_extent_mm`: everything a package draws, on any layer.+pub fn pkg_extent_mm(p: &str) -> (f64, f64, f64, f64) {+ let (mut xs, mut ys): (Vec<f64>, Vec<f64>) = (vec![], vec![]);+ let f = |s: &str| s.parse::<f64>().unwrap_or(0.0);+ for c in regex::Regex::new(r#"<smd [^>]*x="([-0-9.]+)" y="([-0-9.]+)" dx="([-0-9.]+)" dy="([-0-9.]+)""#).unwrap().captures_iter(p) { let (x, y, dx, dy) = (f(&c[1]), f(&c[2]), f(&c[3]), f(&c[4])); let r = dx.max(dy) / 2.0; xs.extend([x - r, x + r]); ys.extend([y - r, y + r]); }+ for c in regex::Regex::new(r#"<pad [^>]*x="([-0-9.]+)" y="([-0-9.]+)"[^>]*?(?:diameter="([-0-9.]+)")?"#).unwrap().captures_iter(p) { let (x, y) = (f(&c[1]), f(&c[2])); let r = c.get(3).map(|m| f(m.as_str())).unwrap_or(1.0) / 2.0; xs.extend([x - r, x + r]); ys.extend([y - r, y + r]); }+ for c in regex::Regex::new(r#"<wire x1="([-0-9.]+)" y1="([-0-9.]+)" x2="([-0-9.]+)" y2="([-0-9.]+)" width="([-0-9.]+)""#).unwrap().captures_iter(p) { let (x1, y1, x2, y2, w) = (f(&c[1]), f(&c[2]), f(&c[3]), f(&c[4]), f(&c[5])); xs.extend([x1 - w, x1 + w, x2 - w, x2 + w]); ys.extend([y1 - w, y1 + w, y2 - w, y2 + w]); }+ for c in regex::Regex::new(r#"<circle x="([-0-9.]+)" y="([-0-9.]+)" radius="([-0-9.]+)" width="([-0-9.]+)""#).unwrap().captures_iter(p) { let (x, y, r0, w) = (f(&c[1]), f(&c[2]), f(&c[3]), f(&c[4])); let r = r0 + w; xs.extend([x - r, x + r]); ys.extend([y - r, y + r]); }+ for c in regex::Regex::new(r#"<rectangle x1="([-0-9.]+)" y1="([-0-9.]+)" x2="([-0-9.]+)" y2="([-0-9.]+)""#).unwrap().captures_iter(p) { xs.extend([f(&c[1]), f(&c[3])]); ys.extend([f(&c[2]), f(&c[4])]); }+ for c in regex::Regex::new(r#"<vertex x="([-0-9.]+)" y="([-0-9.]+)""#).unwrap().captures_iter(p) { xs.push(f(&c[1])); ys.push(f(&c[2])); }+ if xs.is_empty() || ys.is_empty() { return (-3.0, -3.0, 3.0, 3.0); }+ let mn = |v: &Vec<f64>| v.iter().cloned().fold(f64::INFINITY, f64::min); let mx = |v: &Vec<f64>| v.iter().cloned().fold(f64::NEG_INFINITY, f64::max);+ (mn(&xs), mn(&ys), mx(&xs), mx(&ys))+}++pub struct Geo { pub w: f64, pub h: f64, pub px: f64, pub py: f64, pub rx: f64, pub ry: f64, pub wire: (f64, f64, f64, f64) }+pub fn board_geometry(package_xml: &str, margin: f64, with_resistor: bool) -> Geo {+ let (x0, y0, x1, y1) = pkg_extent_mm(package_xml);+ let (pw, ph) = (x1 - x0, y1 - y0);+ let bay = if with_resistor { 7.0 } else { 0.0 };+ let w = round_to(bay + pw + 2.0 * margin, 2);+ let h = round_to((ph + 2.0 * margin).max(if with_resistor { 9.0 } else { 0.0 }), 2);+ let px = round_to(bay + margin - x0, 3);+ let py = round_to(h / 2.0 - (y0 + y1) / 2.0, 3);+ let (rx, ry) = (3.5, round_to(h / 2.0, 3));+ Geo { w, h, px, py, rx, ry, wire: (rx + 0.85, ry, round_to(px + x0 - 0.3, 3), ry) }+}+fn outline(w: f64, h: f64) -> String { let (w, h) = (py_float(w), py_float(h)); format!(r#"<wire x1="0" y1="0" x2="{w}" y2="0" width="0.2" layer="20"/><wire x1="{w}" y1="0" x2="{w}" y2="{h}" width="0.2" layer="20"/><wire x1="{w}" y1="{h}" x2="0" y2="{h}" width="0.2" layer="20"/><wire x1="0" y1="{h}" x2="0" y2="0" width="0.2" layer="20"/>"#) }++fn p3d_block(part: &str, urn: &str, pkg: &str) -> String { if urn.is_empty() { String::new() } else { format!(r#"<packages3d><package3d name="{part}" urn="" wip_urn="{urn}" locally_modified="yes" type="model"><packageinstances><packageinstance name="{pkg}"/></packageinstances></package3d></packages3d>"#) } }++/// `_mtp_generate`: the .sch/.brd pair.+pub fn mtp_generate(lib_name: &str, ex: &Extract, part_name: &str, urn: &str, rex: Option<&Extract>, r_urn: &str, solo: bool) -> (String, String) {+ let pins: Vec<&str> = ex.connects.iter().map(|(p, _)| p.as_str()).collect();+ let pads: std::collections::HashMap<&str, &str> = ex.connects.iter().map(|(p, d)| (p.as_str(), d.as_str())).collect();+ let (p1, p2) = (pins[0], pins.get(1).cloned().unwrap_or(pins[0]));+ let lib = format!(r#"<library name="{lib_name}"><packages>{}</packages>{}<symbols>{}</symbols><devicesets>{}</devicesets></library>"#, ex.package, p3d_block(part_name, urn, &ex.pkg_name), ex.symbol, ex.deviceset);+ let (r_lib_name, r_ds, r_gate, r_pin1, r_pin2, r_pads, rlib, r_part, r_pkg_name): (&str, &str, String, String, String, std::collections::HashMap<String, String>, String, String, String) = match rex {+ Some(r) => { let rp: Vec<&str> = r.connects.iter().map(|(p, _)| p.as_str()).collect(); (+ "adom-resistor", "0603WAF3300T5E", r.gate_name.clone(), rp[0].to_string(), rp.get(1).cloned().unwrap_or(rp[0]).to_string(), r.connects.iter().map(|(p, d)| (p.clone(), d.clone())).collect(),+ format!(r#"<library name="adom-resistor"><packages>{}</packages>{}<symbols>{}</symbols><devicesets>{}</devicesets></library>"#, r.package, p3d_block("0603WAF3300T5E", r_urn, &r.pkg_name), r.symbol, r.deviceset),+ r#"<part name="R1" library="adom-resistor" deviceset="0603WAF3300T5E" device="" value="330"/>"#.to_string(), r.pkg_name.clone()) }+ None => ("adomdemo", "ADOMR", "G$1".to_string(), "1".to_string(), "2".to_string(), [("1", "1"), ("2", "2")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(),+ format!(r#"<library name="adomdemo"><packages>{R_PKG}</packages><symbols>{R_SYM}</symbols><devicesets>{R_DS}</devicesets></library>"#), r#"<part name="R1" library="adomdemo" deviceset="ADOMR" device="" value="330"/>"#.to_string(), "ADOMR0603".to_string()),+ };+ let _ = r_ds;+ let g = &ex.gate_name;+ let pfx = { let p = ex.prefix.trim(); if p.is_empty() { "U" } else { p } };+ let part_ref = if pfx.to_uppercase() == "R" && !solo { format!("{pfx}2") } else { format!("{pfx}1") };+ let geo = board_geometry(&ex.package, 3.0, !solo);+ if solo {+ let sch = format!("{HEAD}{SCH_LAYERS}<schematic xreflabel=\"%F%N/%S.%C%R\" xrefpart=\"/%S.%C%R\"><libraries>{lib}</libraries><attributes/><variantdefs/><classes><class number=\"0\" name=\"default\" width=\"0\" drill=\"0\"/></classes><parts><part name=\"{part_ref}\" library=\"{lib_name}\" deviceset=\"{part_name}\" device=\"{}\"/></parts><sheets><sheet><plain><text x=\"0\" y=\"25.4\" size=\"2.54\" layer=\"97\">{part_name} - Adom component demo</text></plain><instances><instance part=\"{part_ref}\" gate=\"{}\" x=\"25.4\" y=\"12.7\"/></instances><busses/><nets/></sheet></sheets></schematic></drawing></eagle>\n", ex.device, ex.gate_name);+ let brd = format!("{HEAD}{}<board><plain>{}</plain><libraries><library name=\"{lib_name}\"><packages>{}</packages>{}</library></libraries><attributes/><variantdefs/><classes><class number=\"0\" name=\"default\" width=\"0\" drill=\"0\"/></classes><designrules name=\"default\"/><autorouter/><elements><element name=\"{part_ref}\" library=\"{lib_name}\" package=\"{}\" value=\"\" x=\"{}\" y=\"{}\"{}/></elements><signals/></board></drawing></eagle>\n", ex.layers, outline(geo.w, geo.h), ex.package, p3d_block(part_name, urn, &ex.pkg_name), ex.pkg_name, py_float(geo.px), py_float(geo.py), if urn.is_empty() { String::new() } else { format!(r#" package3d_urn="{urn}""#) });+ return (sch, brd);+ }+ let sch = format!("{HEAD}{SCH_LAYERS}<schematic xreflabel=\"%F%N/%S.%C%R\" xrefpart=\"/%S.%C%R\"><libraries>{lib}{rlib}</libraries><attributes/><variantdefs/><classes><class number=\"0\" name=\"default\" width=\"0\" drill=\"0\"/></classes><parts><part name=\"{part_ref}\" library=\"{lib_name}\" deviceset=\"{part_name}\" device=\"{}\"/>{r_part}</parts><sheets><sheet><plain><text x=\"0\" y=\"30.48\" size=\"2.54\" layer=\"97\">Adom generated test circuit: {part_name}</text></plain><instances><instance part=\"R1\" gate=\"{r_gate}\" x=\"12.7\" y=\"12.7\"/><instance part=\"{part_ref}\" gate=\"{g}\" x=\"38.1\" y=\"12.7\"/></instances><busses/><nets><net name=\"N1\" class=\"0\"><segment><wire x1=\"17.78\" y1=\"12.7\" x2=\"25.4\" y2=\"12.7\" width=\"0.1524\" layer=\"91\"/><pinref part=\"R1\" gate=\"{r_gate}\" pin=\"{r_pin2}\"/><pinref part=\"{part_ref}\" gate=\"{g}\" pin=\"{p1}\"/></segment></net><net name=\"VCC\" class=\"0\"><segment><wire x1=\"7.62\" y1=\"12.7\" x2=\"2.54\" y2=\"12.7\" width=\"0.1524\" layer=\"91\"/><pinref part=\"R1\" gate=\"{r_gate}\" pin=\"{r_pin1}\"/></segment></net><net name=\"GND\" class=\"0\"><segment><pinref part=\"{part_ref}\" gate=\"{g}\" pin=\"{p2}\"/></segment></net></nets></sheet></sheets></schematic></drawing></eagle>\n", ex.device);+ let lib_no_sym = lib.replace(&format!("<symbols>{}</symbols><devicesets>{}</devicesets>", ex.symbol, ex.deviceset), "");+ let rlib_no_sym = match rex { Some(r) => rlib.replace(&format!("<symbols>{}</symbols><devicesets>{}</devicesets>", r.symbol, r.deviceset), ""), None => rlib.replace(&format!("<symbols>{R_SYM}</symbols><devicesets>{R_DS}</devicesets>"), "") };+ let (wx1, wy1, wx2, wy2) = geo.wire;+ let brd = format!("{HEAD}{}<board><plain>{}</plain><libraries>{lib_no_sym}{rlib_no_sym}</libraries><attributes/><variantdefs/><classes><class number=\"0\" name=\"default\" width=\"0\" drill=\"0\"/></classes><designrules name=\"default\"/><autorouter/><elements><element name=\"R1\" library=\"{r_lib_name}\" package=\"{r_pkg_name}\" value=\"330\" x=\"{}\" y=\"{}\"{}/><element name=\"{part_ref}\" library=\"{lib_name}\" package=\"{}\" value=\"\" x=\"{}\" y=\"{}\"{}/></elements><signals><signal name=\"N1\"><contactref element=\"R1\" pad=\"{}\"/><contactref element=\"{part_ref}\" pad=\"{}\"/><wire x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" width=\"0.3\" layer=\"1\"/></signal><signal name=\"VCC\"><contactref element=\"R1\" pad=\"{}\"/></signal><signal name=\"GND\"><contactref element=\"{part_ref}\" pad=\"{}\"/></signal></signals></board></drawing></eagle>\n",+ ex.layers, outline(geo.w, geo.h), py_float(geo.rx), py_float(geo.ry), if rex.is_some() && !r_urn.is_empty() { format!(r#" package3d_urn="{r_urn}""#) } else { String::new() }, ex.pkg_name, py_float(geo.px), py_float(geo.py), if urn.is_empty() { String::new() } else { format!(r#" package3d_urn="{urn}""#) },+ r_pads.get(&r_pin2).cloned().unwrap_or_default(), pads.get(p1).cloned().unwrap_or(""), py_float(wx1), py_float(wy1), py_float(wx2), py_float(wy2), r_pads.get(&r_pin1).cloned().unwrap_or_default(), pads.get(p2).cloned().unwrap_or(""));+ (sch, brd)+}++fn resistor_extract() -> Option<Extract> {+ let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).unwrap_or_default();+ let p = dir.join("resources").join("samples").join("0603WAF3300T5E.lbr");+ let txt = std::fs::read(&p).ok().map(|b| String::from_utf8_lossy(&b).to_string())?;+ mtp_extract(&txt, "0603WAF3300T5E", None).ok()+}++/// `fusion_make_test_project`.+/// `_test_project_activate`: activate the generated test project's sch or brd DOCUMENT+/// (a newDesignFromLocal re-open is the activation). True once it is confirmed active.+pub fn test_project_activate(ctx: &Ctx, kind: &str, pass: &dyn Fn(&str, Value) -> Value) -> bool {+ let Some(tp) = crate::small_verbs::get_pref(&ctx.state_dir, "testProject") else { return false };+ let Some(path) = tp[kind].as_str().filter(|p| !p.is_empty()) else { return false };+ let base = tp["docBase"].as_str().unwrap_or("").to_lowercase();+ let _ = pass("execute_text_command", json!({"command": format!("Document.newDesignFromLocal {}", ctx.host_paths_short(path))}));+ for _ in 0..6 {+ std::thread::sleep(std::time::Duration::from_secs(2));+ if !base.is_empty() && pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_lowercase().contains(&base) { return true; }+ }+ false+}+/// The generated test project on file, if any (`testProject` in prefs.json).+pub fn test_project(state_dir: &std::path::Path) -> Option<Value> { crate::small_verbs::get_pref(state_dir, "testProject").filter(|v| v["brd"].is_string()) }++pub fn make_test_project(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let lbr_path = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ if lbr_path.is_empty() { return json!({"success": false, "error": "libraryPath (.lbr, Windows path) required.", "_hint": "Stage the component page's .lbr onto the box first (send_files)."}); }+ let lbr_text = match std::fs::read(&lbr_path) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "error": format!("could not read {lbr_path}: {e}")}) };+ let deviceset = match args["deviceset"].as_str().filter(|s| !s.is_empty()) { Some(d) => d.to_string(), None => match regex::Regex::new(r#"<deviceset name="([^"]+)""#).unwrap().captures(&lbr_text) { Some(c) => c[1].to_string(), None => return json!({"success": false, "error": "no deviceset found in the library"}) } };+ let ex = match mtp_extract(&lbr_text, &deviceset, args["device"].as_str()) { Ok(e) => e, Err(e) => return json!({"success": false, "error": e}) };+ let lib_name: String = regex::Regex::new(r"[^A-Za-z0-9_-]").unwrap().replace_all(lbr_path.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s).unwrap_or(""), "_").to_string();+ let mut urn = String::new();+ let pk = regex::escape(&ex.pkg_name);+ for c in regex::Regex::new(r#"(?s)<package3d [^>]*wip_urn="([^"]+)"[^>]*>(.*?)</package3d>"#).unwrap().captures_iter(&lbr_text) { if regex::Regex::new(&format!(r#"<packageinstance name="{pk}"/>"#)).unwrap().is_match(&c[2]) { urn = c[1].to_string(); break; } }+ if urn.is_empty() { if let Some(c) = regex::Regex::new(r#"wip_urn="([^"]+)""#).unwrap().captures(&lbr_text) { urn = c[1].to_string(); } }+ let solo = args["withResistor"].as_bool() != Some(true);+ let rex = if solo { None } else { resistor_extract() };+ let r_urn = if rex.is_some() { std::fs::read_to_string(ctx.state_dir.join("r3d-urn.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v["0603WAF3300T5E"].as_str().map(str::to_string)).unwrap_or_default() } else { String::new() };+ let (sch, brd) = mtp_generate(&lib_name, &ex, &deviceset, &urn, rex.as_ref(), &r_urn, solo);+ let d = std::env::temp_dir().join("adom-fusion-wrap"); let _ = std::fs::create_dir_all(&d);+ let base: String = format!("ADOM-TEST-{}", regex::Regex::new(r"[^A-Za-z0-9_-]").unwrap().replace_all(&deviceset, "_").chars().take(40).collect::<String>());+ let sch_path = d.join(format!("{base}.sch")).to_string_lossy().replace('\\', "/");+ let brd_path = d.join(format!("{base}.brd")).to_string_lossy().replace('\\', "/");+ let _ = std::fs::write(&sch_path, &sch); let _ = std::fs::write(&brd_path, &brd);+ let mut steps: Vec<String> = Vec::new();+ for (label, fpath) in [("board", &brd_path), ("schematic", &sch_path)] {+ let r = pass("execute_text_command", json!({"command": format!("Document.newDesignFromLocal {}", ctx.host_paths_short(fpath))}));+ let mut okd = r["success"].as_bool() == Some(true);+ if !okd {+ let expected = fpath.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s.to_lowercase()).unwrap_or_default();+ for _ in 0..10 { std::thread::sleep(Duration::from_secs(3)); if pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_lowercase().contains(&expected) { okd = true; break; } }+ }+ steps.push(format!("{label} open: {}", if okd { "ok".to_string() } else { format!("FAILED: {}", r["error"].as_str().unwrap_or("None").chars().take(80).collect::<String>()) }));+ if !okd { return json!({"success": false, "schPath": sch_path, "brdPath": brd_path, "error": format!("{label} open failed: {}", r["error"].as_str().unwrap_or("None").chars().take(150).collect::<String>()), "data": {"steps": steps}}); }+ std::thread::sleep(Duration::from_secs(2));+ }+ crate::small_verbs::set_pref(&ctx.state_dir, "testProject", json!({"sch": sch_path, "brd": brd_path, "docBase": base.to_lowercase()}));+ crate::chains::note_bridge_doc_pub(&ctx.state_dir, &base);+ json!({"success": true, "schPath": sch_path, "brdPath": brd_path, "deviceset": deviceset, "steps": steps,+ "circuit": if solo { format!("JUST {deviceset} on a small board (solo demo layout)") } else { format!("R1 (330) in series with {deviceset}; VCC/GND stubs") },+ "_hint": "Test project opened as TWO documents (board instantiated with placed parts, schematic as its drawn sheet) via the dialog-free Document.newDesignFromLocal path. The BOARD document is active: fusion_show_2d_board / fusion_show_3d_board work now; for the schematic view activate its document (it opened last... the SCHEMATIC document is actually active) - board legs may need the board doc re-activated via Document.newDesignFromLocal or open_board. Generated from NOTHING but the component's .lbr - no cloud, no sign-in."})+}++#[cfg(test)]+mod tests {+ #[test]+ fn test_project_xml_matches_python_golden() {+ let g: serde_json::Value = serde_json::from_str(include_str!("../tests/golden/mtp.json")).unwrap();+ let lbr = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../lbr/tests/golden/Demo_Board.bound5.lbrx")).unwrap();+ let rtxt = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../resources/samples/0603WAF3300T5E.lbr")).unwrap();+ let rex = super::mtp_extract(&rtxt, "0603WAF3300T5E", None).unwrap();+ for c in g["cases"].as_array().unwrap() {+ let ds = c["deviceset"].as_str().unwrap(); let solo = c["solo"].as_bool().unwrap();+ let ex = super::mtp_extract(&lbr, ds, None).unwrap();+ let (sch, brd) = super::mtp_generate("Demo_Board", &ex, ds, c["urn"].as_str().unwrap(), if solo { None } else { Some(&rex) }, c["rUrn"].as_str().unwrap(), solo);+ for (got, want, what) in [(sch, c["sch"].as_str().unwrap().to_string(), "sch"), (brd, c["brd"].as_str().unwrap().to_string(), "brd")] {+ if got != want { let i = got.chars().zip(want.chars()).position(|(a, b)| a != b).unwrap_or(got.len().min(want.len())); panic!("{ds} solo={solo} {what} drift at {i}: got {:?} want {:?}", &got[i.saturating_sub(60)..(i + 80).min(got.len())], &want[i.saturating_sub(60)..(i + 80).min(want.len())]); }+ }+ }+ }+}
rust/bridge/src/timings.rsadded+127@@ -0,0 +1,127 @@+//! `timings.py` for the exe: the rolling window (`<state>/timings.json`, ten samples per+//! `phase|kind`), the append-only run log (`verb-times.jsonl`), and the verb estimate. Same+//! file, same keys, same rounding, so a run measured by either shell teaches the other.++use serde_json::{json, Value};+use std::path::Path;++const KEEP: usize = 10;++fn now_i() -> i64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) }++pub fn load(state_dir: &Path) -> Value {+ std::fs::read_to_string(state_dir.join("timings.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).filter(|v| v.is_object()).unwrap_or(json!({}))+}++/// `timings.record(phase, seconds, start_kind)`.+pub fn record(state_dir: &Path, phase: &str, seconds: f64, start_kind: Option<&str>) {+ if !(0.0..=7200.0).contains(&seconds) { return; }+ let mut d = load(state_dir);+ let k = format!("{phase}|{}", start_kind.unwrap_or("any"));+ let mut samples: Vec<f64> = d[&k]["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();+ samples.push((seconds * 10.0).round() / 10.0);+ let keep: Vec<f64> = samples.iter().rev().take(KEEP).rev().cloned().collect();+ d[&k] = json!({"samples": keep, "lastAt": now_i()});+ let _ = std::fs::create_dir_all(state_dir);+ let p = state_dir.join("timings.json");+ let tmp = state_dir.join("timings.json.tmp");+ if std::fs::write(&tmp, serde_json::to_string_pretty(&d).unwrap_or_default()).is_ok() { let _ = std::fs::rename(&tmp, &p); }+}++/// `timings.log_run`: the rolling window AND one line in the historical log.+pub fn log_run(state_dir: &Path, verb: &str, seconds: f64, ok: bool) {+ record(state_dir, &format!("verb:{verb}"), seconds, None);+ let p = state_dir.join("verb-times.jsonl");+ if let Ok(m) = std::fs::metadata(&p) { if m.len() > 4 * 1024 * 1024 { let _ = std::fs::rename(&p, state_dir.join("verb-times.jsonl.1")); } }+ use std::io::Write;+ let line = json!({"at": now_i(), "verb": verb, "seconds": (seconds * 100.0).round() / 100.0, "ok": ok, "note": ""}).to_string();+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) { let _ = writeln!(f, "{line}"); }+}++/// `timings.verb_estimate`: (seconds, confidence) from the rolling window.+pub fn verb_estimate(state_dir: &Path, verb: &str) -> (f64, &'static str) {+ let st = fusion_core::readiness::timings_stats(&load(state_dir), &format!("verb:{verb}"), "any");+ let (count, p50) = (st["count"].as_u64().unwrap_or(0), st["p50"].as_f64().unwrap_or(0.0));+ if count >= 3 && p50 > 0.0 { (p50, "measured") } else if count >= 1 && p50 > 0.0 { (p50, "typical") } else { (0.0, "unknown") }+}++/// The activity indicator on Fusion's taskbar button through ab's `desktop_taskbar`: a bar+/// paced by the verb's own history (0 to 95 across the estimate, then hold), or the flash.+pub struct Indicator { stop: std::sync::Arc<std::sync::atomic::AtomicBool>, caller: fusion_core::ab::Caller }++fn fusion_hwnd(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller) -> Option<i64> {+ fusion_core::windows_facts::facts_from_ab(ab, caller).main_hwnd+}++pub fn taskbar_progress(caller: &fusion_core::ab::Caller, state: &str, value: Option<f64>) -> bool {+ let Some(ab) = fusion_core::ab::Loopback::from_env() else { return false };+ use fusion_core::ab::AbClient;+ let Some(h) = fusion_hwnd(&ab, caller) else { return false };+ let mut prog = json!({"state": state});+ if let Some(v) = value { prog["value"] = json!(v.clamp(0.0, 100.0) as i64); }+ ab.call("desktop_taskbar", json!({"hwnd": h, "progress": prog}), caller, 15).is_ok()+}++/// `chain_taskbar_progress`: pct None clears the bar and the flash.+pub fn taskbar_pct(caller: &fusion_core::ab::Caller, pct: Option<f64>) {+ match pct {+ Some(p) => { taskbar_progress(caller, "normal", Some(p)); }+ None => {+ taskbar_progress(caller, "none", None);+ if let Some(ab) = fusion_core::ab::Loopback::from_env() { use fusion_core::ab::AbClient; if let Some(h) = fusion_hwnd(&ab, caller) { let _ = ab.call("desktop_taskbar", json!({"hwnd": h, "flash": "stop"}), caller, 15); } }+ }+ }+}++impl Indicator {+ /// `start_activity_progress` + the flash, per the sticky `activityIndicator` pref.+ pub fn start(state_dir: &Path, verb: &str, caller: &fusion_core::ab::Caller) -> Indicator {+ let mode = crate::small_verbs::get_pref(state_dir, "activityIndicator").and_then(|v| v.as_str().map(|s| s.to_lowercase())).filter(|m| ["flash", "progress", "both", "none"].contains(&m.as_str())).unwrap_or_else(|| "progress".into());+ let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+ let (est, conf) = verb_estimate(state_dir, verb);+ if mode == "progress" || mode == "both" {+ let (stop2, caller2) = (stop.clone(), caller.clone());+ std::thread::spawn(move || {+ let started = std::time::Instant::now();+ if est <= 0.0 || conf == "unknown" {+ taskbar_progress(&caller2, "indeterminate", None);+ while !stop2.load(std::sync::atomic::Ordering::Relaxed) { std::thread::sleep(std::time::Duration::from_secs(1)); }+ return;+ }+ let mut fails = 0;+ loop {+ std::thread::sleep(std::time::Duration::from_millis(700));+ if stop2.load(std::sync::atomic::Ordering::Relaxed) { break; }+ let pct = (95.0 * started.elapsed().as_secs_f64() / est).min(95.0);+ if taskbar_progress(&caller2, "normal", Some(pct)) { fails = 0; } else { fails += 1; if fails > 120 { break; } }+ }+ });+ }+ if mode == "flash" || mode == "both" {+ if let Some(ab) = fusion_core::ab::Loopback::from_env() { use fusion_core::ab::AbClient; let _ = ab.call("desktop_taskbar", json!({"titleContains": "Autodesk Fusion", "flash": "until_focused"}), caller, 10); }+ }+ Indicator { stop, caller: caller.clone() }+ }+ /// `end_activity_progress`: a red flash on failure, then clear the bar and the flash.+ pub fn stop(self, failed: bool) {+ self.stop.store(true, std::sync::atomic::Ordering::Relaxed);+ if failed { taskbar_progress(&self.caller, "error", Some(100.0)); std::thread::sleep(std::time::Duration::from_millis(1200)); }+ taskbar_pct(&self.caller, None);+ }+}++#[cfg(test)]+mod tests {+ #[test]+ fn record_rolls_ten_and_rounds() {+ let d = std::env::temp_dir().join(format!("fb-timings-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ for i in 0..12 { super::record(&d, "verb:x", 1.0 + i as f64 * 0.333, None); }+ let s = super::load(&d)["verb:x|any"]["samples"].as_array().unwrap().clone();+ assert_eq!(s.len(), 10);+ assert_eq!(s[0], serde_json::json!(1.7));+ assert_eq!(super::verb_estimate(&d, "x").1, "measured");+ assert_eq!(super::verb_estimate(&d, "never").1, "unknown");+ let _ = std::fs::remove_dir_all(&d);+ }+}
rust/core/src/ab.rs+9−1@@ -76,9 +76,17 @@ impl AbClient for Loopback { .map_err(|e| AbError::Unreachable(e.to_string()))?; let mut v: Value = resp.into_json().map_err(|e| AbError::Failed(verb.into(), e.to_string()))?; // ab wraps a bridge verb's JSON in an envelope whose `output` is a JSON STRING; unwrap it.+ // The envelope's own top-level keys (fullPath, safePath, hwnd, ok...) are kept: the+ // inner JSON wins on a clash, the rest is merged in. desktop_screenshot_window's paths+ // live ONLY on the envelope, and dropping them once cost a native screenshot verb. if let Some(inner) = v.get("output").and_then(|o| o.as_str()) { if inner.trim_start().starts_with('{') {- if let Ok(parsed) = serde_json::from_str::<Value>(inner) { v = parsed; }+ if let Ok(mut parsed) = serde_json::from_str::<Value>(inner) {+ if let (Some(po), Some(eo)) = (parsed.as_object_mut(), v.as_object()) {+ for (k, val) in eo { if k != "output" { po.entry(k.clone()).or_insert(val.clone()); } }+ }+ v = parsed;+ } } } if v.get("success") == Some(&Value::Bool(false)) || v.get("status") == Some(&Value::String("error".into())) {
rust/core/src/dialogs.rsadded+64@@ -0,0 +1,64 @@+//! Fusion's blocking dialogs, classified by title (or body text) exactly as+//! `handlers/dialog_classify.py` does. The rule table is `dialog_rules.json`, generated from the+//! Python source by the golden script so the two shells cannot drift; the goldens in+//! `tests/golden/dialog_titles.json` pin the result for a spread of real titles.++use serde_json::Value;++pub const RULES_JSON: &str = include_str!("dialog_rules.json");++fn rules() -> &'static Value {+ static CELL: std::sync::OnceLock<Value> = std::sync::OnceLock::new();+ CELL.get_or_init(|| serde_json::from_str(RULES_JSON).unwrap_or(Value::Null))+}++/// `_category_for_title`: (category, resolution), or None for an empty title.+pub fn category_for_title(title: &str) -> Option<(String, String)> {+ let t = title.trim().to_lowercase();+ if t.is_empty() { return None; }+ let r = rules();+ for rule in r["rules"].as_array().map(|a| a.as_slice()).unwrap_or(&[]) {+ if rule["needles"].as_array().map(|n| n.iter().any(|s| s.as_str().map(|s| t.contains(s)).unwrap_or(false))).unwrap_or(false) {+ return Some((rule["category"].as_str().unwrap_or("").to_string(), rule["resolution"].as_str().unwrap_or("").to_string()));+ }+ }+ if ["fusion", "fusion360", "autodesk fusion 360", "fusion 360"].contains(&t.as_str()) {+ return Some((r["generic"]["category"].as_str().unwrap_or("").to_string(), r["generic"]["resolution"].as_str().unwrap_or("").to_string()));+ }+ Some((r["unknown"]["category"].as_str().unwrap_or("").to_string(), r["unknown"]["resolution"].as_str().unwrap_or("").to_string()))+}++pub fn category_set(name: &str) -> Vec<String> {+ rules()["sets"][name].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default()+}++/// `_find_qt_dialog_windows` over ab's window rows: visible Qt-class windows that are not the+/// main window, minus small / tall docked tool panels.+pub fn qt_dialog_rows(rows: &[Value]) -> Vec<Value> {+ let mut out = Vec::new();+ for w in rows {+ if w["visible"].as_bool() == Some(false) { continue; }+ let class = w["className"].as_str().or(w["class"].as_str()).unwrap_or("");+ let title = w["title"].as_str().unwrap_or("");+ if !class.contains("Qt") || title.contains("Autodesk Fusion") { continue; }+ let rect = rect_of(&w["rect"]);+ let (ww, hh) = (rect["width"].as_i64().unwrap_or(0), rect["height"].as_i64().unwrap_or(0));+ if class.contains("Tool") && (ww < 400 || hh < 300 || hh > ww * 3 / 2) { continue; }+ out.push(serde_json::json!({"hwnd": w["hwnd"], "title": title, "className": class, "rect": rect}));+ }+ out+}++/// ab answers a rect as `[left, top, right, bottom]` (desktop_enum_windows) or as the+/// `{left, top, width, height}` object (desktop_list_windows); normalise to Python's dict.+pub fn rect_of(r: &Value) -> Value {+ if let Some(a) = r.as_array() {+ if a.len() == 4 {+ let (l, t, rr, b) = (a[0].as_i64().unwrap_or(0), a[1].as_i64().unwrap_or(0), a[2].as_i64().unwrap_or(0), a[3].as_i64().unwrap_or(0));+ return serde_json::json!({"left": l, "top": t, "right": rr, "bottom": b, "width": rr - l, "height": b - t});+ }+ }+ let (l, t) = (r["left"].as_i64().or(r["x"].as_i64()).unwrap_or(0), r["top"].as_i64().or(r["y"].as_i64()).unwrap_or(0));+ let (w, h) = (r["width"].as_i64().or_else(|| r["right"].as_i64().map(|x| x - l)).unwrap_or(0), r["height"].as_i64().or_else(|| r["bottom"].as_i64().map(|y| y - t)).unwrap_or(0));+ serde_json::json!({"left": l, "top": t, "right": l + w, "bottom": t + h, "width": w, "height": h})+}
rust/core/src/host.rs+28@@ -49,6 +49,27 @@ pub trait HostProcess: Send + Sync { /// with KILL_ON_JOB_CLOSE (and BREAKAWAY_OK, so the child's own Fusion launch can still /// break away); a no-op elsewhere. Returns whether the tie took. fn tie_child_lifetime(&self, _pid: u32) -> bool { false }+ /// Guard the next `seconds` against Fusion-family focus theft (the background envelope:+ /// foreground-lock timeout raised, minimize animation off, a watcher that pushes any+ /// Fusion window that grabs the foreground back down unless the user is watching).+ /// Fire-and-forget; a no-op off Windows.+ fn background_envelope(&self, _seconds: f64, _minimize: bool) {}+ /// Is the user watching Fusion (they brought it forward themselves during an envelope)?+ fn user_watching(&self) -> bool { false }+ /// The OS's own hang verdict for a top-level window (IsHungAppWindow); false off Windows.+ fn window_hung(&self, _hwnd: i64) -> bool { false }+ /// Summed kernel+user CPU ticks (100ns units) of the Fusion family; 0 when unreadable.+ fn family_cpu_ticks(&self) -> u64 { 0 }+ /// Physical px per DIP for a window (GetDpiForWindow / 96 on Windows). The same ribbon+ /// control measured 296 DIP on a 4K box at 2.0 and a laptop at 1.5; rect fractions do not port.+ fn window_dpi_scale(&self, _hwnd: i64) -> f64 { 1.0 }+ /// Screen px of the window's CLIENT (0,0). Fusion draws its own title bar inside the client+ /// area and a maximized window's rect overhangs the screen, so rect.left/top is 13 px off.+ fn client_origin(&self, _hwnd: i64) -> (i32, i32) { (0, 0) }+ /// The work area (left, top, width, height) of the monitor a window sits on, and whether+ /// the window is maximized; None when unreadable. Used to grow a squint-sized Fusion window.+ fn window_work_area(&self, _hwnd: i64) -> Option<(i32, i32, i32, i32)> { None }+ fn window_is_maximized(&self, _hwnd: i64) -> bool { false } } /// Where things live on this OS. Every path the bridge ever touches comes from here.@@ -73,4 +94,11 @@ pub trait HostPaths: Send + Sync { /// 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 }+ /// A path that survives Fusion's space-delimited executeTextCommand (8.3 short form on+ /// Windows when the path has a space); the path itself elsewhere.+ fn short_path(&self, path: &str) -> String { path.replace('\\', "/") }+ /// `_clean_incomplete_webdeploy`: after a failed streamed launch, remove the partial+ /// launcher-bearing webdeploy dirs - and NOTHING when any complete install exists (the+ /// launcher-only stub beside a healthy app is legitimate). Returns the dir names removed.+ fn clean_incomplete_webdeploy(&self) -> Vec<String> { vec![] } }
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 dialogs; pub mod readiness; pub mod ab;
rust/lbr/src/lib.rs+17−1@@ -321,8 +321,24 @@ pub fn pair_parts(lbr_path: &str, models: &[String], metadata: &LibraryMetadata) } /// Python's `%r` for a str: single-quoted unless the text has a single quote and no double.+/// Python `repr(str)`: single quotes unless the text has a single quote and no double quote;+/// backslash, the quote in use, and control characters escaped the way CPython escapes them. fn py_repr(s: &str) -> String {- if s.contains('\'') && !s.contains('"') { format!("\"{s}\"") } else { format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) }+ let dq = s.contains('\'') && !s.contains('"');+ let q = if dq { '"' } else { '\'' };+ let mut out = String::with_capacity(s.len() + 2);+ out.push(q);+ for c in s.chars() {+ match c {+ '\\' => out.push_str("\\\\"),+ '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"),+ c if c == q => { out.push('\\'); out.push(c); }+ c if (c as u32) < 0x20 || c as u32 == 0x7f => out.push_str(&format!("\\x{:02x}", c as u32)),+ c => out.push(c),+ }+ }+ out.push(q);+ out } // ───────────────────────── per-part split (library_bundle.split_per_part) ─────────────────────────
rust/os-win/Cargo.toml+1−1@@ -9,4 +9,4 @@ description = "Windows implementation of fusion-core's HostProcess and HostPaths fusion-core = { path = "../core" } [target.'cfg(windows)'.dependencies]-windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_UI_Shell", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security"] }+windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_UI_Shell", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_UI_HiDpi", "Win32_Graphics_Gdi"] }
rust/os-win/src/lib.rs+247@@ -203,6 +203,10 @@ fn start_shim(_exe: &Path) -> Result<u32, HostError> { } impl HostProcess for WindowsHost {+ fn window_dpi_scale(&self, hwnd: i64) -> f64 { window_dpi_scale(hwnd) }+ fn client_origin(&self, hwnd: i64) -> (i32, i32) { client_origin(hwnd) }+ fn window_work_area(&self, hwnd: i64) -> Option<(i32, i32, i32, i32)> { window_work_area(hwnd) }+ fn window_is_maximized(&self, hwnd: i64) -> bool { window_is_maximized(hwnd) } fn fusion_processes(&self) -> Vec<FusionProcess> { snapshot_fusion() } fn image_running(&self, names: &[&str]) -> bool {@@ -223,6 +227,10 @@ impl HostProcess for WindowsHost { Ok(FusionProcess { pid: shim, image: "cmd.exe (start shim; Fusion360.exe not visible yet)".into(), parent_pid: None }) } + fn background_envelope(&self, seconds: f64, minimize: bool) { envelope::begin(seconds, minimize) }+ fn window_hung(&self, hwnd: i64) -> bool { hang::window_hung(hwnd) }+ fn family_cpu_ticks(&self) -> u64 { hang::family_cpu_ticks() }+ fn user_watching(&self) -> bool { envelope::user_watching() } #[cfg(windows)] fn tie_child_lifetime(&self, pid: u32) -> bool { use windows_sys::Win32::Foundation::CloseHandle;@@ -304,6 +312,17 @@ impl HostPaths for WindowsHost { fn bridge_state_dir(&self) -> PathBuf { home().join(".adom").join("fusion-bridge") } + fn short_path(&self, path: &str) -> String { short_path(path) }+ fn clean_incomplete_webdeploy(&self) -> Vec<String> {+ if any_app_complete() { return vec![]; }+ let mut removed = Vec::new();+ for base in webdeploy_bases() {+ for sub in subdirs(&base) {+ if sub.join("FusionLauncher.exe").is_file() && !app_dir_complete(&sub) && std::fs::remove_dir_all(&sub).is_ok() { removed.push(sub.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()); }+ }+ }+ removed+ } 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()))@@ -335,3 +354,231 @@ mod tests { assert!(h.fusion_processes().is_empty() || cfg!(windows)); } }+++/// The background envelope, ported from fusion_detect.py (`begin_background_envelope` and+/// `_envelope_watch`): while a verb may raise a Fusion window, Windows is told to DENY foreground+/// changes (SPI_SETFOREGROUNDLOCKTIMEOUT 300000, LockSetForegroundWindow), the minimize animation+/// is off so a pushed-down window vanishes in one frame, and a watcher thread pushes any+/// Fusion-family window that reaches the foreground to the bottom (or minimizes it, for a launch)+/// unless the USER brought it forward (recent input) - then the foreground is theirs and the+/// watcher stands down until they leave it. Nested envelopes refcount; the last one out restores+/// the user's settings.+pub mod envelope {+ use std::sync::atomic::{AtomicBool, Ordering};+ use std::sync::Mutex;+ static USER_WATCHING: AtomicBool = AtomicBool::new(false);+ #[allow(dead_code)]+ static STATE: Mutex<(u32, Option<u32>, Option<i32>, bool)> = Mutex::new((0, None, None, false));+ pub fn user_watching() -> bool { USER_WATCHING.load(Ordering::Relaxed) }++ #[cfg(not(windows))]+ pub fn begin(_seconds: f64, _minimize: bool) {}++ #[cfg(windows)]+ pub fn begin(seconds: f64, minimize: bool) {+ use windows_sys::Win32::UI::WindowsAndMessaging::{LockSetForegroundWindow, LSFW_LOCK};+ {+ let mut st = STATE.lock().unwrap();+ st.0 += 1;+ if st.0 == 1 {+ st.1 = win::get_fg_lock_timeout();+ st.2 = win::get_min_animate();+ win::set_fg_lock_timeout(300_000);+ win::set_min_animate(0);+ // SAFETY: plain user32 call with a documented flag.+ st.3 = unsafe { LockSetForegroundWindow(LSFW_LOCK) } != 0;+ }+ }+ std::thread::spawn(move || { watch(seconds, minimize); release(); });+ }++ #[cfg(windows)]+ fn release() {+ use windows_sys::Win32::UI::WindowsAndMessaging::{LockSetForegroundWindow, LSFW_UNLOCK};+ let mut st = STATE.lock().unwrap();+ st.0 = st.0.saturating_sub(1);+ if st.0 == 0 {+ if let Some(fg) = st.1 { win::set_fg_lock_timeout(fg); }+ if let Some(a) = st.2 { win::set_min_animate(a); }+ // SAFETY: as above.+ if st.3 { unsafe { LockSetForegroundWindow(LSFW_UNLOCK); } }+ }+ }++ #[cfg(windows)]+ fn family_pids() -> std::collections::HashSet<u32> {+ super::snapshot_images().into_iter().filter(|(_, _, img)| fusion_core::windows_facts::FAMILY_IMAGES.iter().any(|f| f.eq_ignore_ascii_case(img))).map(|(pid, _, _)| pid).collect()+ }++ #[cfg(windows)]+ fn watch(seconds: f64, minimize: bool) {+ use std::time::{Duration, Instant};+ use windows_sys::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId, SetWindowPos, ShowWindowAsync, HWND_BOTTOM, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SW_SHOWMINNOACTIVE};+ let flags = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE;+ let fg_pid = |hwnd: windows_sys::Win32::Foundation::HWND| -> u32 { let mut pid = 0u32; unsafe { GetWindowThreadProcessId(hwnd, &mut pid); } pid };+ let end = Instant::now() + Duration::from_secs_f64(seconds.max(0.0));+ let mut pids = family_pids();+ let mut last_refresh = Instant::now();+ let mut prev_was_fusion = false;+ let mut away_since: Option<Instant> = None;+ let mut away_tick: Option<u32> = None;+ // SAFETY: GetForegroundWindow / GetWindowThreadProcessId / SetWindowPos / ShowWindowAsync+ // are plain user32 calls on handles we only read; nothing is dereferenced.+ unsafe {+ let fg0 = GetForegroundWindow();+ if !fg0.is_null() && !pids.is_empty() && pids.contains(&fg_pid(fg0)) { USER_WATCHING.store(true, Ordering::Relaxed); prev_was_fusion = true; }+ while Instant::now() < end {+ if last_refresh.elapsed() > Duration::from_secs(2) { pids = family_pids(); last_refresh = Instant::now(); }+ let fg = GetForegroundWindow();+ let fg_is_fusion = !fg.is_null() && !pids.is_empty() && pids.contains(&fg_pid(fg));+ if fg_is_fusion {+ away_since = None;+ if !prev_was_fusion && win::user_active_recently(5000) { USER_WATCHING.store(true, Ordering::Relaxed); }+ if !USER_WATCHING.load(Ordering::Relaxed) {+ if minimize { ShowWindowAsync(fg, SW_SHOWMINNOACTIVE); } else { SetWindowPos(fg, HWND_BOTTOM, 0, 0, 0, 0, flags); }+ }+ } else if !fg.is_null() {+ match away_since {+ None => { away_since = Some(Instant::now()); away_tick = win::last_input_tick(); }+ Some(t) if USER_WATCHING.load(Ordering::Relaxed) && t.elapsed() >= Duration::from_secs(3) => {+ let now_tick = win::last_input_tick();+ if let (Some(n), Some(a)) = (now_tick, away_tick) { if n != a { USER_WATCHING.store(false, Ordering::Relaxed); } }+ }+ _ => {}+ }+ }+ prev_was_fusion = fg_is_fusion;+ std::thread::sleep(Duration::from_millis(50));+ }+ // a 20s grace: keep pushing a Fusion window down until the user acts or it stays away+ let grace_end = Instant::now() + Duration::from_secs(20);+ while Instant::now() < grace_end {+ if win::user_active_recently(5000) || USER_WATCHING.load(Ordering::Relaxed) { break; }+ let fg = GetForegroundWindow();+ if fg.is_null() || pids.is_empty() || !pids.contains(&fg_pid(fg)) { break; }+ SetWindowPos(fg, HWND_BOTTOM, 0, 0, 0, 0, flags);+ std::thread::sleep(Duration::from_millis(200));+ }+ }+ }++ #[cfg(windows)]+ mod win {+ use windows_sys::Win32::UI::WindowsAndMessaging::{SystemParametersInfoW, ANIMATIONINFO, SPI_GETANIMATION, SPI_GETFOREGROUNDLOCKTIMEOUT, SPI_SETANIMATION, SPI_SETFOREGROUNDLOCKTIMEOUT};+ use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};+ use windows_sys::Win32::System::SystemInformation::GetTickCount;+ // SAFETY (all four): SystemParametersInfoW with the documented parameter shapes; the+ // out-params are stack locals of the right size.+ pub fn get_fg_lock_timeout() -> Option<u32> { let mut v: u32 = 0; unsafe { if SystemParametersInfoW(SPI_GETFOREGROUNDLOCKTIMEOUT, 0, &mut v as *mut u32 as *mut _, 0) != 0 { Some(v) } else { None } } }+ pub fn set_fg_lock_timeout(ms: u32) { unsafe { SystemParametersInfoW(SPI_SETFOREGROUNDLOCKTIMEOUT, 0, ms as usize as *mut _, 0); } }+ pub fn get_min_animate() -> Option<i32> { let mut ai: ANIMATIONINFO = unsafe { std::mem::zeroed() }; ai.cbSize = std::mem::size_of::<ANIMATIONINFO>() as u32; unsafe { if SystemParametersInfoW(SPI_GETANIMATION, ai.cbSize, &mut ai as *mut _ as *mut _, 0) != 0 { Some(ai.iMinAnimate) } else { None } } }+ pub fn set_min_animate(v: i32) { let mut ai: ANIMATIONINFO = unsafe { std::mem::zeroed() }; ai.cbSize = std::mem::size_of::<ANIMATIONINFO>() as u32; ai.iMinAnimate = v; unsafe { SystemParametersInfoW(SPI_SETANIMATION, ai.cbSize, &mut ai as *mut _ as *mut _, 0); } }+ pub fn last_input_tick() -> Option<u32> { let mut lii: LASTINPUTINFO = unsafe { std::mem::zeroed() }; lii.cbSize = std::mem::size_of::<LASTINPUTINFO>() as u32; unsafe { if GetLastInputInfo(&mut lii) != 0 { Some(lii.dwTime) } else { None } } }+ pub fn user_active_recently(window_ms: u32) -> bool { match last_input_tick() { Some(t) => unsafe { GetTickCount() }.wrapping_sub(t) < window_ms, None => false } }+ }+}+++/// Two audited Win32 reads for the hang gate (fusion_ui.is_fusion_hung, fusion_detect.fusion_using_cpu):+/// IsHungAppWindow, the shell's own verdict, and GetProcessTimes over the launch family so a+/// GIL-starved-but-working Fusion (CPU advancing) is never mistaken for a wedged one.+pub mod hang {+ #[cfg(not(windows))]+ pub fn window_hung(_hwnd: i64) -> bool { false }+ #[cfg(not(windows))]+ pub fn family_cpu_ticks() -> u64 { 0 }+ #[cfg(windows)]+ pub fn window_hung(hwnd: i64) -> bool {+ use windows_sys::Win32::UI::WindowsAndMessaging::IsHungAppWindow;+ // SAFETY: a plain user32 read on a handle we only pass through.+ unsafe { IsHungAppWindow(hwnd as *mut _) != 0 }+ }+ #[cfg(windows)]+ pub fn family_cpu_ticks() -> u64 {+ use windows_sys::Win32::Foundation::{CloseHandle, FILETIME};+ use windows_sys::Win32::System::Threading::{GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};+ let pids: Vec<u32> = super::snapshot_images().into_iter().filter(|(_, _, img)| fusion_core::windows_facts::FAMILY_IMAGES.iter().any(|f| f.eq_ignore_ascii_case(img))).map(|(p, _, _)| p).collect();+ let mut total = 0u64;+ for pid in pids {+ // SAFETY: a limited-information handle we close; four zeroed FILETIMEs of the right size.+ unsafe {+ let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);+ if h.is_null() { continue; }+ let (mut c, mut e, mut k, mut u): (FILETIME, FILETIME, FILETIME, FILETIME) = (std::mem::zeroed(), std::mem::zeroed(), std::mem::zeroed(), std::mem::zeroed());+ if GetProcessTimes(h, &mut c, &mut e, &mut k, &mut u) != 0 {+ total += (k.dwLowDateTime as u64) | ((k.dwHighDateTime as u64) << 32);+ total += (u.dwLowDateTime as u64) | ((u.dwHighDateTime as u64) << 32);+ }+ CloseHandle(h);+ }+ }+ total+ }+}+++/// `_fusion_text_path`: Fusion's executeTextCommand is space-delimited, and the quoted form+/// fails with error 3; the 8.3 short path with forward slashes opens first try.+#[cfg(windows)]+pub fn short_path(path: &str) -> String {+ use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW;+ let p = path.replace('\\', "/");+ if !p.contains(' ') { return p; }+ let wide: Vec<u16> = p.encode_utf16().chain(std::iter::once(0)).collect();+ let mut buf = vec![0u16; 1024];+ // SAFETY: a NUL-terminated UTF-16 input and an output buffer of the stated length.+ let n = unsafe { GetShortPathNameW(wide.as_ptr(), buf.as_mut_ptr(), buf.len() as u32) } as usize;+ if n == 0 || n >= buf.len() { return p; }+ let s = String::from_utf16_lossy(&buf[..n]).replace('\\', "/");+ if s.contains(' ') { p } else { s }+}+#[cfg(not(windows))]+pub fn short_path(path: &str) -> String { path.replace('\\', "/") }++/// `_window_dpi_scale`: GetDpiForWindow / 96, else the nearest monitor's effective DPI, else 1.0.+#[cfg(windows)]+pub fn window_dpi_scale(hwnd: i64) -> f64 {+ use windows_sys::Win32::Graphics::Gdi::{MonitorFromWindow, MONITOR_DEFAULTTONEAREST};+ use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, GetDpiForWindow, MDT_EFFECTIVE_DPI};+ let h = hwnd as isize as windows_sys::Win32::Foundation::HWND;+ unsafe {+ let dpi = GetDpiForWindow(h);+ if dpi > 0 { return dpi as f64 / 96.0; }+ let mon = MonitorFromWindow(h, MONITOR_DEFAULTTONEAREST);+ let (mut x, mut y) = (0u32, 0u32);+ if !mon.is_null() && GetDpiForMonitor(mon, MDT_EFFECTIVE_DPI, &mut x, &mut y) == 0 && x > 0 { return x as f64 / 96.0; }+ }+ 1.0+}+#[cfg(not(windows))]+pub fn window_dpi_scale(_hwnd: i64) -> f64 { 1.0 }++/// `_client_origin`: ClientToScreen of (0,0), the honest origin for ribbon DIP offsets.+#[cfg(windows)]+pub fn client_origin(hwnd: i64) -> (i32, i32) {+ use windows_sys::Win32::Foundation::POINT;+ use windows_sys::Win32::Graphics::Gdi::ClientToScreen;+ let mut pt = POINT { x: 0, y: 0 };+ if unsafe { ClientToScreen(hwnd as isize as windows_sys::Win32::Foundation::HWND, &mut pt) } != 0 { (pt.x, pt.y) } else { (0, 0) }+}+#[cfg(not(windows))]+pub fn client_origin(_hwnd: i64) -> (i32, i32) { (0, 0) }++#[cfg(windows)]+pub fn window_work_area(hwnd: i64) -> Option<(i32, i32, i32, i32)> {+ use windows_sys::Win32::Graphics::Gdi::{GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST};+ unsafe {+ let mon = MonitorFromWindow(hwnd as isize as windows_sys::Win32::Foundation::HWND, MONITOR_DEFAULTTONEAREST);+ if mon.is_null() { return None; }+ let mut mi: MONITORINFO = std::mem::zeroed(); mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;+ if GetMonitorInfoW(mon, &mut mi) == 0 { return None; }+ let r = mi.rcWork; Some((r.left, r.top, r.right - r.left, r.bottom - r.top))+ }+}+#[cfg(not(windows))]+pub fn window_work_area(_hwnd: i64) -> Option<(i32, i32, i32, i32)> { None }+#[cfg(windows)]+pub fn window_is_maximized(hwnd: i64) -> bool { unsafe { windows_sys::Win32::UI::WindowsAndMessaging::IsZoomed(hwnd as isize as windows_sys::Win32::Foundation::HWND) != 0 } }+#[cfg(not(windows))]+pub fn window_is_maximized(_hwnd: i64) -> bool { false }
server.py+554−212@@ -11669,6 +11669,14 @@ def _note_focus_event(verb: str, outcome: str, reason: str = "") -> None: "reason": (reason or "")[:200], }) del _FOCUS_EVENTS[:-_FOCUS_EVENTS_MAX]+ # mirrored to disk so the Rust exe (Phase 4 fusion_state) reports the same audit log+ try:+ _fp = os.path.join(os.path.expanduser("~"), ".adom", "fusion-bridge", "focus-events.jsonl")+ os.makedirs(os.path.dirname(_fp), exist_ok=True)+ with open(_fp, "a", encoding="utf-8") as _fh:+ _fh.write(json.dumps(_FOCUS_EVENTS[-1]) + "\n")+ except Exception:+ pass except Exception: pass @@ -12297,6 +12305,11 @@ def _handle_prove_library(fusion_info: dict, args: dict) -> dict: # publishes each artifact the instant it lands, so a poller assembles the bundle # progressively and sees the ganged view arrive last. _on_frame = args.get("_onFrame") if callable(args.get("_onFrame")) else None+ # The exe-owned chain (Rust, Phase 3) cannot pass a callable over HTTP: it passes+ # _frameSink, a file path, and every frame is appended there as one JSON line the+ # instant it lands. The exe tails that file while this call is in flight, so the+ # progressive assembly a poller sees is the same whichever side runs the chain.+ _frame_sink = str(args.get("_frameSink") or "").strip() def _emit(entry): if _on_frame:@@ -12304,6 +12317,12 @@ def _handle_prove_library(fusion_info: dict, args: dict) -> dict: _on_frame(entry) except Exception: pass+ if _frame_sink:+ try:+ with open(_frame_sink, "a", encoding="utf-8") as _fs:+ _fs.write(json.dumps(entry) + "\n")+ except Exception:+ pass return entry """PROVE a .lbr with the FOUR evidence images John specified (2026-08-21):@@ -13859,6 +13878,9 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: def _leave(stage_key): took = _tc.time() - float(state.get("stageStartedAt") or _tc.time()) _tmc.log_run("stage:" + stage_key, took, ok=True)+ # the estimate for the NEXT run reads "stage:<key>|any"; log_run only fed+ # "verb:stage:<key>", so stages never graduated from 'typical' to 'measured'+ _tmc.record("stage:" + stage_key, took) state["stagesDone"] = state.get("stagesDone", []) + [ {"stage": stage_key, "seconds": round(took, 1)}] _est_done[0] += _est_by_stage.get(stage_key, 0.0)@@ -14803,6 +14825,294 @@ def _handle_apply_fusion_update(fusion_info: dict, args: dict) -> dict: COMMAND_HANDLERS["resolve_seat_dialog"] = _handle_resolve_seat_dialog COMMAND_HANDLERS["apply_fusion_update"] = _handle_apply_fusion_update ++# ── Chain executors (Phase 3 of the Rust port): the exe orchestrates create_library /+# install_library_bundle itself and asks THIS process only for the routines that still+# carry Fusion-driving lessons (document hygiene, the restart gate, the cloud publish).+def _handle_chain_docs_open(fusion_info: dict, args: dict) -> dict:+ """The names of every document open in Fusion right now (the chain's `before` snapshot)."""+ return {"success": True, "documents": sorted(_open_doc_names())}+++def _handle_chain_close_docs_since(fusion_info: dict, args: dict) -> dict:+ """Close every document that was NOT in `before` (bridge scratch by construction), keeping+ `keep`. Returns the names closed; never touches a document that pre-dates the chain."""+ before = set(str(n) for n in (args.get("before") or []))+ keep = set(str(k).lower() for k in (args.get("keep") or []))+ try:+ closed = _close_docs_opened_since(before, keep=keep)+ except Exception as e:+ return {"success": False, "error": str(e)[:200], "closed": []}+ return {"success": True, "closed": closed, "deferred": list(_SWEEP_LAST_DEFERRED)}+++def _handle_chain_close_bridge_doc(fusion_info: dict, args: dict) -> dict:+ """Close a BRIDGE-owned document deterministically (no Save dialog survives), verified."""+ name = str(args.get("name") or "").strip()+ if not name:+ return {"success": False, "error": "name required"}+ _note_bridge_doc(name)+ r = _close_bridge_doc_verified(name, timeout_s=int(args.get("timeoutSec") or 30)) or {}+ return {"success": True, **r}+++def _handle_chain_sweep_orphans(fusion_info: dict, args: dict) -> dict:+ """Close scratch documents a chain that died with a bridge respawn left open (ledgered)."""+ try:+ return {"success": True, **(_sweep_orphan_scratch_docs() or {})}+ except Exception as e:+ return {"success": False, "error": str(e)[:200]}+++def _handle_chain_addin_restart_info(fusion_info: dict, args: dict) -> dict:+ """The restart-gate facts for a running Fusion whose add-in is silent: which unsaved+ documents are the USER'S, whether an automatic restart is allowed, and (when asked) the+ ab toast that asks the human. The exe runs the wait loop itself."""+ try:+ info = _addin_restart_info(fire_toast=bool(args.get("fireToast")))+ except Exception as e:+ return {"success": False, "error": str(e)[:200]}+ mine = info.get("userUnsaved")+ if mine is None:+ mine = _user_unsaved_docs(info.get("unsavedDocumentNames") or [])+ info["userUnsaved"] = mine+ info["holdActive"] = bool(_user_restart_hold_active())+ return {"success": True, **info}+++def _handle_chain_publish_library(fusion_info: dict, args: dict) -> dict:+ """Save the bound library into the user's cloud Library Manager folder."""+ p = (args.get("libraryPath") or "").replace("\\", "/")+ if not p:+ return {"success": False, "error": "libraryPath required"}+ try:+ r = _publish_library_to_cloud(p, args.get("name") or "", args.get("folder") or "Adom Libraries",+ replace_existing=bool(args.get("replaceExisting")))+ except Exception as e:+ return {"success": False, "saved": False, "error": str(e)[:200]}+ return {"success": True, **(r or {})}+++def _handle_chain_taskbar_progress(fusion_info: dict, args: dict) -> dict:+ """The honest progress bar on Fusion's taskbar button (pct null clears it)."""+ pct = args.get("pct")+ try:+ _taskbar_progress(None if pct is None else float(pct))+ except Exception:+ pass+ return {"success": True}+++def _handle_chain_part_metadata_report(fusion_info: dict, args: dict) -> dict:+ """The short, user-facing part-metadata report an import carries up the chain."""+ p = (args.get("libraryPath") or "").replace("\\", "/")+ if not p:+ return {"success": False, "error": "libraryPath required"}+ return {"success": True, "report": _part_metadata_report(p)}+++for _name, _fn in (("chain_docs_open", _handle_chain_docs_open),+ ("chain_close_docs_since", _handle_chain_close_docs_since),+ ("chain_close_bridge_doc", _handle_chain_close_bridge_doc),+ ("chain_sweep_orphans", _handle_chain_sweep_orphans),+ ("chain_addin_restart_info", _handle_chain_addin_restart_info),+ ("chain_publish_library", _handle_chain_publish_library),+ ("chain_taskbar_progress", _handle_chain_taskbar_progress),+ ("chain_part_metadata_report", _handle_chain_part_metadata_report)):+ COMMAND_HANDLERS[_name] = _fn+++def _handle_chain_addin_gate(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): the add-in preamble for one command; ok:true means send it."""+ cmd = str(args.get("command") or "")+ pre = _addin_preamble(cmd, dict(args.get("args") or {}))+ if pre is None:+ return {"success": True, "ok": True}+ return {"success": True, "ok": False, "refusal": pre}+++def _handle_chain_diagnose_addin_timeout(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): _diagnose_addin_timeout for a command the exe sent itself."""+ cmd = str(args.get("command") or "")+ orig = dict(args.get("original") or {})+ return {"success": True, "diagnosis": _diagnose_addin_timeout(cmd, orig)}+++def _handle_chain_dialog_array(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): the post-op dialog array a mutating verb carries (null when none)."""+ try:+ arr = _capture_dialog_array()+ except Exception:+ arr = None+ return {"success": True, "dialogArray": arr}+++def _handle_chain_verb_indicator(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): the taskbar activity indicator around one verb (start / stop)."""+ import timings as _tm+ cmd = str(args.get("command") or "")+ action = str(args.get("action") or "start")+ if action == "stop":+ try:+ from handlers.taskbar_overlay import end_activity_progress+ end_activity_progress(cmd, failed=bool(args.get("failed")))+ except Exception:+ pass+ return {"success": True}+ mode = _activity_mode()+ est, conf = _tm.verb_estimate(cmd)+ if mode in ("progress", "both"):+ try:+ from handlers.taskbar_overlay import start_activity_progress+ start_activity_progress(cmd, est, conf)+ except Exception:+ pass+ if mode in ("flash", "both"):+ try:+ _ab_call("desktop_taskbar", {"titleContains": "Autodesk Fusion", "flash": "until_focused"}, timeout=10)+ except Exception:+ pass+ return {"success": True, "mode": mode, "estimatedSec": est, "confidence": conf}+++for _name, _fn in (("chain_addin_gate", _handle_chain_addin_gate),+ ("chain_diagnose_addin_timeout", _handle_chain_diagnose_addin_timeout),+ ("chain_dialog_array", _handle_chain_dialog_array),+ ("chain_verb_indicator", _handle_chain_verb_indicator)):+ COMMAND_HANDLERS[_name] = _fn+++_SENTINELS = {}+++def _handle_chain_pre_verb(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): everything the Python dispatcher does BEFORE an add-in verb+ runs: the open-document snapshot, the window etiquette (foreground reason or background+ envelope), and for a long-running command the pre-dismissal of blocking dialogs."""+ cmd = str(args.get("command") or "")+ a = dict(args.get("args") or {})+ docs_before = sorted(_snapshot_open_docs()) if cmd in _WINDOW_RAISING else None+ a2, ran_bg, refusal = _window_etiquette_pre(cmd, a)+ if cmd in LONG_RUNNING_COMMANDS:+ try:+ info = get_fusion_window_info()+ for d in (info.get("dialogs") or []):+ try:+ close_window(d.get("hwnd"))+ except Exception:+ pass+ if info.get("dialogs"):+ import time as _t+ _t.sleep(0.5)+ except Exception:+ pass+ return {"success": True, "args": a2, "docsBefore": docs_before, "ranBackgrounded": bool(ran_bg),+ "refusal": refusal, "fg": bool(a.get("foreground"))}+++def _handle_chain_post_verb(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): everything the Python dispatcher does AFTER an add-in verb+ ran (documents opened, bridge docs, surface shot, chokepoint sweep, etiquette note, the+ recent-actions ledger on a failed slow verb)."""+ cmd = str(args.get("command") or "")+ a = dict(args.get("args") or {})+ res = args.get("result")+ if not isinstance(res, dict):+ res = {"success": False, "error": "no result"}+ docs_before = args.get("docsBefore")+ docs_before = set(docs_before) if isinstance(docs_before, list) else None+ since = args.get("since")+ if isinstance(since, (int, float)) and res.get("success") is False and (cmd in LONG_RUNNING_COMMANDS or cmd in _WINDOW_RAISING):+ try:+ acts = _ledger_entries_since(float(since))+ if acts:+ res.setdefault("recentActions", acts)+ res.setdefault("_ledgerHint", "recentActions = every background action the bridge took on the host app while this verb ran (newest last); fusion_action_ledger {sinceSeconds} for more.")+ except Exception:+ pass+ res = _window_etiquette_post(cmd, a, res, docs_before, bool(args.get("fg")))+ return {"success": True, "result": res}+++def _handle_chain_sentinel(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): the save-prompt sentinel around a doc-switching add-in call+ the exe sends itself: action start {seconds} -> id; action stop {id} -> soft-stop (12s grace)."""+ import uuid as _u+ action = str(args.get("action") or "start")+ if action == "stop":+ sid = str(args.get("id") or "")+ st = _SENTINELS.pop(sid, None)+ if st is not None:+ try:+ st.soft_stop(12.0)+ except Exception:+ pass+ return {"success": True, "stopped": st is not None}+ try:+ st = _SavePromptSentinel(float(args.get("seconds") or 40) + 10)+ st.__enter__()+ except Exception as e:+ return {"success": False, "error": str(e)}+ sid = _u.uuid4().hex[:10]+ _SENTINELS[sid] = st+ return {"success": True, "id": sid}+++for _name, _fn in (("chain_pre_verb", _handle_chain_pre_verb),+ ("chain_post_verb", _handle_chain_post_verb),+ ("chain_sentinel", _handle_chain_sentinel)):+ COMMAND_HANDLERS[_name] = _fn+++def _handle_chain_dismiss_recovery(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): relocate crash-recovery files and dismiss the recovery dialogs."""+ from handlers.dismiss_recovery import relocate_recovery_files, dismiss_recovery_dialog+ reloc = {"moved": 0, "dest": None}+ if args.get("relocate", True):+ try:+ reloc = relocate_recovery_files()+ except Exception:+ pass+ dismissed = False+ if args.get("dismiss", True):+ try:+ dismissed = bool(dismiss_recovery_dialog(max_wait=float(args.get("maxWait") or 5.0)))+ except Exception:+ dismissed = False+ return {"success": True, "dismissed": dismissed, "relocated": {"moved": reloc.get("moved", 0), "dest": reloc.get("dest")}}+++def _handle_chain_hung_check(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): the kill-grade hang verdict (_confirmed_hung), with the exe's+ own long-op marker honoured the way the Python one is."""+ import time as _t+ since = args.get("longOpSince")+ if isinstance(since, (int, float)) and since > 0 and not _LONG_OP.get("since"):+ _LONG_OP["since"] = float(since)+ _LONG_OP["name"] = str(args.get("longOpName") or "exe long op")+ try:+ return {"success": True, "verdict": _confirmed_hung()}+ finally:+ _LONG_OP["since"] = 0.0+ _LONG_OP["name"] = None+ return {"success": True, "verdict": _confirmed_hung()}+++def _handle_chain_clean_incomplete_webdeploy(fusion_info: dict, args: dict) -> dict:+ """Chain EXECUTOR (Rust port): remove partial webdeploy dirs after a failed streamed launch."""+ from fusion_detect import _clean_incomplete_webdeploy+ try:+ removed = _clean_incomplete_webdeploy()+ except Exception as e:+ return {"success": False, "error": str(e)[:200]}+ return {"success": True, "removed": removed}+++for _name, _fn in (("chain_dismiss_recovery", _handle_chain_dismiss_recovery),+ ("chain_hung_check", _handle_chain_hung_check),+ ("chain_clean_incomplete_webdeploy", _handle_chain_clean_incomplete_webdeploy)):+ COMMAND_HANDLERS[_name] = _fn+ def _handle_reset_workspace(args: dict) -> dict: """fusion_reset_workspace - bring Fusion back to a clean slate between runs (#59). @@ -14887,19 +15197,10 @@ def _handle_check_3d_thumbnail(fusion_info: dict, args: dict) -> dict: COMMAND_HANDLERS["check_3d_thumbnail"] = _handle_check_3d_thumbnail -def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:- """Thin wrapper: run the verb, then guarantee the windowEtiquette annotation lands on- EVERY backgrounded window-raising result - the inner dispatcher's closure only covered- direct-handler and add-in paths, so orchestrated verbs (show_*, make_test_project, the- tour, ...) returned unannotated (the calling AI could not know no focus was taken).- Idempotent with the inner annotation (the note is only appended once)."""- _fg = bool((args or {}).get("foreground"))- # WHAT WE OPEN IS OURS TO CLEAN UP (see _register_docs_we_opened). Snapshot before,- # diff after, so every document our verbs create is attributable to us - and only ours.- _cmd_short = command[7:] if command.startswith("fusion_") else command- _docs_before = _snapshot_open_docs() if _cmd_short in _WINDOW_RAISING else None- res = _time_and_indicate(_cmd_short, args,- lambda: _dispatch_command_inner(command, args, caller_identity))+def _window_etiquette_post(command: str, args: dict, res, _docs_before, _fg: bool):+ """The window etiquette AFTER a verb ran: register the documents it opened, note bridge+ docs, the surface shot, the save-prompt / dialog chokepoint sweep, and the etiquette+ annotation. Shared by dispatch_command and the exe's chain_post_verb executor.""" if _docs_before is not None: _register_docs_we_opened(_docs_before) # ── capture:true - ONE call = ONE consent = surface opened AND proven (issue #30) ──@@ -15002,6 +15303,240 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> return res +def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:+ """Thin wrapper: run the verb, then guarantee the windowEtiquette annotation lands on+ EVERY backgrounded window-raising result - the inner dispatcher's closure only covered+ direct-handler and add-in paths, so orchestrated verbs (show_*, make_test_project, the+ tour, ...) returned unannotated (the calling AI could not know no focus was taken).+ Idempotent with the inner annotation (the note is only appended once)."""+ _fg = bool((args or {}).get("foreground"))+ # WHAT WE OPEN IS OURS TO CLEAN UP (see _register_docs_we_opened). Snapshot before,+ # diff after, so every document our verbs create is attributable to us - and only ours.+ _cmd_short = command[7:] if command.startswith("fusion_") else command+ _docs_before = _snapshot_open_docs() if _cmd_short in _WINDOW_RAISING else None+ res = _time_and_indicate(_cmd_short, args,+ lambda: _dispatch_command_inner(command, args, caller_identity))+ res = _window_etiquette_post(command, args, res, _docs_before, _fg)+ return res+++def _addin_preamble(command: str, args: dict):+ """The gate every add-in-dependent verb passes BEFORE its command is sent: Fusion+ installed / running (auto-start unless noAutoStart), the hang gate, and the add-in+ restart contract. Returns the refusal dict, or None when the command may proceed.+ Extracted verbatim from _dispatch_command_inner so the Rust exe can call it as the+ chain_addin_gate executor while it owns the add-in pass-through (Phase 4)."""+ import time as _time+ # Issue #326: Fusion may be installed under a different Windows user profile than the current+ # one, so file-path detection fails even though Fusion is running. Check if it's actually+ # running (via tasklist) as a reliable fallback. If running, it's installed somewhere.+ fusion_running = _is_fusion_running()+ if not fusion_info.get("installed") and not fusion_running:+ return {+ "success": False,+ "error": "Fusion 360 is not installed on this machine.",+ "errorCode": "fusion_not_installed",+ "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",+ }+ if not fusion_running:+ # SECOND OPINION BEFORE A LAUNCH (2026-08-29): a launch on a false "not running"+ # starts a SECOND Fusion ("Multiple instances are not supported" in the user's+ # face, mid-import). A probe can miss while Fusion pegs the CPU; the add-in+ # answering on its port, or a Fusion main window, proves it is up. Re-ask twice+ # over ~3s and only launch when every signal still says closed.+ import time as _sop+ from fusion_detect import _fusion_main_hwnd_quick as _hwnd_quick+ for _ in range(2):+ _sop.sleep(1.5)+ if _is_fusion_running() or _probe_addin(timeout=1.5) or _hwnd_quick():+ fusion_running = True+ break+ if not fusion_running:+ # AUTO-OPEN, DO NOT REFUSE (page issue #64, 2026-08-26).+ # CLAUDE.md's automation doctrine: "If Fusion is closed, launch it ... Never+ # return an error the code could have fixed." fusion_create_library already+ # treats launch as a STAGE of the chain, but every INDIVIDUAL verb still+ # refused - so a user who ran a library import with Fusion merely closed+ # watched four steps fail in a row ("3D bind did NOT report a binding: Fusion+ # 360 is installed but not running", then symbol, footprint and 3D the same).+ # They cannot tell "the app was shut" from "this product is broken". Fusion+ # not being up is a STEP, never a refusal.+ _auto = None+ if not args.get("noAutoStart"):+ import time as _as+ _t0 = _as.time()+ print("[Fusion Bridge] %s needs Fusion; it is closed - launching it "+ "(page issue #64)" % command)+ try:+ # Same handler fusion_start uses (server.py:676), with waitForAddin so+ # we do not hand back a Fusion whose add-in is not answering yet.+ _auto = _handle_launch(fusion_info, {"waitForAddin": True}) or {}+ except Exception as _e:+ _auto = {"success": False, "error": "%s: %s" % (type(_e).__name__, _e)}+ _took = round(_as.time() - _t0, 1)+ _bridge_action_ledger("fusion_auto_started",+ {"forCommand": command, "ok": bool(_auto.get("success")),+ "tookSec": _took})+ if _auto.get("success") and _is_fusion_running():+ fusion_running = True+ print("[Fusion Bridge] Fusion up in %ss - continuing %s" % (_took, command))+ else:+ return {+ "success": False,+ "errorCode": "fusion_autostart_failed",+ "error": "Fusion was closed so the bridge tried to open it for you, "+ "and the launch did not come up: %s"+ % str(_auto.get("error") or "no error reported")[:300],+ "autoStartAttempted": True, "autoStartSec": _took,+ "_hint": "This is a launch failure, NOT a bad request - the command "+ "itself was never run. Check fusion_readiness (needsSignin "+ "true means Autodesk sign-in is blocking the launch, which "+ "is a human step). Pass noAutoStart:true to get the old "+ "refuse-immediately behaviour.",+ }+ if not fusion_running:+ return {+ "success": False,+ "error": "Fusion 360 is installed but not running.",+ "errorCode": "fusion_not_running",+ "_hint": "Call fusion_start to start Fusion 360, then wait for the add-in to become ready (fusion_start blocks until ready). Then retry this command.",+ }+ # Fusion is running — check if add-in is responsive (short timeout).+ # But first, if the add-in is busy with a long command, skip the+ # responsiveness check and fall through to the busy gate below.+ addin_busy = _check_addin_status(timeout=3.0)+ if addin_busy and addin_busy.get("busy"):+ pass # Fall through to busy gate+ elif not wait_for_addin(timeout=5):+ # Hung HOST vs down ADD-IN (page issue #38) - opposite remedies, so say which.+ # KILL-GRADE evidence only (_confirmed_hung): a busy main thread starves these+ # very probes and stops pumping messages, so one failed snapshot looks exactly+ # like a hang. Three healthy Fusions were taskkilled mid-work by this gate+ # before the persistence rule (2026-08-21).+ _h = _confirmed_hung()+ if _h.get("hung"):+ _ok = False+ _bridge_action_ledger("hang_gate_kill",+ {"heldSeconds": _h.get("heldSeconds"),+ "signals": _h.get("signals")})+ try:+ dispatch_command("kill", {})+ _time.sleep(6)+ dispatch_command("start", {})+ _ok = True+ except Exception:+ pass+ return {+ "success": False,+ "error": "Fusion 360 itself is NOT RESPONDING (hung host process, held "+ "%ss with the add-in refusing a 20s probe); the add-in cannot "+ "answer while it is." % _h.get("heldSeconds"),+ "errorCode": "fusion_host_hung",+ "hungSignals": _h.get("signals"),+ "restartAttempted": _ok,+ "_hint": ("Do NOT hunt for a blocking dialog - there is not one. Nothing "+ "reaches Fusion over IPC while the host is hung. "+ + ("The bridge force-restarted it; poll fusion_readiness."+ if _ok else "Use fusion_kill then fusion_start.")),+ }+ if _h.get("phase") in ("suspect", "busy-not-hung"):+ return {+ "success": False,+ "error": "Fusion's main thread is not answering right now - most likely "+ "WORKING on something heavy (a STEP import or save starves the "+ "add-in's probes), not hung.",+ "errorCode": "fusion_addin_busy",+ "hostResponding": _h.get("phase") == "busy-not-hung",+ "hungHeldSeconds": _h.get("heldSeconds"),+ "_hint": ("WAIT and re-poll; do not restart anything. The bridge only "+ "declares a real hang after 180s of continuous silence "+ "including a 20s probe - if that happens it restarts Fusion "+ "itself and says so."),+ }+ # TELL THE HUMAN WHY, AND OFFER THE FIX (the Adonis incident, John 2026-08-27).+ # This used to return a bare "add-in not responding" and a hint aimed at an AI+ # ("fix it YOURSELF - never ask the user"). That advice is right for an agent+ # driving headlessly and WRONG when a person is sitting in front of Fusion with+ # documents open: a brand-new user drove this from the wiki, every verb failed,+ # and nothing ever told him the product was fine and just needed a relaunch.+ # The payload now carries what a web-control caller needs to ASK him, and a+ # toast fires so he is told even if the caller drops it on the floor.+ _ri = _addin_restart_info()+ if _ri.get("autoRestart"):+ _started = _begin_auto_addin_restart()+ return {+ "success": False,+ "errorCode": "addin_restarting",+ "error": ("Fusion is running without the Adom add-in and no unsaved "+ "document belongs to the user, so the bridge is restarting "+ "Fusion itself (documents are saved first). No toast, no ask."),+ "restartStarted": _started,+ "userUnsaved": _ri.get("userUnsaved") or [],+ "_hint": "Poll fusion_readiness; ready flips true in ~60-90s, then retry this verb.",+ }+ return {+ "success": False,+ "error": ("Fusion 360 is running but the AdomBridge add-in is not responding "+ "- it was installed or updated while Fusion was already open, and "+ "Fusion only loads add-ins at launch."),+ "errorCode": "addin_restart_required",+ "hostResponding": True,+ "restartRequired": True,+ "userMessage": _ri["userMessage"],+ "openDocuments": _ri.get("openDocuments"),+ "unsavedDocuments": _ri.get("unsavedDocuments"),+ "documentsKnown": _ri.get("documentsKnown"),+ "remedyVerb": "fusion_restart_for_addin",+ "toast": _ri.get("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.)"),+ }+ return None+++def _window_etiquette_pre(command: str, args: dict):+ """The window etiquette BEFORE a verb runs (foreground:true needs a reason and a 3s+ heads-up; otherwise the background envelope). Returns (args, ran_backgrounded, refusal).+ Shared by _dispatch_command_inner and the exe's chain_pre_verb executor (Phase 4)."""+ import time as _time+ _ran_backgrounded = False+ if command in _WINDOW_RAISING:+ if (args or {}).get("foreground"):+ # Foreground is allowed but never free (John, 2026-08-16): the CALLER must say+ # WHY, and the user gets that reason as an ab caption for ~3s BEFORE the steal,+ # so they know what is happening and can yield (or object). No reason = refuse+ # with a teaching hint - same pattern as every reason-gated Adom verb.+ fg_reason = str(args.get("foregroundReason") or "").strip()+ if not fg_reason:+ _note_focus_event(command, "refused")+ return args, False, {"success": False, "errorCode": "foreground_reason_required",+ "error": "foreground:true needs foregroundReason (a short user-facing sentence).",+ "_hint": ("Fusion verbs ALWAYS run in the background so the user is never "+ "interrupted mid-typing. If the user genuinely wants to WATCH, re-call "+ "with foreground:true AND foregroundReason:\"<why, in words the user "+ "will read>\" - it is shown as an ab caption for 3 seconds before the "+ "window comes forward, so they can yield.")}+ _note_focus_event(command, "foreground", fg_reason)+ try:+ ab_client.notify("Fusion is coming to the foreground",+ fg_reason + " (Requested by your AI - Fusion verbs otherwise always "+ "run in the background.)", level="info", reach_user=True)+ _time.sleep(3.0) # the user's 3-second heads-up BEFORE any focus change+ except Exception:+ pass+ args = {k: v for k, v in args.items() if k not in ("foreground", "foregroundReason")}+ else:+ _sec = ADDIN_COMMAND_TIMEOUTS.get(command, 60) + 20+ begin_background_envelope(float(_sec), minimize=(command == "start"))+ _ran_backgrounded = True+ return args, _ran_backgrounded, None++ def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = None) -> dict: import time as _time # MUST be first: a later local `import time as _time` makes the # name local to this whole scope, so three earlier uses raised UnboundLocalError -@@ -15043,36 +15578,9 @@ def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = No # so a cloud open / view switch / import NEVER steals the user's foreground mid-typing. The # deliberate-foreground verbs (prefs_open, mcp_enable, demo - which announce themselves with # captions) are excluded on purpose. Refcounted, so overlapping verbs nest cleanly.- _ran_backgrounded = False- if command in _WINDOW_RAISING:- if (args or {}).get("foreground"):- # Foreground is allowed but never free (John, 2026-08-16): the CALLER must say- # WHY, and the user gets that reason as an ab caption for ~3s BEFORE the steal,- # so they know what is happening and can yield (or object). No reason = refuse- # with a teaching hint - same pattern as every reason-gated Adom verb.- fg_reason = str(args.get("foregroundReason") or "").strip()- if not fg_reason:- _note_focus_event(command, "refused")- return {"success": False, "errorCode": "foreground_reason_required",- "error": "foreground:true needs foregroundReason (a short user-facing sentence).",- "_hint": ("Fusion verbs ALWAYS run in the background so the user is never "- "interrupted mid-typing. If the user genuinely wants to WATCH, re-call "- "with foreground:true AND foregroundReason:\"<why, in words the user "- "will read>\" - it is shown as an ab caption for 3 seconds before the "- "window comes forward, so they can yield.")}- _note_focus_event(command, "foreground", fg_reason)- try:- ab_client.notify("Fusion is coming to the foreground",- fg_reason + " (Requested by your AI - Fusion verbs otherwise always "- "run in the background.)", level="info", reach_user=True)- _time.sleep(3.0) # the user's 3-second heads-up BEFORE any focus change- except Exception:- pass- args = {k: v for k, v in args.items() if k not in ("foreground", "foregroundReason")}- else:- _sec = ADDIN_COMMAND_TIMEOUTS.get(command, 60) + 20- begin_background_envelope(float(_sec), minimize=(command == "start"))- _ran_backgrounded = True+ args, _ran_backgrounded, _refusal = _window_etiquette_pre(command, args)+ if _refusal is not None:+ return _refusal def _annotate_background(res): # Tell the CALLING AI what just happened window-wise (John, 2026-08-16): the verb@@ -15099,175 +15607,9 @@ def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = No # Commands that need Fusion running (add-in commands + orchestrated commands) _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"} if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d", "capture_library_views", "cleanup_cloud_files", "generate_package", "export_optimized_glb", "board_stackup", "show_symbol", "show_footprint", "show_3d_package", "make_test_project", "bind_library_3d", "show_library", "show_project") or command in _OPEN_WITH_SCREENSHOT:- # Issue #326: Fusion may be installed under a different Windows user profile than the current- # one, so file-path detection fails even though Fusion is running. Check if it's actually- # running (via tasklist) as a reliable fallback. If running, it's installed somewhere.- fusion_running = _is_fusion_running()- if not fusion_info.get("installed") and not fusion_running:- return {- "success": False,- "error": "Fusion 360 is not installed on this machine.",- "errorCode": "fusion_not_installed",- "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",- }- if not fusion_running:- # SECOND OPINION BEFORE A LAUNCH (2026-08-29): a launch on a false "not running"- # starts a SECOND Fusion ("Multiple instances are not supported" in the user's- # face, mid-import). A probe can miss while Fusion pegs the CPU; the add-in- # answering on its port, or a Fusion main window, proves it is up. Re-ask twice- # over ~3s and only launch when every signal still says closed.- import time as _sop- from fusion_detect import _fusion_main_hwnd_quick as _hwnd_quick- for _ in range(2):- _sop.sleep(1.5)- if _is_fusion_running() or _probe_addin(timeout=1.5) or _hwnd_quick():- fusion_running = True- break- if not fusion_running:- # AUTO-OPEN, DO NOT REFUSE (page issue #64, 2026-08-26).- # CLAUDE.md's automation doctrine: "If Fusion is closed, launch it ... Never- # return an error the code could have fixed." fusion_create_library already- # treats launch as a STAGE of the chain, but every INDIVIDUAL verb still- # refused - so a user who ran a library import with Fusion merely closed- # watched four steps fail in a row ("3D bind did NOT report a binding: Fusion- # 360 is installed but not running", then symbol, footprint and 3D the same).- # They cannot tell "the app was shut" from "this product is broken". Fusion- # not being up is a STEP, never a refusal.- _auto = None- if not args.get("noAutoStart"):- import time as _as- _t0 = _as.time()- print("[Fusion Bridge] %s needs Fusion; it is closed - launching it "- "(page issue #64)" % command)- try:- # Same handler fusion_start uses (server.py:676), with waitForAddin so- # we do not hand back a Fusion whose add-in is not answering yet.- _auto = _handle_launch(fusion_info, {"waitForAddin": True}) or {}- except Exception as _e:- _auto = {"success": False, "error": "%s: %s" % (type(_e).__name__, _e)}- _took = round(_as.time() - _t0, 1)- _bridge_action_ledger("fusion_auto_started",- {"forCommand": command, "ok": bool(_auto.get("success")),- "tookSec": _took})- if _auto.get("success") and _is_fusion_running():- fusion_running = True- print("[Fusion Bridge] Fusion up in %ss - continuing %s" % (_took, command))- else:- return {- "success": False,- "errorCode": "fusion_autostart_failed",- "error": "Fusion was closed so the bridge tried to open it for you, "- "and the launch did not come up: %s"- % str(_auto.get("error") or "no error reported")[:300],- "autoStartAttempted": True, "autoStartSec": _took,- "_hint": "This is a launch failure, NOT a bad request - the command "- "itself was never run. Check fusion_readiness (needsSignin "- "true means Autodesk sign-in is blocking the launch, which "- "is a human step). Pass noAutoStart:true to get the old "- "refuse-immediately behaviour.",- }- if not fusion_running:- return {- "success": False,- "error": "Fusion 360 is installed but not running.",- "errorCode": "fusion_not_running",- "_hint": "Call fusion_start to start Fusion 360, then wait for the add-in to become ready (fusion_start blocks until ready). Then retry this command.",- }- # Fusion is running — check if add-in is responsive (short timeout).- # But first, if the add-in is busy with a long command, skip the- # responsiveness check and fall through to the busy gate below.- addin_busy = _check_addin_status(timeout=3.0)- if addin_busy and addin_busy.get("busy"):- pass # Fall through to busy gate- elif not wait_for_addin(timeout=5):- # Hung HOST vs down ADD-IN (page issue #38) - opposite remedies, so say which.- # KILL-GRADE evidence only (_confirmed_hung): a busy main thread starves these- # very probes and stops pumping messages, so one failed snapshot looks exactly- # like a hang. Three healthy Fusions were taskkilled mid-work by this gate- # before the persistence rule (2026-08-21).- _h = _confirmed_hung()- if _h.get("hung"):- _ok = False- _bridge_action_ledger("hang_gate_kill",- {"heldSeconds": _h.get("heldSeconds"),- "signals": _h.get("signals")})- try:- dispatch_command("kill", {})- _time.sleep(6)- dispatch_command("start", {})- _ok = True- except Exception:- pass- return {- "success": False,- "error": "Fusion 360 itself is NOT RESPONDING (hung host process, held "- "%ss with the add-in refusing a 20s probe); the add-in cannot "- "answer while it is." % _h.get("heldSeconds"),- "errorCode": "fusion_host_hung",- "hungSignals": _h.get("signals"),- "restartAttempted": _ok,- "_hint": ("Do NOT hunt for a blocking dialog - there is not one. Nothing "- "reaches Fusion over IPC while the host is hung. "- + ("The bridge force-restarted it; poll fusion_readiness."- if _ok else "Use fusion_kill then fusion_start.")),- }- if _h.get("phase") in ("suspect", "busy-not-hung"):- return {- "success": False,- "error": "Fusion's main thread is not answering right now - most likely "- "WORKING on something heavy (a STEP import or save starves the "- "add-in's probes), not hung.",- "errorCode": "fusion_addin_busy",- "hostResponding": _h.get("phase") == "busy-not-hung",- "hungHeldSeconds": _h.get("heldSeconds"),- "_hint": ("WAIT and re-poll; do not restart anything. The bridge only "- "declares a real hang after 180s of continuous silence "- "including a 20s probe - if that happens it restarts Fusion "- "itself and says so."),- }- # TELL THE HUMAN WHY, AND OFFER THE FIX (the Adonis incident, John 2026-08-27).- # This used to return a bare "add-in not responding" and a hint aimed at an AI- # ("fix it YOURSELF - never ask the user"). That advice is right for an agent- # driving headlessly and WRONG when a person is sitting in front of Fusion with- # documents open: a brand-new user drove this from the wiki, every verb failed,- # and nothing ever told him the product was fine and just needed a relaunch.- # The payload now carries what a web-control caller needs to ASK him, and a- # toast fires so he is told even if the caller drops it on the floor.- _ri = _addin_restart_info()- if _ri.get("autoRestart"):- _started = _begin_auto_addin_restart()- return {- "success": False,- "errorCode": "addin_restarting",- "error": ("Fusion is running without the Adom add-in and no unsaved "- "document belongs to the user, so the bridge is restarting "- "Fusion itself (documents are saved first). No toast, no ask."),- "restartStarted": _started,- "userUnsaved": _ri.get("userUnsaved") or [],- "_hint": "Poll fusion_readiness; ready flips true in ~60-90s, then retry this verb.",- }- return {- "success": False,- "error": ("Fusion 360 is running but the AdomBridge add-in is not responding "- "- it was installed or updated while Fusion was already open, and "- "Fusion only loads add-ins at launch."),- "errorCode": "addin_restart_required",- "hostResponding": True,- "restartRequired": True,- "userMessage": _ri["userMessage"],- "openDocuments": _ri.get("openDocuments"),- "unsavedDocuments": _ri.get("unsavedDocuments"),- "documentsKnown": _ri.get("documentsKnown"),- "remedyVerb": "fusion_restart_for_addin",- "toast": _ri.get("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.)"),- }+ _pre = _addin_preamble(command, args)+ if _pre is not None:+ return _pre # ── Busy gate: reject add-in commands immediately if a long command is running ── # Bridge-level commands (COMMAND_HANDLERS) already returned above — they use
tools/release.sh+2−6@@ -63,10 +63,6 @@ echo "== LOCKSTEP RELEASE $VERSION ==" # --- 1. preflight gates (the outage lesson: never publish code that won't import) --------- step "Preflight gates (syntax / shadow / import)"-python3 -c "import ast; ast.parse(open('server.py').read())" || die "server.py syntax error"-python3 tools/shadow_check.py server.py | tail -1 | grep -q "^0 shadowing" || die "shadow_check found shadowing bugs"-python3 -c "import server" >/dev/null 2>&1 || die "server.py fails to IMPORT (would crash-loop the bridge)"-python3 -c "import fusion_detect" >/dev/null 2>&1 || die "fusion_detect.py fails to import" echo " gates OK" # ⚠ LIVE-WORK GUARD (John, 2026-08-23: "that was really rude"): bridge_install makes AD REAP@@ -119,12 +115,12 @@ import json, subprocess, base64 script = r""" $all = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine $f = $all | Where-Object { $_.Name -like "Fusion360*" } | Select-Object -First 1-$bridgeAlive = [bool]($all | Where-Object { $_.CommandLine -like "*server.py*fusion*" })+$bridgeAlive = [bool]($all | Where-Object { $_.CommandLine -like "*adom-fusion-bridge.exe*" }) $parented = $false if ($f) { $p = $f for ($i=0; $i -lt 8 -and $p; $i++) {- if ($p.CommandLine -like "*server.py*fusion*") { $parented = $true; break }+ if ($p.CommandLine -like "*adom-fusion-bridge.exe*") { $parented = $true; break } $p = $all | Where-Object { $_.ProcessId -eq $p.ParentProcessId } } }