app
fusion - the Fusion 360 bridge (macOS)
Public Made by Adomby adom
This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Desktop loads the bridge runtime itself from the release zip.
← Commit history
Publish 1.8.5
41 files changed
+1131−2433
BRIDGE_VERSION+1−1CANONICAL.md+79README.md+376−57SKILL.md+7−7ad_client.py+15−24addin/AdomBridge/commands/assembly_bom.py+1−1addin/AdomBridge/commands/cloud_documents.py+1−1addin/AdomBridge/commands/electronics.py+12−11addin/AdomBridge/commands/export.py+1−1addin/AdomBridge/commands/import_file.py+1−1addin/AdomBridge/commands/manufacturing.py+10−10addin/AdomBridge/commands/open_electronics_file.py+13−28addin/AdomBridge/commands/open_lbr.py+1−1addin/AdomBridge/commands/silkscreen_capture.py+1−1addin/AdomBridge/http_server.py+1adom-bridge-fusion-manifest.json+12−16aps.py+4−8bridge.json+7−16describe.py+30−30eagle_lbr.py+2−1fusion_detect.py+178−636handlers/close_fusion.py+47−123handlers/dialog_classify.py+29−64handlers/dismiss_recovery.py+57−200handlers/fusion_ui.py+18−768handlers/mac_ui.py+5−5install_addin.py+9−16package.json+2−3page.json+2−2server.py+166−365skills/fusion-aps-signin/SKILL.md+1−1skills/fusion-autodesk-signin/SKILL.md+1−1skills/fusion-bridge-dev/SKILL.md+7skills/fusion-build-parts-library/SKILL.md+4−4skills/fusion-driving/SKILL.md+2−2skills/fusion-eagle-commands/SKILL.md+2−2skills/fusion-electronics/SKILL.md+2−2skills/fusion-libraries/SKILL.md+13−13skills/fusion-molecule-export/SKILL.md+2−2skills/fusion-multipart-libraries/SKILL.md+8−8skills/fusion-onboarding/SKILL.md+1−1BRIDGE_VERSION+1−1@@ -1 +1 @@-1.8.4+1.8.5
CANONICAL.mdadded+79@@ -0,0 +1,79 @@+# ⚠ This is a SEED copy — do NOT edit it as if it were live++The **canonical source** for the Adom Desktop Fusion 360 bridge is **not here**. It lives in+its own cloud-owned, git-backed repo:++- **Wiki (canonical, macOS line):** https://wiki.adom.inc/adom/adom-desktop-fusion-bridge-macos+- **GitHub mirror (fork here):** https://github.com/adom-inc/adom-desktop-fusion-bridge+- Wiki manifest AD updates from: `…/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json`+ (declared in this folder's `bridge.json` as `updateManifestUrl`).++## What this folder is++A **first-run SEED / offline fallback** that ships inside the Adom Desktop NSIS installer.+The moment AD can reach the wiki, **`refresh_bridges` streams the newer published version+into the cache** (`%LOCALAPPDATA%\Adom Desktop\bridges-cache\fusion360\`) and that cache copy+**supersedes** this seed (cache-over-bundled precedence, version-aware — newer wins).++## Why you must NOT hand-edit it++This copy WILL go stale and diverge from the wiki. It is periodically **re-bundled FROM the+canonical wiki/GH repo** — any edit you make here is overwritten on the next re-bundle and can+re-introduce drift that makes the laptop run code that doesn't match the published bridge.+Develop the bridge in the cloud-owned repo, publish it to the wiki, and let AD pull it.++## How updates reach users++1. The cloud thread publishes the bridge to its wiki page (manifest + versioned zip).+2. AD's `refresh_bridges` reads this folder's `updateManifestUrl`, fetches the wiki manifest,+ and — if newer than cached/bundled — downloads + sha256-verifies + extracts it into the cache.+3. On-demand pull:+ `adom-desktop bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/adom-bridge-fusion-manifest.json"}'`++Current seed: **v1.8.4** (mac line, published 2026-07-24 to `adom-desktop-fusion-bridge-macos`).+1.8.4 adds John's two new skills — `fusion-mcp-and-preferences` (with a macOS-enable-path note;+the Windows page's ROOT SKILL.md was accidentally clobbered by this skill's content, flagged to+John) and `fusion-web-export` (platform-agnostic APS/pup cloud export) — and restores the root+`SKILL.md` + full `skills/` tree to the runtime zip so AD's `verify_bundle` seed-gate passes+without patching in the 1.7.8 copy. The hero is now page-asset-only (out of the pkg tarball).+Base was **v1.8.3** (refreshed 2026-07-24 from the published release), plus the+macOS port carried on top: `handlers/mac_ui.py` (CGWindowList + screencapture + CGEvent ++System Events implementations of the window-automation verbs) and darwin branches in+`fusion_ui.py` / `close_fusion.py` / `dismiss_recovery.py` / `dialog_classify.py` /+`fusion_detect.py` / `install_addin.py` / `server.py` (prefixed-verb shim, /status OS gate).+Until the canonical repo absorbs the mac port, re-seeding from the wiki wholesale will+DROP it — re-apply these files or wait for the upstream merge.++**1.8.3 brought John's new Windows work — what ported cleanly vs. what needs mac follow-up:**+- ✅ Cross-platform, works on mac as-is: the **Autodesk MCP client verbs**+ (`fusion_mcp_status`/`_tools`/`_call`/`_resources`) — pure loopback HTTP to Fusion's+ local MCP server on `127.0.0.1:27182`; the new add-in commands `assembly_bom` ++ `physical_properties` (run in-process via the add-in); `describe.py` / `cloud_documents.py`+ updates. The upstream docked-panel **aspect guard** (tall+narrow windows aren't dialogs)+ was also ported into `mac_ui.find_dialog_windows`.+- ⚠️ Windows-only for now (degraded to an honest manual hint on darwin):+ `fusion_mcp_enable` / `fusion_prefs_open` / `fusion_prefs_close`. They drive Fusion's+ Preferences UI via `Commands.Start PreferencesCommand` (no-op on mac; mac opens prefs with+ Cmd+, or the avatar menu), Windows-measured section coordinates (mac's Preferences is a+ FLAT section list — no nested "API" row), and AD's `desktop_screenshot_window` (which on+ this mac returns `Failed to spawn screencapture` — an AD-core mac bug). The MCP toggle has+ no Autodesk API, so enabling is manual on mac (General → tick "Fusion MCP Server", Apply);+ once on, all `fusion_mcp_*` client verbs work. Full mac automation needs a measured+ Preferences coordinate map + a mac open-path — verified `mac_ui` CAN drive the dialog open+ (avatar click → Preferences → CEF/BitBlt capture), so it's coordinate work, not a blocker.+++## 1.8.5 (pending publish — held for Kyle's go)++**macOS-only strip (Kyle's directive, 2026-07-31):** every Windows code path removed — the bridge+is now a macOS-only codebase. fusion_detect rewritten mac-native (bundle-binary completeness+marker, CGWindowList family/licensing detection); handlers/fusion_ui is a thin re-export of+mac_ui; dismiss_recovery/close_fusion/dialog_classify are mac-only; ad_client discovery is the+~/.adom/direct-api-port handshake only; fusion_helper.py and start.bat deleted; the injected+EPG-locator now globs the real mac path (verified on disk:+`<hash>/Autodesk Fusion.app/Contents/Libraries/Neutron/Api/InternalAddins/ElectronicsPackageGenerator`);+Chrome-history OAuth rescue reads ~/Library/Application Support profiles; all C:/ defaults and+"stage to Windows" doc instructions are now local-path/mac instructions. fusion_install_fusion+honestly reports there is no automated mac installer instead of running the Windows streamer.+bridge.json: killImageName dropped, windows detect paths removed, hero/docs/manifest URLs point at+the macos page.
README.md+376−57@@ -1,67 +1,386 @@-# Adom Desktop - Fusion 360 Bridge (macOS)--> The **macOS build** of the [Fusion 360 bridge](https://wiki.adom.inc/adom/adom-desktop-fusion-bridge).-> Same `fusion_*` verbs, same skills, same AI knowledge as the canonical (Windows) bridge - the-> only difference is the window-automation layer is natively ported to macOS. Published as a-> separate app so the mac line and the Windows line never step on each other's releases.--## Why a separate app--The canonical `adom-desktop-fusion-bridge` page is developed on Windows. Its window-automation-code (screenshots, clicks, keystrokes, dialog handling) is Win32 (`ctypes.windll`, `SendInput`,-`PrintWindow`). This build carries the identical bridge with those primitives reimplemented for-macOS, and lives on its own page so a Windows release can never overwrite the mac line (and vice-versa). Auto-update routing keeps each machine on its own platform's line - see below.--## What is different from the Windows bridge--Everything the add-in does in-process (geometry, exports, parameters, the Autodesk MCP client-verbs, APS cloud search, document ops) is platform-agnostic and identical. The macOS port is the-**window-automation layer** in `handlers/mac_ui.py`:--| Capability | Windows | macOS |-|---|---|---|-| Enumerate windows | Win32 `EnumWindows` | `CGWindowListCopyWindowInfo` (hwnd = CGWindowID) |-| Capture a window | `PrintWindow` / BitBlt | `screencapture -l <windowid>` |-| Click / keys | `SendInput` | `CGEvent` (named keys, unicode, `cmd+` combos) |-| Close / raise a dialog | `WM_CLOSE` / `SetForegroundWindow` | System Events `AXCloseButton` / `AXRaise` |-| Launch / quit / detect | `tasklist` / webdeploy | `pgrep` / `open -g` / `~/Library/Application Support/Autodesk/webdeploy` |--Verbs that are inherently Win32 UI-coordinate driven (`fusion_mcp_enable`, `fusion_prefs_open`,-`fusion_prefs_close`) return an honest manual hint on macOS instead of blind-clicking Windows-coordinates - Fusion's macOS Preferences layout differs (a flat section list). The MCP toggle has-no Autodesk API, so it is enabled manually on mac (General -> tick "Fusion MCP Server", Apply);-once on, every `fusion_mcp_*` client verb works unchanged.--## Auto-update routing (why this does not collide with the Windows page)--The bridge NAME is `fusion360` on both pages, so all verbs and skills transfer. Adom Desktop keys-its auto-update off the **installed copy's** `bridge.json`, not a global name lookup, so a machine-follows whatever line its bundle came from. This build's `bridge.json` declares:--```json-"updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json",-"updateManifestUrls": {- "windows": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/adom-bridge-fusion-manifest.json",- "macos": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json"-}+# Adom Desktop - Fusion 360 Bridge++Drive **Autodesk Fusion 360** from the cloud through [Adom Desktop](https://wiki.adom.inc/adom/adom-desktop):+component libraries with real 3D, parametric IPC package generation with laser-etched markings,+electronics board workflows, manufacturing exports (Gerbers/BOM/CPL), per-manufacturer design rules,+and fast APS cloud-file search - all by asking your AI. Every verb returns a rich `_hint`, so a+calling AI never has to guess.++```+"generate an IPC SOIC-8 with LM358 etched on top and export the STEP" - just ask Claude ``` -A map-aware Adom Desktop picks the `macos` line; older ones fall back to the plain field.+## The demo: one verb, a whole guided tour (`fusion_demo`) -## Install (via Adom Desktop on a Mac)+New to this? Run **one verb** and watch: ```bash-adom-desktop bridge_install '{"manifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json"}'-adom-wiki pkg install adom/adom-desktop-fusion-bridge-macos # the skills pack for your AI+adom-desktop fusion_demo '{}' ``` -On macOS, Adom Desktop is normally hosted by Hydrogen Desktop, which bundles this build directly.+`fusion_demo` takes a first-time user from nothing to a real board. It launches Fusion, **finishes+the Autodesk sign-in**, turns on cloud search, and then walks the actual story of a PCB:++**electronics project -> schematic -> 2D board layout -> 3D board**, screenshotting each view.++It is **staged and resumable**: each call advances as far as it safely can and returns `stage`,+`done`, a `narrate` line written for a newcomer, the `screenshots` to show them, and a `_hint` with+the exact next action. Keep calling it until `done:true`.++Two things it deliberately does NOT do:++- **It never leaves you parked on the sign-in page.** It drives the sign-in through your **native+ browser** (via the Adom browser extension), because that profile is usually already signed into+ Autodesk - one click instead of a password + 2FA. An anonymous automation browser would force a+ full fresh login, so the demo never uses one.+- **It never just name-drops cloud search.** It sets **APS** up for you, then runs a live sample+ search so you watch your whole Autodesk hub return results in about two seconds.++If you decline the cloud-search setup, run `fusion_demo '{"stage":"skip_aps"}'` and the tour+continues without it. To demo a specific design: `fusion_demo '{"query":"BQ25792"}'`.++Playbook for the AI running it: the bundled **fusion-demo** skill.++## Component library manager - the Adom basic parts library++The bridge builds and opens full EAGLE-format component libraries in Fusion's library editor. Below:+the **entire Adom basic parts library** (138 parts - 0402/0603/0805 R/C/L, LEDs, and more) open in+the Content Manager: the component list, the R-0402 deviceset's schematic symbol, footprint + 3D+chip previews, and the footprint/package **Mapped** check EEs look for.++++- `fusion_open_lbr` - open any `.lbr` in the library editor+- `fusion_build_library_3d` - build a whole RENDERING multi-part 3D library in one call+- `fusion_capture_library_views` - capture the symbol / footprint / component view per part+- Get the library: [adom-basic-parts-fusion-lbr](https://wiki.adom.inc/adom/adom-basic-parts-fusion-lbr)++<video controls width="100%" src="https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/AdomBasicParts-138-library.mp4"></video>++++## Generate IPC packages from parameters (ElectronicsPackageGenerator)++Fusion ships a built-in **ElectronicsPackageGenerator** add-in (~50 package families with IPC-7351B+calculators). This bridge drives it **headlessly** via `fusion_generate_package`: parametric 3D+package + laser-etched marking + STEP export, in ONE call, a few seconds per part.++++```bash+adom-desktop fusion_generate_package '{+ "type": "soic",+ "params": {"DPins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "e": 1.27, "A": 1.6, "A1": 0.1, "b": 0.41, "L": 0.84},+ "etch": "LM358\nADOM-A",+ "outputStep": "/tmp/ADOM-LM358-SOIC8.step"+}'+```++++**Why this matters:** the output is OUR generated geometry from open IPC-7351B math - **no vendor+CAD licenses, no redistribution restrictions** - the royalty-free path to a complete component+library. Families: chip (0402/0603/0805...), soic, soj, sot23/143/223, son, qfn, qfp, plcc, bga,+dfn2/3/4, dip (+sockets), dpak, melf, molded, ecap, radial + axial passives, crystals (+HC49),+oscillators, headers (straight/right-angle, socket/SMD), standoffs, chip arrays, LEDs.++**Markings that read like real silkscreen:**+- **Raised WHITE text by default** (a thin positive extrude painted white - the contrast humans can+ actually read; `etchStyle:"engraved"` for a sunken laser cut when a datasheet calls for it).+- The top surface is found from the **whole-chip bounding box**: only upward faces in the **top 5%+ height band** count (terminals tie the body on height), then **largest area** wins.+- Text runs along the **longest axis** of the face (auto-rotated 90° when needed), with a **10%+ margin ENFORCED by measuring** the placed text and shrink-to-fitting - never a font-metric guess.+- **Multi-line**: `"LM358\nADOM-A"` = MPN + variant, per-line auto-fit.+- A **sidecar manifest** (`<step>.manifest.json`) records every setting picked + WHY + the+ calculated bboxes, so a marking refresh (adding a variant line later) reproduces placement exactly.++++The exported STEP is **colored** (AP214): the white marking arrives white in KiCad's 3D viewer.++## Electronics boards: project -> schematic -> 2D board -> 3D board++A Fusion electronics design is a parent/child chain: the **PROJECT** file (open THIS) links the+schematic, the 2D board layout, and the generated 3D PCB. The bridge opens the project and switches+views on command - each `show_*` verb returns a screenshot array so the AI SEES what it switched to.++| View | Verb |+|---|---|+| Project (parent: schematic + 2D + 3D previews) | `fusion_open_cloud_file` / `fusion_aps_open` (the project file) |+| Schematic | `fusion_show_schematic` |+| 2D board layout | `fusion_show_2d_board` |+| 3D board | `fusion_show_3d_board` (orbitable via `fusion_run_modeling_script`) |++Real Adom boards driven through it - the DRV8411A copper layout in the 2D editor, and the TI+DRV8323SR + ST VL53L8 boards rendered as populated 3D PCBs:++++++++Always open the PROJECT - opening a child (.brd / 3D) directly gives an isolated view. The open+verbs reprimand via `_hint` if you open a non-project file.++## Manufacturing exports: Gerbers, BOM, CPL++One verb each, straight from the open board:++```bash+adom-desktop fusion_export_gerbers '{"outputPath": "/tmp/gerbers"}' # JLCPCB-ready CAM job+adom-desktop fusion_export_bom '{"outputPath": "/tmp/bom.csv"}' # grouped by value+package+adom-desktop fusion_export_cpl '{"outputPath": "/tmp/cpl.csv"}' # pick-and-place centroids+```++Plus mechanical exports: STEP / IGES / STL / 3MF / USDZ / OBJ / DXF / DWG. Long exports return+promptly with `statusVerb: fusion_get_app_state` - the AI polls instead of blocking.++## Design rules per manufacturer (JLCPCB 2-layer, 4-layer, ...)++The bridge ships manufacturer rule files + CAM jobs in `resources/` and loads them into the open+board, so DRC matches what the fab actually checks:++```bash+adom-desktop fusion_load_design_rules '{"ruleFile": "JLCPCB-2layer.dru"}'+adom-desktop fusion_set_design_rules '{"rules": {"minClearance": 0.127}}' # ad-hoc tweaks+```++Included: JLCPCB 2-layer + 4-layer DRU rules, the matching Gerber CAM jobs (used by+`fusion_export_gerbers`), and Adom's house rules.++## How the bridge talks to Fusion (architecture)++++The bridge picks the right channel per task:++1. **Python bridge server** (`server.py`) - spawned by Adom Desktop, receives every `fusion_*` verb.+2. **AdomBridge add-in** - installed BY the bridge into every Fusion add-in location+ (`%APPDATA%\Autodesk\FusionAddins` on 2025+ Fusion, plus the legacy `API\AddIns` dirs - Fusion+ moved this across versions and silently ignores the others). It serves HTTP :8774 inside Fusion+ and marshals work onto Fusion's single main thread via CustomEvent.+3. **In-Fusion channels**: the `adsk` Python API for data/geometry, `executeTextCommand` for EAGLE+ editor commands (EDIT / ADD / WINDOW FIT), `Electron.run` for electronics-UI JS+ (zoom/pan/select), and `run_modeling_script` for free-form CAD (this is how EPG packages and+ laser markings are generated).+4. **Win32 / UIA side channel** - when the API can't reach it: background hwnd screenshots, WM_CLOSE+ dialog dismissal (no focus steal), SendInput keys/clicks, and AD core verbs+ (`desktop_find_control` / `desktop_ui_click`).+5. **APS cloud** (`aps.py`) - fast server-indexed search + file ops at scale (below).++## Dialog awareness: parent/child screenshots (the automation breakthrough)++Fusion's classic failure mode is a **modal dialog** (save prompt, recovery, licensing) silently+blocking the add-in's main thread while the caller sees only a fake "not responding". The+breakthrough: Adom Desktop's window capture returns a window's **owned child popups** - dialogs that+don't even enumerate in a normal window list - so every mutating verb returns a **classified dialog+array + screenshots** and the AI can never fly blind past one.++++- Benign launch errors (an incomplete webdeploy folder) are **auto-dismissed in the background**+ (WM_CLOSE - never a focus-stealing Escape).+- DECISION dialogs - like the seat conflict above, whose options suspend Fusion on the user's OTHER+ machine - are **never auto-picked**: the bridge returns `needsUserDecision:true` and the AI+ notifies + asks the user.+- `fusion_check_dialogs` polls the dialog array on demand; `fusion_dismiss_blocking_dialogs` clears+ the known-safe ones.++## Fast cloud search + file management (APS) - the ONLY cloud-search path++`fusion_aps_search` finds any design in the team's Fusion cloud in **~2 seconds** (server-indexed),+plus browse / recent / file-info / versions / upload / download / folder creation, and+`fusion_aps_open` opens any design by name at any folder depth.++### Why APS (and why the old in-app search is now DISABLED)++Autodesk offers **no fast file-search API** for Fusion. The only in-app option walks the cloud+folder tree from the add-in, which takes **30+ minutes** and can **crash Fusion** (WinError 10054).+We searched far and wide; **APS (Autodesk Platform Services) Data Management is the only thing+Autodesk offers that actually works.** So as of **v1.6.76** the old verbs+(`fusion_search_cloud_files`, `fusion_walk_cloud_tree`) are hard-disabled in code - they return+an error that redirects to APS and can no longer reach (or crash) Fusion. There is no fallback;+APS is the path. It costs a one-time OAuth sign-in, and once set up it is fast and stays signed in.++### Authentication (one-time, then silent)++APS uses Autodesk's official OAuth (PKCE). Setup is **once per company** (an admin registers the+APS app, `fusion_aps_set_client_id`), then **each user signs in once** (`fusion_aps_signin`);+tokens refresh silently afterward. Per-org by design: each company uses its own APS app and quota.+**You are never charged** - the bridge caps all calls to Autodesk's free tier.++The bridge **checks APS state in code on every cloud call** (`_aps_guarded`) and returns a rich,+self-teaching `_hint` (is it configured? signed in? token live? the full verb list, and the exact+next step) so the AI is never stuck on an opaque auth error.++### Best sign-in: your NATIVE Chrome/Edge via ABE (adom-browser-extension)++Autodesk's email OTP / 2FA and "which profile is signed in" headaches vanish if the AI drives the+browser you're **already logged into Autodesk** with. That is what **ABE+([adom-browser-extension](https://wiki.adom.inc/adom/adom-browser-extension))** provides. If ABE+isn't installed, the AI will walk you through the one-time install, then open the Autodesk auth URL+in your real profile and capture the token - **no password typing**. Fallback:+`fusion_aps_signin {allowDefaultBrowser:true}` (OS default browser).++### APS verbs++| Verb | Does |+|---|---|+| `fusion_aps_status` | configured? signed in? token live? + never-charge meter |+| `fusion_aps_signin` | one-time OAuth sign-in (prefer ABE native browser) |+| `fusion_aps_search {query}` | server-indexed name search across the whole team hub |+| `fusion_aps_open {query}` | find + open a design in Fusion, any folder depth |+| `fusion_aps_browse` / `fusion_aps_recent` | walk hubs/projects/folders, newest-modified designs |+| `fusion_aps_file_info` / `fusion_aps_versions` | version history / who / when / size |+| `fusion_aps_download` / `fusion_aps_upload` | pull/push a cloud file (no Fusion needed) |+| `fusion_aps_create_folder` | make a subfolder (never dump loose files in a shared root) |+| `fusion_aps_set_browser` / `get_browser` / `forget_browser` | remember the native browser+profile |+| `fusion_aps_set_client_id` | set the org's PKCE client id (once per company) |+| ~~`fusion_search_cloud_files`~~ / ~~`fusion_walk_cloud_tree`~~ | DISABLED (crashed Fusion) - use `fusion_aps_search` |++**Cloud hygiene:** the bridge NEVER writes to a shared project root - uploads default to an+**Adom AI Workspace** folder with per-task subfolders, and `fusion_cleanup_cloud_files` deletes+precisely by lineage URN (never by name).++## Installing Fusion for the user (or finding theirs)++The AI does the whole setup - the user never gets handed a download link:++- **Detection first:** AD detects an existing Fusion via this bridge's `detect` paths - per-user+ (`%LOCALAPPDATA%`) AND system-wide (`%ProgramFiles%`, from `--globalinstall`) webdeploy installs.+ `fusion_readiness` / AD's `bridge_readiness` answer installed/running/ready without launching+ anything.+- **AI-driven install:** if Fusion is absent, the AI OFFERS to install it and does it on a yes - the+ fusion-onboarding skill silent-installs the **free trial / personal-use tier** (plenty for viewing+ and real work), drives the Autodesk sign-in (the user types only their own credentials/2FA), and+ deploys the add-in. A **view-only trial** install is a first-class option for users who just need+ to open and inspect designs.+- **Never a manual step:** the bridge restarts Fusion itself (`fusion_stop`/`fusion_start`),+ installs the add-in to every add-in directory Fusion has ever used, and fires an AD toast the+ moment a step genuinely needs the user (sign-in, 2FA, license acceptance).++## Full verb catalog++All verbs return a rich `_hint`. `fusion_describe '{}'` is the machine-readable source of truth; this table is generated from it.+_55 verbs._++**Lifecycle**++- `fusion_start` - Launch Fusion 360 (if needed) and wait for the AdomBridge add-in to be ready.+- `fusion_stop` - Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait+- `fusion_kill` - Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged proc+- `fusion_close_window` - Close the active Fusion document/window.+- `fusion_get_app_state` - Current app state: running, active document, workspace, isElectronics+- `fusion_readiness` - FAST readiness check (does NOT launch Fusion): is the Fusion host app installed + running + the bridge ready to drive? A+- `fusion_new_electronics_from_eagle` - Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design so the parts INSTANTIATE (schematic + po+- `fusion_notify_owner` - LAST-RESORT human escalation: toast the user's MAIN computer (fans out cross-AD to every peer on the relay, so it reache++**Cloud search & files (APS)**++- `fusion_aps_status` - APS state: configured, signed in, token live, never-charge meter.+- `fusion_aps_set_client_id` - Set the org's APS (PKCE) client id used for cloud search.+- `fusion_aps_signin` - Open the Autodesk sign-in in the REMEMBERED browser+profile (not the OS default); a background listener captures the tok+- `fusion_aps_set_browser` - Remember the browser+profile that authed to Autodesk so every later sign-in reuses it (power users have many browsers/pr+- `fusion_aps_get_browser` - Show the remembered Autodesk sign-in browser (None until set).+- `fusion_aps_forget_browser` - Forget the remembered Autodesk sign-in browser (re-detect next time).+- `fusion_aps_search` - Server-indexed search across the whole team hub (seconds, not a 30-min folder walk).+- `fusion_aps_open` - Find a cloud design by name and open it in Fusion (any folder depth)+- `fusion_open_by_urn` - Open a cloud ELECTRONICS DESIGN by URN+- `fusion_aps_browse` - Browse hubs/projects/folders without launching Fusion.+- `fusion_aps_recent` - Most-recently-modified designs across the team hub, newest first.+- `fusion_aps_file_info` - Version history + who/when/size for a file (tip + recent versions).+- `fusion_aps_versions` - Full (uncapped) version history of a file.+- `fusion_aps_download` - Download a cloud file to the local machine (no Fusion needed).+- `fusion_aps_upload` - Upload a local file as a NEW cloud file (free Data Management; needs data:write scope)+- `fusion_aps_create_folder` - Create a subfolder in a project (free; needs data:create scope)+- `fusion_aps_get` - Raw authenticated GET against a free APS path (dev/verify helper).+- ~~`fusion_walk_cloud_tree`~~ - DISABLED (returns an error): the in-app add-in walk took 30+ min and CRASHED Fusion (WinError 10054)+- ~~`fusion_search_cloud_files`~~ - DISABLED (returns an error): the in-app add-in search took 30+ min and CRASHED Fusion (WinError 10054)++**Electronics**++- `fusion_show_schematic` - Switch the OPEN electronics design to its SCHEMATIC view (the symmetric partner of fusion_show_2d_board / fusion_show_3d+- `fusion_show_2d_board` - Switch the OPEN electronics design to its 2D PCB Editor (board layout) view+- `fusion_show_3d_board` - Switch the OPEN electronics design to its 3D PCB view (populated board)+- `fusion_board_info` - Raw board geometry XML (copper/arcs/lines) for the open board.+- `fusion_open_lbr` - Import an EAGLE .lbr library into Fusion's Electronics LIBRARY editor (Content Manager / Electronics Library workspace),+- `fusion_attach_3d_package` - Attach a real 3D chip model to a library package, end to end: opens the .lbr (library active), runs Electron.Create3DPac+- `fusion_install_fusion` - Install Fusion 360 FOR the user - downloads Autodesk's official Client Downloader and streams the free trial silently, w+- `fusion_generate_package` - Generate an IPC-7351-compliant parametric 3D package via Fusion's BUILT-IN ElectronicsPackageGenerator (EPG) - ~50 packa+- `fusion_make_3d_package` - Create a RENDERING component 3D package (FOOTPRINT + chip, aligned) for ONE part, fully programmatically with ZERO GUI d+- `fusion_build_library_3d` - Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline+- `fusion_capture_library_views` - Capture the LIBRARY-EDITOR views EEs trust: the schematic SYMBOL (full pinout), the FOOTPRINT (pads + layer stack), and +- `fusion_cleanup_cloud_files` - PRECISELY delete a list of cloud files by lineage urn - SAFE cleanup of AI-created clutter+- `fusion_run_modeling_script` - Run an adsk.fusion modeling script in the live session (sketches/extrudes/params)+- `fusion_set_preference` - Set Fusion appearance/navigation preferences in the LIVE session - applies immediately, NO restart+- `fusion_get_preferences` - Read current Fusion appearance/navigation preferences (theme, activeTheme, invertScrollZoom, orbitScheme, modelingOrient++**CAD & 3D export**++- `fusion_export_step` - Export the active design to STEP (needs the 3D view).++**Manufacturing**++- `fusion_export_gerbers` - Run the bundled JLCPCB CAM job (auto 2/4-layer) to produce the gerber + drill ZIP.+- `fusion_export_bom` - Export a Bill of Materials CSV (grouped by value+package) from the open board.+- `fusion_export_cpl` - Export the Component Placement List (pick-and-place) CSV.+- `fusion_set_design_rules` - Load manufacturing-grade DRC into the open board (JLCPCB 2/4-layer profiles).+- `fusion_load_design_rules` - Load a bundled .edru design-rule set into the board.+- `fusion_detect_layers` - Detect 2-layer vs 4-layer board (ULP + CAM comparison).+- `fusion_dismiss_blocking_dialogs` - Detect + classify (and optionally dismiss) blocking Fusion dialogs.+- `fusion_electron_run` - Run an EAGLE/Electron command in the open board (the generic extension point, e.g+- `fusion_electron_zoom` - Smoothly ZOOM the schematic/2D board for a recording (the whole motion is one call)+- `fusion_electron_pan` - Smoothly PAN + zoom the view to frame a board-coordinate box (mm; get part positions from fusion_board_info)+- `fusion_electron_select` - SELECT a part (by reference designator, or x/y board mm) and surface its properties - the way clicking a part reveals th++**Self**++- `fusion_describe` - Self-describe every verb the bridge exposes (this list) for AD's Verbs tab + runner.++## Two install layers (they are NOT the same thing)++This page ships **two separate artifacts**, and it matters which one you want:++| Layer | What it is | Who installs it | Contents | Size |+|---|---|---|---|---|+| **Wiki package** (`adom-wiki pkg install adom/adom-desktop-fusion-bridge`) | **SKILLS ONLY** for a container's AI | your cloud/HD container | `SKILL.md` + `skills/**` (the AI playbooks: APS, driving, onboarding, cloud hygiene, molecule export, PCB stackup) | tiny, text |+| **Release zip** (`adom-bridge-fusion-v<ver>.zip`, pointed at by `adom-bridge-fusion-manifest.json`) | the **bridge RUNTIME** | Adom Desktop, via `bridge_install` | `server.py`, `aps.py`, `describe.py`, `handlers/`, the `AdomBridge` Fusion add-in, `resources/` | ~456 KB |++The package teaches your AI **how** to drive Fusion; the release zip is the code Adom Desktop actually+runs on the machine where Fusion lives. Installing the package alone gives you skills with no bridge;+`bridge_install` alone gives you a working bridge whose calling AI has no skills. Most setups want both.++The release zip is deliberately **runtime-only**: demo videos and screenshots live on this page (Files+tab), never inside the artifact streamed to every user's machine.++## Install (via Adom Desktop)++```bash+adom-desktop bridge_install '{"manifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/adom-bridge-fusion-manifest.json"}'+adom-wiki pkg install adom/adom-desktop-fusion-bridge # the skills pack for your AI+```++Auto-updates via `updateManifestUrl` (AD polls on launch + every 4h). 55 verbs -+`fusion_describe '{}'` returns the full machine-readable catalog. More demos:++<video controls width="100%" src="https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/library2-3d-montage.mp4"></video>++## Source code++The full source lives on this page (Files tab): `server.py`, `aps.py`, the AdomBridge add-in,+`describe.py`, handlers, skills, and manufacturer resources. GitHub mirror:+`adom-inc/adom-desktop-fusion-bridge` (the wiki is canonical). MIT licensed.++## Platforms -## Full documentation+macOS-only (this line). Install detection in `fusion_detect.py`. Linux: not+supported (Autodesk ships no Fusion host app for Linux). -The complete feature guides (APS cloud search, schematics, board layout, 3D export, library-manager, the Autodesk MCP server, sign-in) live on the canonical page and apply verbatim:-**[adom-desktop-fusion-bridge](https://wiki.adom.inc/adom/adom-desktop-fusion-bridge)**.+## Contributing -MIT licensed. Source is developed on the `hydrogen-desktop` macOS branch and published here.+Open a discussion on this page - bug reports with a repro (like+[#55](https://wiki.adom.inc/adom/adom-desktop-fusion-bridge/discussions/55) and+[#63](https://wiki.adom.inc/adom/adom-desktop-fusion-bridge/discussions/63), both root-caused and+fixed) directly shape releases.
SKILL.md+7−7@@ -228,7 +228,7 @@ Real-world live test: `Main/Molecules` recursive depth-10 cosmiic search hit 173 adom-desktop fusion_start '{}' # Open a local design-adom-desktop fusion_open_design '{"path":"C:/designs/board.f3d"}'+adom-desktop fusion_open_design '{"path":"/Users/<user>/designs/board.f3d"}' # Screenshot all Fusion windows adom-desktop fusion_screenshot_all '{}'@@ -236,7 +236,7 @@ adom-desktop fusion_screenshot_all '{}' # Run a Fusion API command (add-in required) adom-desktop fusion_command '{ "command":"export_step",- "args":{"occurrence":"body1","output":"C:/tmp/part.step"}+ "args":{"occurrence":"body1","output":"/tmp/part.step"} }' # Check add-in health@@ -288,12 +288,12 @@ also install the pkg itself; then the skills **auto-update** via the pkg mechani The repo ships a ready sample so you can prove the bridge end to end right after installing: `examples/ADS8588SIPM.fusion.lbr` (a real 4-sided LQFP-64 library) + `examples/ADS8588SIPM.step` (its 3D). -1. Download both from this page's Files tab (or `adom-wiki repo clone`), then stage them onto Windows- (serve + `Invoke-WebRequest`, since there is no container->Windows push verb).-2. **Import demo:** `fusion_open_lbr {"filePath":"C:/.../ADS8588SIPM.fusion.lbr","verify":true}` -> the+1. Download both from this page's Files tab (or `adom-wiki repo clone`) onto this machine+ (send_files if they were produced elsewhere).+2. **Import demo:** `fusion_open_lbr {"filePath":"/path/to/ADS8588SIPM.fusion.lbr","verify":true}` -> the symbol + 4-sided footprint render in the Electronics Library editor.-3. **3D-attach demo:** `fusion_attach_3d_package {"filePath":"C:/.../ADS8588SIPM.fusion.lbr",- "modelPath":"C:/.../ADS8588SIPM.step","packageName":"ADS8588SIPM"}` -> click the Save dialog per the+3. **3D-attach demo:** `fusion_attach_3d_package {"filePath":"/path/to/ADS8588SIPM.fusion.lbr",+ "modelPath":"/path/to/ADS8588SIPM.step","packageName":"ADS8588SIPM"}` -> click the Save dialog per the returned hint -> the real chip binds onto the footprint (Content Manager shows the 3D under the package). See the **`fusion-libraries`** skill's `MAKING_LIBRARIES.md` for the full walkthrough + screenshots.
ad_client.py+15−24@@ -59,34 +59,25 @@ def _looks_like_ad_api(data) -> bool: def _discover_direct_api():- """Find AD's loopback direct-API base by port-probing, for the case where AD did NOT inject- ADOM_DIRECT_API_URL into our spawn env (seen live on an AD 1.9.103 Hyper-V VM: the direct API- WAS up on 127.0.0.1 but the env var was absent, so available() short-circuited to False and the- bridge fell back to slow CLI relays - John caught it 2026-07-14). We enumerate the loopback TCP- ports adom-desktop.exe is LISTENING on and probe each `/commands`; the one that returns AD's verb- catalog is the direct API. Cached for the process lifetime. Windows-only + best-effort; never raises."""+ """Find AD's loopback direct-API base when AD did NOT inject ADOM_DIRECT_API_URL into our+ spawn env (seen live on AD 1.9.103: the direct API WAS up on 127.0.0.1 but the env var was+ absent, so available() short-circuited to False and the bridge fell back to slow CLI relays -+ John caught it 2026-07-14).++ Discovery is the port file AD publishes at ~/.adom/direct-api-port — a published-endpoint+ handshake, never a process/port walk. A port file can be stale (we probe /commands, so we+ find out), but it can't be invisible to a thin PATH and it doesn't care where the .app+ bundle put things. Cached SUCCESS-only for the process lifetime; best-effort; never raises.""" global _discovered_base if _discovered_base: # cache SUCCESS only; a transient miss must not stick forever return _discovered_base+ try:- import subprocess as _sp, re as _re- cnw = getattr(_sp, "CREATE_NO_WINDOW", 0)- tl = _sp.run(["tasklist", "/FI", "IMAGENAME eq adom-desktop.exe", "/FO", "CSV", "/NH"],- capture_output=True, text=True, timeout=8, creationflags=cnw).stdout- pids = set(_re.findall(r'"adom-desktop\.exe","(\d+)"', tl))- if not pids:- return None- ns = _sp.run(["netstat", "-ano", "-p", "TCP"], capture_output=True, text=True,- timeout=8, creationflags=cnw).stdout- seen = []- for line in ns.splitlines():- m = _re.search(r'\s(?:127\.0\.0\.1|\[::1\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)', line)- if m and m.group(2) in pids:- p = int(m.group(1))- if p not in seen:- seen.append(p)- for p in seen:- base = f"http://127.0.0.1:{p}"+ pf = os.path.join(os.path.expanduser("~"), ".adom", "direct-api-port")+ raw = open(pf, encoding="utf-8").read().strip() if os.path.exists(pf) else ""+ if raw:+ hostport = raw.split("//")[-1].strip().strip("/")+ base = hostport if hostport.startswith("http") else "http://" + hostport if _looks_like_ad_api(_raw_get(base + "/commands")): _discovered_base = base return base
addin/AdomBridge/commands/assembly_bom.py+1−1@@ -44,7 +44,7 @@ def handle_assembly_bom(app: adsk.core.Application, args: dict) -> dict: and NOT exploded (default: ["with fasteners"]). exclude optional list of top-level component-name prefixes to skip entirely. includePhysicalProperties optional bool -- add volume_cm3 / mass_kg per line.- outputPath optional str -- also write a CSV to this Windows path.+ outputPath optional str -- also write a CSV to this local path. """ design = adsk.fusion.Design.cast(app.activeProduct) if not design:
addin/AdomBridge/commands/cloud_documents.py+1−1@@ -1025,7 +1025,7 @@ def handle_search_cloud_files(app: adsk.core.Application, args: dict) -> dict: SAFETY (v1.0.1 — May 2026): The previous implementation had thin defenses and could freeze Fusion's main thread for long enough that- Windows marked the window "Not Responding" — users perceived this as+ the OS marked the window unresponsive (beachball) — users perceived this as a crash. The fixes: - Per-folder wall-clock timeout (FOLDER_TIMEOUT = 30s). If a single
addin/AdomBridge/commands/electronics.py+12−11@@ -30,6 +30,7 @@ Safe automation patterns: EDIT <existing>.sym — Opens existing symbol (no dialog) SET CONFIRM YES — In .scr: suppresses create-new confirmations """+from __future__ import annotations # PEP-604 hints must parse on py3.9 (mac stock) import adsk.core import glob@@ -41,7 +42,7 @@ import tempfile def _get_fusion_log_path() -> str | None: """Find the latest Fusion 360 log file.""" log_dir = os.path.join(- os.environ.get("LOCALAPPDATA", ""),+ os.path.expanduser("~"), "Library", "Application Support", "Autodesk", "Autodesk Fusion 360", ) # Find user-specific log dir (e.g. X3G293QFLPJERKGZ/logs/)@@ -94,9 +95,9 @@ def _extract_export_path(eagle_cmd: str) -> str | None: """Extract the output file path from an EAGLE EXPORT command. EAGLE EXPORT commands use single-quoted paths:- EXPORT NETLIST 'C:/tmp/netlist.txt'- EXPORT IMAGE 'C:/tmp/board.png' 300- EXPORT PARTLIST 'C:/tmp/parts.txt'+ EXPORT NETLIST '/tmp/netlist.txt'+ EXPORT IMAGE '/tmp/board.png' 300+ EXPORT PARTLIST '/tmp/parts.txt' Returns the unquoted path, or None if not found. """@@ -372,8 +373,8 @@ def handle_electron_run(app: adsk.core.Application, args: dict) -> dict: "error": "Command 'WRITE' is blocked — it opens a blocking 'Version Description' save dialog " "on cloud documents, even when a path argument is provided (tested 2026-04-10). " "Use fusion_export_source for .fsch/.fbrd export, or fusion_export_eagle_source for .sch/.brd.",- "_hint": "For Fusion-format source: fusion_export_source {\"outputPath\":\"C:/out/file.fbrd\"}. "- "For EAGLE-format source: fusion_export_eagle_source {\"outputPath\":\"C:/out/file.brd\"}.",+ "_hint": "For Fusion-format source: fusion_export_source {\"outputPath\":\"/tmp/out/file.fbrd\"}. "+ "For EAGLE-format source: fusion_export_eagle_source {\"outputPath\":\"/tmp/out/file.brd\"}.", "data": { "command": eagle_cmd, "blockedVerb": "WRITE",@@ -564,7 +565,7 @@ def handle_open_electronics(app: adsk.core.Application, args: dict) -> dict: return { "success": False, "error": f"Failed to open {file_path}: {e}",- "_hint": "Verify the file exists on the Windows host, then try fusion_open_schematic or fusion_open_board with the correct filePath.",+ "_hint": "Verify the file exists on this machine, then try fusion_open_schematic or fusion_open_board with the correct filePath.", } return { "success": True,@@ -591,15 +592,15 @@ def handle_export_source(app: adsk.core.Application, args: dict) -> dict: To export .fbrd (board): 1. Open the .fprj: fusion_open_cloud_file {"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", ...} 2. Enter board view: fusion_show_2d_board- 3. Export: fusion_export_source {"outputPath": "C:/out/MyDesign.fbrd"}+ 3. Export: fusion_export_source {"outputPath": "/tmp/out/MyDesign.fbrd"} To export .fsch (schematic): 4. Switch to schematic: fusion_electron_run {"command": "EDIT .s1"}- 5. Export: fusion_export_source {"outputPath": "C:/out/MyDesign.fsch"}+ 5. Export: fusion_export_source {"outputPath": "/tmp/out/MyDesign.fsch"} To export .flbr (library):- 1. Open the .lbr: fusion_open_lbr {"filePath": "C:/path/to/lib.lbr"}- 2. Export: fusion_export_source {"outputPath": "C:/out/MyLib.flbr"}+ 1. Open the .lbr: fusion_open_lbr {"filePath": "/path/to/lib.lbr"}+ 2. Export: fusion_export_source {"outputPath": "/tmp/out/MyLib.flbr"} IMPORTANT GOTCHAS: - You MUST open the .fprj file first, NOT .fbrd/.fsch directly
addin/AdomBridge/commands/export.py+1−1@@ -27,7 +27,7 @@ _HINT_EXPORT_FAILED = ( "Wrong-workspace errors: call fusion_show_3d_board first." ) _HINT_FILE_LOCKED = (- "Close the file in any viewer (Blender, Windows Explorer preview, etc.), "+ "Close the file in any viewer (Blender, a Finder Quick Look preview, etc.), " "or call fusion_close_document first if it's open in Fusion, then retry." )
addin/AdomBridge/commands/import_file.py+1−1@@ -34,7 +34,7 @@ def handle_import_file(app: adsk.core.Application, args: dict) -> dict: return { "success": False, "error": f"File not found: {file_path}",- "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",+ "_hint": "The path may exist remotely but not on this machine. Use push_file to copy it here first, or verify the local path.", } ext = path.suffix.lower()
addin/AdomBridge/commands/manufacturing.py+10−10@@ -165,11 +165,11 @@ def handle_export_bom(app: adsk.core.Application, args: dict) -> dict: 2. boardInfo XML parsing for coordinates and grouping Args:- outputPath: File path for the output CSV (default: C:/tmp/adom-bom.csv)+ outputPath: File path for the output CSV (default: /tmp/adom-bom.csv) grouped: If true (default), group identical parts by value+package. If false, list every individual component. """- output_path = args.get("outputPath", "C:/tmp/adom-bom.csv")+ output_path = args.get("outputPath", "/tmp/adom-bom.csv") grouped = args.get("grouped", True) # Ensure output directory exists@@ -322,10 +322,10 @@ def handle_export_cpl(app: adsk.core.Application, args: dict) -> dict: Coordinates are in millimeters relative to the board origin. Args:- outputPath: File path for the output CSV (default: C:/tmp/adom-cpl.csv)+ outputPath: File path for the output CSV (default: /tmp/adom-cpl.csv) side: Filter by side — "all" (default), "top", or "bottom" """- output_path = args.get("outputPath", "C:/tmp/adom-cpl.csv")+ output_path = args.get("outputPath", "/tmp/adom-cpl.csv") side_filter = args.get("side", "all").lower() os.makedirs(os.path.dirname(output_path), exist_ok=True)@@ -415,7 +415,7 @@ def _get_resources_dir() -> str: """Get the path to the add-in's bundled resources directory. Resources (CAM jobs, DRU files, ULP scripts) are bundled at:- AdomBridge/resources/ (synced to %APPDATA%/.../AdomBridge/resources/)+ AdomBridge/resources/ (synced into the installed add-in dir) """ # manufacturing.py is at: AdomBridge/commands/manufacturing.py # resources are at: AdomBridge/resources/@@ -599,11 +599,11 @@ def handle_export_gerbers(app: adsk.core.Application, args: dict) -> dict: Auto-detects 2-layer vs 4-layer boards and selects the right CAM job. Args:- outputDir: Directory for the output ZIP (default: C:/tmp/adom-gerbers/)+ outputDir: Directory for the output ZIP (default: /tmp/adom-gerbers/) boardName: Prefix for the ZIP filename (default: from active document) layers: Force layer count — "2" or "4" (default: auto-detect) """- output_dir = args.get("outputDir", "C:/tmp/adom-gerbers")+ output_dir = args.get("outputDir", "/tmp/adom-gerbers") board_name = args.get("boardName", "") force_layers = args.get("layers", "auto") @@ -822,7 +822,7 @@ def handle_set_design_rules(app: adsk.core.Application, args: dict) -> dict: } if action == "export":- output_path = args.get("outputPath", "C:/tmp/current-design-rules.dru")+ output_path = args.get("outputPath", "/tmp/current-design-rules.dru") os.makedirs(os.path.dirname(output_path), exist_ok=True) try: app.executeTextCommand(f"Electron.run DRC SAVE '{output_path}'")@@ -983,7 +983,7 @@ def handle_export_board_image(app: adsk.core.Application, args: dict) -> dict: After export, restores the previous layer visibility. Args:- outputPath: File path for the output image (default: C:/tmp/adom-board.png)+ outputPath: File path for the output image (default: /tmp/adom-board.png) dpi: Resolution in dots per inch (default: 300, max: 600) preset: Layer preset name — one of: "all", "top_copper", "bottom_copper", "top_silkscreen",@@ -1004,7 +1004,7 @@ def handle_export_board_image(app: adsk.core.Application, args: dict) -> dict: "data": {"presets": preset_list}, } - output_path = args.get("outputPath", "C:/tmp/adom-board.png")+ output_path = args.get("outputPath", "/tmp/adom-board.png") dpi = min(int(args.get("dpi", 300)), 600) preset = args.get("preset", "all") custom_layers = args.get("layers", None)
addin/AdomBridge/commands/open_electronics_file.py+13−28@@ -24,34 +24,19 @@ def _open_local_document(app, file_path: str): BUG (Drew's agent, issue #196, 2026-07-16): executeTextCommand is a SPACE-DELIMITED command string, so an unquoted path like- C:/Users/drew/Downloads/e2e-pmcoil/PM COIL Molecule.brd+ ~/Downloads/e2e-pmcoil/PM COIL Molecule.brd was split at the first space - Fusion got ".../e2e-pmcoil/PM" and the open silently no-op'd, surfacing only as the generic "Fusion may not support this file" hint. A space-free copy of the same file opened instantly, which is what pinned it to path handling rather than file support. - Two-step, so it works whichever way this Fusion build's parser behaves:- 1. QUOTE the path.- 2. If that fails and the path has spaces, retry with the Windows 8.3 SHORT path- (GetShortPathNameW) - same file, no spaces, no copying.+ QUOTE the path so a space can't split the command argument. Returns (result, how) and raises the LAST exception if every attempt fails. """ attempts = [] quoted = '"%s"' % file_path.replace('"', '') attempts.append((quoted, "quoted"))- if " " in file_path:- try:- import ctypes- from ctypes import wintypes- _gsp = ctypes.windll.kernel32.GetShortPathNameW- _gsp.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]- _gsp.restype = wintypes.DWORD- buf = ctypes.create_unicode_buffer(600)- if _gsp(file_path, buf, 600) and buf.value and " " not in buf.value:- attempts.append((buf.value, "shortpath"))- except Exception:- pass def _doc_name(): try: d = app.activeDocument@@ -84,7 +69,7 @@ def _open_electronics(app: adsk.core.Application, file_path: str, file_type: str "success": False, "error": f"No filePath specified. Provide a {expected_ext} file path.", "data": {- "hint": f"Usage: fusion_open_{file_type.replace(' ', '_')} {{\"filePath\": \"C:/path/to/file{expected_ext}\"}}"+ "hint": f"Usage: fusion_open_{file_type.replace(' ', '_')} {{\"filePath\": \"/path/to/file{expected_ext}\"}}" }, } @@ -94,8 +79,8 @@ def _open_electronics(app: adsk.core.Application, file_path: str, file_type: str return { "success": False, "error": f"File not found: {file_path}",- "_hint": "The path may exist on Docker but not on the Windows host. Verify with desktop_list_directory on the host, or use push_file to copy from Docker first.",- "data": {"filePath": file_path, "hint": "Check that the file path exists on the Windows desktop machine, not on Docker."},+ "_hint": "The path may exist remotely but not on this machine. Verify with desktop_list_directory, or use push_file to copy it here first.",+ "data": {"filePath": file_path, "hint": "Check that the file path exists on this machine."}, } ext = os.path.splitext(file_path)[1].lower()@@ -124,7 +109,7 @@ def _open_electronics(app: adsk.core.Application, file_path: str, file_type: str "data": { "filePath": file_path, "hint": ("PATH WITH SPACES? This verb now quotes the path and falls back to the "- "Windows 8.3 short path, so spaces should work (issue #196). If it still "+ "quoted path, so spaces should work (issue #196). If it still " "fails, verify the file EXISTS at exactly this path and is valid, then try " "a space-free copy to isolate it. Otherwise Fusion may not support this " "file, or it is not fully loaded/responsive."),@@ -196,7 +181,7 @@ def handle_open_schematic(app: adsk.core.Application, args: dict) -> dict: Args: args: Dict with:- - filePath: Path to the .sch file on the Windows desktop (required).+ - filePath: Path to the .sch file on this machine (required). """ return _open_electronics(app, args.get("filePath", ""), "schematic", ".sch") @@ -212,7 +197,7 @@ def handle_open_board(app: adsk.core.Application, args: dict) -> dict: Args: args: Dict with:- - filePath: Path to the .brd file on the Windows desktop (required).+ - filePath: Path to the .brd file on this machine (required). """ return _open_electronics(app, args.get("filePath", ""), "board layout", ".brd") @@ -267,7 +252,7 @@ def handle_show_3d_board(app: adsk.core.Application, args: dict) -> dict: "_hint": "Call fusion_open_board {\"filePath\":\"...\"} to load a board, then retry fusion_show_3d_board.", "data": { "activeWorkspace": ws,- "hint": "You need to open a .brd file first: fusion_open_board {\"filePath\": \"C:/path/to/board.brd\"}",+ "hint": "You need to open a .brd file first: fusion_open_board {\"filePath\": \"/path/to/board.brd\"}", }, } @@ -351,7 +336,7 @@ def handle_import_electronics(app: adsk.core.Application, args: dict) -> dict: Args: args: Dict with:- - filePath: Path to the .fsch, .fbrd, or .flbr file on Windows (required).+ - filePath: Path to the .fsch, .fbrd, or .flbr file on this machine (required). """ file_path = args.get("filePath", "") if not file_path:@@ -359,7 +344,7 @@ def handle_import_electronics(app: adsk.core.Application, args: dict) -> dict: "success": False, "error": "No filePath specified. Provide a .fsch, .fbrd, or .flbr file path.", "data": {- "hint": "Usage: fusion_import_electronics {\"filePath\": \"C:/path/to/file.fsch\"}",+ "hint": "Usage: fusion_import_electronics {\"filePath\": \"/path/to/file.fsch\"}", "supportedExtensions": [".fsch", ".fbrd", ".flbr"], }, }@@ -370,8 +355,8 @@ def handle_import_electronics(app: adsk.core.Application, args: dict) -> dict: return { "success": False, "error": f"File not found: {file_path}",- "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy it from Docker first, or verify the host-side path.",- "data": {"filePath": file_path, "hint": "Check that the file path exists on the Windows desktop machine."},+ "_hint": "The path may exist remotely but not on this machine. Use push_file to copy it here first, or verify the local path.",+ "data": {"filePath": file_path, "hint": "Check that the file path exists on this machine."}, } ext = os.path.splitext(file_path)[1].lower()
addin/AdomBridge/commands/open_lbr.py+1−1@@ -43,7 +43,7 @@ def handle_open_lbr(app: adsk.core.Application, args: dict) -> dict: return { "success": False, "error": f"File not found: {file_path}",- "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker, or verify the host-side path.",+ "_hint": "The path may exist remotely but not on this machine. Use push_file to copy it here first, or verify the local path.", } if not file_path.lower().endswith(".lbr"):
addin/AdomBridge/commands/silkscreen_capture.py+1−1@@ -216,7 +216,7 @@ def handle_take_silkscreen_screenshot(app: adsk.core.Application, args: dict) -> return { "success": False, "error": "outputPath is required",- "_hint": 'Provide {"outputPath": "C:/tmp/silk_top.png", "layer": "top"}',+ "_hint": 'Provide {"outputPath": "/tmp/silk_top.png", "layer": "top"}', } layer = args.get("layer", "").lower().strip()
addin/AdomBridge/http_server.py+1@@ -18,6 +18,7 @@ Threading model: already timed out. The CustomEventHandler also checks staleness BEFORE dispatching, so it can skip heavy work entirely. """+from __future__ import annotations # PEP-604 hints must parse on py3.9 (mac stock) import json import os
adom-bridge-fusion-manifest.json+12−16@@ -1,18 +1,14 @@ {- "manifest_version": 1,- "name": "fusion360",- "version": "1.8.4",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge-macos/1.8.4/adom-bridge-fusion-macos-v1.8.4.zip",- "sha256": "a4f097ffe756c4565d6b20390b9148ec87192bf3defb43438519d77ad021692e",- "size": 440887,- "verbPrefixes": [- "fusion_"- ],- "healthEndpoint": "/status",- "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json",- "updateManifestUrls": {- "windows": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/adom-bridge-fusion-manifest.json",- "macos": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json"- },- "released_at": "2026-07-24T18:00:00Z"+ "manifest_version": 1,+ "name": "fusion360",+ "version": "1.8.5",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge-macos/1.8.5/adom-bridge-fusion-macos-v1.8.5.zip",+ "sha256": "1b70a4cd6d769bcbaf34154ebaa99dd7d3615b1f6ee352de5e9dcaff5471e1e3",+ "size": 411059,+ "verbPrefixes": [+ "fusion_"+ ],+ "healthEndpoint": "/status",+ "released_at": "2026-07-31T21:30:00Z",+ "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json" }
aps.py+4−8@@ -21,6 +21,7 @@ Verbs (CLI fusion_aps_* -> bridge command aps_*): No third-party deps — stdlib only (the bridge must run on stock Python). """+from __future__ import annotations # PEP-604 hints must parse on py3.9 (mac stock) import base64 import hashlib@@ -50,8 +51,7 @@ TOKEN_URL = f"{APS_BASE}/authentication/v2/token" # Fixed callback ports — tried in order at sign-in; the FIRST free one is bound. # All of these (× localhost AND 127.0.0.1) are registered on the APS app, so a # port collision on any user's machine just falls through to the next — no failed-# sign-ins. 8917 avoids AD's own APS on 8910; all verified free + outside Windows'-# reserved/excluded ranges (5357, 27339, 50000-50059) on a real box.+# sign-ins. 8917 avoids AD's own APS on 8910; all verified free on a real box. REDIRECT_PORTS = [8917, 8918, 8920] REDIRECT_PATH = "/fusion-aps/callback" _active_redirect_uri = None # set to the bound port's URI when a sign-in starts@@ -665,11 +665,7 @@ def start_signin(args: dict | None = None) -> dict: webbrowser.open(auth_url) opened_via = "os_default_browser" except Exception:- try:- os.startfile(auth_url) # noqa: Windows- opened_via = "os_default_browser"- except Exception:- opened_via = None+ opened_via = None else: # EXTENSION-FREE open (issue #12, Caleb): we used to stop here and hand back a hint # telling the AI to run nbrowser_open_window - which REQUIRES the ABE extension. On a@@ -825,7 +821,7 @@ def handle_set_browser(args: dict) -> dict: "how": args.get("how") or ("extension" if not exe else "launch")} if not exe and pref["how"] != "extension": return {"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'}"}+ "_hint": "e.g. {name:'Chrome', exe:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', profileDir:'Default'}"} ok = save_signin_browser(pref) return {"success": ok, "output": f"Remembered Autodesk sign-in browser: {pref.get('name') or exe or pref['how']}.", "data": {"saved": ok, "browser": load_signin_browser()}}
bridge.json+7−16@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.8.4",+ "version": "1.8.5", "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/adom-desktop-fusion-bridge-macos", "author": "Adom Inc.",@@ -13,7 +13,6 @@ "port": 0, "healthEndpoint": "/health", "stopMethod": "kill",- "killImageName": "python.exe", "persistent": true }, "verbPrefixes": [@@ -92,33 +91,29 @@ ], "docs": "https://wiki.adom.inc/adom/adom-desktop-fusion-bridge-macos", "platforms": {- "windows": {- "supported": true- }, "macos": { "supported": true, "reason": "Full macOS port: detect/launch/quit, the AdomBridge add-in API, AND the window-automation verbs (screenshot/click/send_key/window_info/dialog dismiss) via CGWindowList + screencapture + CGEvent + System Events." }, "linux": { "supported": false,- "reason": "Autodesk doesn't ship Fusion 360 for Linux \u2014 there's no host app for the add-in to attach to."+ "reason": "Autodesk doesn't ship Fusion 360 for Linux — there's no host app for the add-in to attach to."+ },+ "windows": {+ "supported": false,+ "reason": "This is the macOS-only line. The Windows bridge is the separate adom/adom-desktop-fusion-bridge wiki app." } }, "languages": [ "Python" ],- "hero": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/screenshots/hero.png",+ "hero": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/screenshots/hero.png", "releasedAt": "2026-06-26T06:00:00Z", "statusVerb": "fusion_get_app_state", "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json", "detect": { "hostApp": "Fusion 360", "paths": {- "windows": [- "%LOCALAPPDATA%\\Autodesk\\webdeploy\\production\\*\\FusionLauncher.exe",- "%ProgramFiles%\\Autodesk\\webdeploy\\production\\*\\FusionLauncher.exe",- "%ProgramFiles(x86)%\\Autodesk\\webdeploy\\production\\*\\FusionLauncher.exe"- ], "macos": [ "/Applications/Autodesk Fusion.app", "/Applications/Autodesk Fusion 360.app"@@ -182,9 +177,5 @@ "seconds": 150 } ]- },- "updateManifestUrls": {- "windows": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/adom-bridge-fusion-manifest.json",- "macos": "https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge-macos/files/adom-bridge-fusion-manifest.json" } }
describe.py+30−30@@ -16,7 +16,7 @@ _T = [ # ── Lifecycle ────────────────────────────────────────────────────────── ("fusion_start", "Launch Fusion 360 (if needed) and wait for the AdomBridge add-in to be ready.", {}, {"success": "bool", "addinReady": "bool", "status": "str"}, 150, None, True, {}),- ("fusion_stop", "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait. NO force-kill - the clean opposite of fusion_start. If a window will not close it returns failure; use fusion_kill then.",+ ("fusion_stop", "Gracefully STOP Fusion 360: close docs cleanly + background window close, then wait. NO force-kill - the clean opposite of fusion_start. If a window will not close it returns failure; use fusion_kill then.", {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 60, None, False, {}), ("fusion_kill", "Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). Closes docs cleanly first when reachable.", {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 30, None, False, {}),@@ -27,10 +27,10 @@ _T = [ ("fusion_readiness", "FAST readiness check (does NOT launch Fusion): is the Fusion host app installed + running + the bridge ready to drive? AD DETECTS Fusion (never installs it) via bridge.json 'detect', and auto-installs Python if missing (AD >=1.9.47). Call this before driving Fusion; if installed-but-not-running, fusion_start. SELF-HEALS the seat/licensing dialog (background UIA + screenshot-verify) whenever it detects one. Pairs with AD's bridge_readiness.", {}, {"installed": "bool", "running": "bool", "ready": "bool", "hostApp": "str", "bridgeVersion": "str"}, 15, None, False, {}), - ("fusion_new_electronics_from_eagle", "Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design so the parts INSTANTIATE (schematic + populated board, lands in the PCB editor). The ONLY way to author a board from EAGLE source: Document.newDesignFromLocal opens the schematic editor but does NOT instantiate parts, and the Fusion-native .fsch/.fbrd container is opaque binary you can't build offline. This fires Fusion's own ImportSCHAndBRDCmd and drives BOTH native Open dialogs (the .sch then the .brd) in the BACKGROUND via desktop_ui_click by accessible name (no foreground). Stage both files to Windows first (send_files). After: fusion_show_2d_board, RATSNEST + 'AUTO ;' to autoroute, fusion_show_3d_board for 3D.",- {"schPath": "required str (Windows path to .sch)", "brdPath": "optional str (defaults to sibling .brd)"},+ ("fusion_new_electronics_from_eagle", "Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design so the parts INSTANTIATE (schematic + populated board, lands in the PCB editor). The ONLY way to author a board from EAGLE source: Document.newDesignFromLocal opens the schematic editor but does NOT instantiate parts, and the Fusion-native .fsch/.fbrd container is opaque binary you can't build offline. This fires Fusion's own ImportSCHAndBRDCmd and drives BOTH native Open dialogs (the .sch then the .brd) in the BACKGROUND via desktop_ui_click by accessible name (no foreground). Stage both files onto this machine first (send_files). After: fusion_show_2d_board, RATSNEST + 'AUTO ;' to autoroute, fusion_show_3d_board for 3D.",+ {"schPath": "required str (local path to .sch)", "brdPath": "optional str (defaults to sibling .brd)"}, {"success": "bool", "imported": "list"}, 200, None, False,- {"schPath": "C:/Users/me/adom-lib/MyBoard.sch", "brdPath": "C:/Users/me/adom-lib/MyBoard.brd"}),+ {"schPath": "/Users/me/adom-lib/MyBoard.sch", "brdPath": "/Users/me/adom-lib/MyBoard.brd"}), ("fusion_notify_owner", "LAST-RESORT human escalation: toast the user's MAIN computer (fans out cross-AD to every peer on the relay, so it reaches them even when this bridge runs on an unattended VM). ONLY for true human walls - password/2FA entry, UAC elevation, a physical step. Exhaust programmatic options first (UIA background clicks, seat auto-resolve, warm-SSO sign-in). After sending, WAIT and poll fusion_readiness; do not re-toast within a few minutes.", {"title": "optional str", "body": "required str", "level": "optional str info|warning|error"},@@ -85,7 +85,7 @@ _T = [ {"success": "bool", "authUrl": "str", "openedVia": "str", "needsExtensionOpen": "bool"}, 30, "fusion_aps_status", False, {}), ("fusion_aps_set_browser", "Remember the browser+profile that authed to Autodesk so every later sign-in reuses it (power users have many browsers/profiles; the OS default is usually the wrong one). Call after a driven sign-in succeeds.", {"name": "optional str", "exe": "str (browser binary) OR how:'extension'", "profileDir": "optional str", "how": "optional 'extension'|'launch'"},- {"success": "bool", "browser": "{name,exe,profileDir}"}, 10, None, False, {"name": "Edge", "exe": "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "profileDir": "Default"}),+ {"success": "bool", "browser": "{name,exe,profileDir}"}, 10, None, False, {"name": "Chrome", "exe": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "profileDir": "Default"}), ("fusion_aps_get_browser", "Show the remembered Autodesk sign-in browser (None until set).", {}, {"success": "bool", "browser": "{name,exe,profileDir}|null"}, 10, None, False, {}), ("fusion_aps_forget_browser", "Forget the remembered Autodesk sign-in browser (re-detect next time).",@@ -109,7 +109,7 @@ _T = [ {"query": "str OR projectId+urn", "saveDir": "optional str", "fileName": "optional str"}, {"path": "str", "bytes": "int"}, 90, None, False, {"query": "BME690 Molecule"}), ("fusion_aps_upload", "Upload a local file as a NEW cloud file (free Data Management; needs data:write scope). ⛔ FOLDER HYGIENE: NEVER pass a project ROOT folderId - the bridge REFUSES it (refused_root_upload). Loose files in a shared root clutter the team's cloud. Create/choose a real subfolder first (fusion_aps_create_folder under an 'Adom AI Workspace' folder), or just use fusion_make_3d_package / fusion_build_library_3d which auto-target a non-root workspace. To RELOCATE an existing file, MOVE it (preserves its urn) - do NOT delete + re-upload (mints a new urn, breaks 3D bindings). See the fusion-cloud-hygiene skill.", {"projectId": "required str", "folderId": "required str (NON-ROOT subfolder)", "localPath": "required str", "fileName": "optional str"},- {"itemUrn": "str", "bytes": "int"}, 90, None, False, {"projectId": "a.YnVz...", "folderId": "urn:...", "localPath": "C:/x.f3d"}),+ {"itemUrn": "str", "bytes": "int"}, 90, None, False, {"projectId": "a.YnVz...", "folderId": "urn:...", "localPath": "/tmp/x.f3d"}), ("fusion_aps_create_folder", "Create a subfolder in a project (free; needs data:create scope). Use this to make an 'Adom AI Workspace' folder + per-task subfolders so the bridge never dumps loose files in the shared project root. See fusion-cloud-hygiene.", {"projectId": "required str", "parentFolderId": "required str", "name": "required str"}, {"folderId": "str"}, 50, None, False, {"projectId": "a.YnVz...", "parentFolderId": "urn:...", "name": "Adom AI Workspace"}), ("fusion_aps_get", "Raw authenticated GET against a free APS path (dev/verify helper).",@@ -129,24 +129,24 @@ _T = [ ("fusion_open_by_urn_note", "(see fusion_open_by_urn)", {}, {}, 30, None, False, {}), ("fusion_board_info", "Raw board geometry XML (copper/arcs/lines) for the open board.", {}, {"rawXml": "str"}, 60, None, False, {}),- ("fusion_open_lbr", "Import an EAGLE .lbr library into Fusion's Electronics LIBRARY editor (Content Manager / Electronics Library workspace), not a PCB design. PITFALL: filePath MUST be a WINDOWS-local path (C:/...). The chip-fetcher/adom-lbr pipeline runs in the cloud container, and there is NO container-to-Windows push verb, so STAGE the .lbr onto Windows first (serve it on a proxied port + Invoke-WebRequest via shell_execute), then pass the C:/ path. PITFALL: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package; the real 3D chip is NOT attached (that needs a Fusion cloud URN). To attach the 3D, see the 'fusion-libraries' skill. Pass verify:true to round-trip-confirm Fusion parsed the deviceset, then check fusion_get_app_state (activeWorkspace 'Electronics Library').",- {"filePath": "required str (.lbr WINDOWS path C:/...; stage from container first)", "symbolName": "optional str", "verify": "optional bool"},- {"success": "bool", "postOpenScreenshot": "{savedTo}"}, 50, None, False, {"filePath": "C:/Users/<user>/adom-lib/nRF54L15.lbr"}),+ ("fusion_open_lbr", "Import an EAGLE .lbr library into Fusion's Electronics LIBRARY editor (Content Manager / Electronics Library workspace), not a PCB design. PITFALL: filePath MUST be a path local to THIS machine. If the .lbr was produced elsewhere (e.g. the chip-fetcher/adom-lbr pipeline in a cloud container), STAGE it onto this machine first (send_files), then pass the local path. PITFALL: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package; the real 3D chip is NOT attached (that needs a Fusion cloud URN). To attach the 3D, see the 'fusion-libraries' skill. Pass verify:true to round-trip-confirm Fusion parsed the deviceset, then check fusion_get_app_state (activeWorkspace 'Electronics Library').",+ {"filePath": "required str (.lbr local path; stage onto this machine first)", "symbolName": "optional str", "verify": "optional bool"},+ {"success": "bool", "postOpenScreenshot": "{savedTo}"}, 50, None, False, {"filePath": "/Users/<user>/adom-lib/nRF54L15.lbr"}), ("fusion_attach_3d_package", "Attach a real 3D chip model to a library package, end to end: opens the .lbr (library active), runs Electron.Create3DPackage to enter the Package3DEnvironment showing the footprint, imports the STEP + auto-orients it flat on the footprint, then executes Package3DStop (FINISH). This BINDS the 3D onto the deviceset (Content Manager shows the 3D nested under the package; the Packages 'Package' column flips Placeholder->part-name). ONE desktop-side step remains: Fusion pops a modal Save dialog (an OWNED popup) - the response _hint gives the exact desktop_ui_click to confirm it. AFTER: re-grab desktop_screenshot_window and check ownedPopupCount for errors; the 3D preview LAGS a beat before showing the real chip. Both paths (filePath/modelPath) are WINDOWS paths - stage from the container first.",- {"filePath": "required str (.lbr WINDOWS path C:/...)", "modelPath": "required str (.step WINDOWS path C:/...)", "packageName": "optional str"},- {"success": "bool", "savePending": "bool", "data": "{steps}", "_hint": "the Save-dialog click + verify steps"}, 240, _STATUS, True, {"filePath": "C:/Users/<user>/adom-lib/ADS8588SIPM.fusion.lbr", "modelPath": "C:/Users/<user>/adom-lib/ADS8588SIPM.step", "packageName": "ADS8588SIPM"}),+ {"filePath": "required str (.lbr local path)", "modelPath": "required str (.step local path)", "packageName": "optional str"},+ {"success": "bool", "savePending": "bool", "data": "{steps}", "_hint": "the Save-dialog click + verify steps"}, 240, _STATUS, True, {"filePath": "/Users/<user>/adom-lib/ADS8588SIPM.fusion.lbr", "modelPath": "/Users/<user>/adom-lib/ADS8588SIPM.step", "packageName": "ADS8588SIPM"}), ("fusion_install_fusion", "Install Fusion 360 FOR the user - downloads Autodesk's official Client Downloader and streams the free trial silently, with NO shell_execute and NO AD approval gate (runs inside the trusted bridge). Elevation-aware: --globalinstall when admin, PER-USER otherwise (a non-elevated globalinstall dies silently on UAC - learned live). Returns promptly; poll fusion_readiness (reports installing:true) until installed:true, then fusion_start. Declared as detect.installVerb so AD's bridge_readiness recommends it.", {}, {"success": "bool", "installing": "bool", "alreadyInstalled": "bool?", "mode": "globalinstall|per-user", "statusVerb": "str"}, 90, _STATUS, True, {}), ("fusion_generate_package", "Generate an IPC-7351-compliant parametric 3D package via Fusion's BUILT-IN ElectronicsPackageGenerator (EPG) - ~50 package families (chip/soic/qfn/qfp/bga/sot23/dfn/melf/ecap/crystal/headers/...), each a few seconds, ZERO GUI. Optionally LASER-ETCH a marking (MPN / '103') into the body top as REAL cut geometry (survives STEP), and export STEP in the same call - the royalty-free, license-clean path to a whole component library (our own generated output, not vendor models). Proven live: 0603 + '103' etch + STEP in ~2s.", {"type": "required str (EPG family, e.g. chip|soic|qfn|qfp|bga|sot23|dfn2|melf|ecap|crystal|header_straight - see supportedTypes in the error for the full list)", "params": "optional dict (generator dims in MM by default, e.g. chip: {D,E,A,L,L1}; soic: {A,A1,b,D,E,E1,e,L,DPins}; omitted keys use EPG defaults)", "unitsCm": "optional bool (params already EPG-native cm)", "etch": "optional str (marking text on the chip top: MPN / '103'; multi-line with newline, e.g. MPN+variant - laid along the LONGEST face axis)", "etchStyle": "optional str raised|engraved (default raised = thin WHITE positive extrude, silkscreen-style contrast; engraved = sunken cut)", "etchDepthMm": "optional float (marking height/depth, default 0.03)", "etchHeightMm": "optional float (text height; default auto-fit: top-5%-band face, 10% margin, width-aware)", "outputStep": "optional str (WINDOWS path; exports STEP incl. the marking)"},- {"success": "bool", "bodies": "[{name,vol}]", "etched": "str|null", "stepPath": "str|null", "manifest": "obj (settings+why+bboxes; also written as <step>.manifest.json sidecar for marking refreshes)", "manifestPath": "str|null", "epgDir": "str"}, 120, _STATUS, True, {"type": "chip", "params": {"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3, "L1": 0.3}, "etch": "103", "outputStep": "C:/tmp/ADOM-R0603-103.step"}),+ {"success": "bool", "bodies": "[{name,vol}]", "etched": "str|null", "stepPath": "str|null", "manifest": "obj (settings+why+bboxes; also written as <step>.manifest.json sidecar for marking refreshes)", "manifestPath": "str|null", "epgDir": "str"}, 120, _STATUS, True, {"type": "chip", "params": {"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3, "L1": 0.3}, "etch": "103", "outputStep": "/tmp/ADOM-R0603-103.step"}), ("fusion_make_3d_package", "Create a RENDERING component 3D package (FOOTPRINT + chip, aligned) for ONE part, fully programmatically with ZERO GUI dialogs. Opens the .lbr, runs Electron.Create3DPackage to load the package's footprint into a generator doc, imports the STEP onto it, orients it flat, saveAs an .f3d (skips the FINISH Save dialog + the two unbeatable 'Fusion360' CEF modals), uploads the f3d via APS, and returns an fs.file:vf wip_urn to hand-write into the library's <packages3d>. projectId/folderId default to the MAIN-project upload folder. Pass captureLabel to also get a BEFORE (footprint) + AFTER (chip placed) screenshot. PITFALL: a RAW STEP upload binds but renders NOTHING ('Thumbnail download failed') - this verb makes an f3d, which renders. Prefer fusion_build_library_3d for many parts.", {"lbrPath": "required str (.lbr WINDOWS path; its FIRST package's footprint is loaded)", "modelPath": "required str (.step WINDOWS path)", "projectId": "optional str (default MAIN-project)", "folderId": "optional str (default upload folder)", "fileName": "optional str", "captureLabel": "optional str (capture before/after)", "orient": "optional bool (default true)"},- {"success": "bool", "wip_urn": "str", "dims_mm": "[x,y,z]", "f3d": "str", "before": "str|null", "after": "str|null"}, 200, _STATUS, True, {"lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step", "captureLabel": "R_4k7"}),+ {"success": "bool", "wip_urn": "str", "dims_mm": "[x,y,z]", "f3d": "str", "before": "str|null", "after": "str|null"}, 200, _STATUS, True, {"lbrPath": "/tmp/newlib/R_4k7.lbr", "modelPath": "/tmp/newlib/R_4k7.step", "captureLabel": "R_4k7"}), ("fusion_build_library_3d", "Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline. For each part it makes the footprint+chip f3d package (no GUI dialogs, optional before/after screenshots) and collects the wip_urn, then injects ALL bindings into the combined .lbr in one pass (no hand XML surgery) and opens the finished library once. This is the verb for a basic-parts sampler / any many-part library; it replaces the per-part make_3d_package loop + manual binding. projectId/folderId default to the MAIN-project upload folder. Each part is independent, so re-run just the failed parts if Fusion resets mid-run.", {"lbrPath": "required str (combined .lbr WINDOWS path to bind + open)", "parts": "required [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]", "outLbrPath": "optional str (default: overwrite lbrPath)", "capture": "optional bool (default true)", "projectId": "optional str", "folderId": "optional str", "openWhenDone": "optional bool (default true)"},- {"success": "bool", "boundLbr": "str", "partsBound": "int", "partsTotal": "int", "parts": "[{package,success,wip_urn,dims_mm,error}]", "screenshots": "[{package,stage,path}]", "opened": "bool"}, 1800, _STATUS, True, {"lbrPath": "C:/tmp/newlib/AdomBasicParts2.lbr", "parts": [{"package": "R_4k7", "lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step"}]}),- ("fusion_capture_library_views", "Capture the LIBRARY-EDITOR views EEs trust: the schematic SYMBOL (full pinout), the FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: symbol + the package table with the footprint<->package Mapped check + pin/pad counts). The 3D before/after shots don't show these. Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). Runs EDIT <pkg>.sym/.pac/.dev + WINDOW FIT + a background hwnd screenshot per view; returns the PNG paths on the box (C:/tmp/conduit-screenshots - pull with desktop_pull_file). EDIT uses the DEVICESET name (e.g. ESP32-S3FN8).",+ {"success": "bool", "boundLbr": "str", "partsBound": "int", "partsTotal": "int", "parts": "[{package,success,wip_urn,dims_mm,error}]", "screenshots": "[{package,stage,path}]", "opened": "bool"}, 1800, _STATUS, True, {"lbrPath": "/tmp/newlib/AdomBasicParts2.lbr", "parts": [{"package": "R_4k7", "lbrPath": "/tmp/newlib/R_4k7.lbr", "modelPath": "/tmp/newlib/R_4k7.step"}]}),+ ("fusion_capture_library_views", "Capture the LIBRARY-EDITOR views EEs trust: the schematic SYMBOL (full pinout), the FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: symbol + the package table with the footprint<->package Mapped check + pin/pad counts). The 3D before/after shots don't show these. Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). Runs EDIT <pkg>.sym/.pac/.dev + WINDOW FIT + a background hwnd screenshot per view; returns the PNG paths on the box (/tmp/conduit-screenshots - pull with desktop_pull_file). EDIT uses the DEVICESET name (e.g. ESP32-S3FN8).", {"packages": "required [<deviceset name>] (or 'package': single name)", "views": "optional subset of ['component','symbol','footprint'] (default all)", "settle": "optional float secs after each EDIT before capture (default 1.5; raise if a shot shows the PREVIOUS part)"}, {"success": "bool", "captured": "[{package,view,path,ok}]"}, 300, _STATUS, True, {"packages": ["ESP32-S3FN8", "ATSAMD51J20A-AUT"]}), ("fusion_cleanup_cloud_files", "PRECISELY delete a list of cloud files by lineage urn - SAFE cleanup of AI-created clutter. Deletes ONLY the exact fileIds given (never name-guessing), so it can't touch a teammate's file in a shared folder. Loops server-side. Get the ids from fusion_aps_browse (item .id). Use this to remove f3d files the bridge created. A big list takes minutes (relay may time out at ~60s while deletes continue server-side - re-browse to confirm).",@@ -157,7 +157,7 @@ _T = [ {"result": "obj", "documentName": "str"}, 180, None, True, {"script": "import adsk.fusion\n# ...build geometry..."}), ("fusion_assembly_bom", "Structured, kit-aware MECHANICAL bill of materials for the active Design assembly (the counterpart to the electronics-only fusion_export_bom). Recurses organizational subassemblies but counts a physical part or a purchased kit/unit ONCE, so hardware modeled inside a kit (a '... with fasteners' bracket, a bearing/pulley) is NOT double-counted the way a flat allOccurrences walk counts it. Matches Fusion's Manage -> BOM quantities. Optionally writes a CSV.",- {"treatAsUnit": "optional [str] name substrings counted as one unit (default ['with fasteners'])", "exclude": "optional [str] top-level name prefixes to skip", "includePhysicalProperties": "optional bool (adds volume_cm3/mass_kg per line)", "outputPath": "optional str (Windows CSV path)"},+ {"treatAsUnit": "optional [str] name substrings counted as one unit (default ['with fasteners'])", "exclude": "optional [str] top-level name prefixes to skip", "includePhysicalProperties": "optional bool (adds volume_cm3/mass_kg per line)", "outputPath": "optional str (local CSV path)"}, {"success": "bool", "design": "str", "partCount": "int", "totalInstances": "int", "parts": "[{componentName, partNumber, description, material, quantity, bodies}]"}, 120, None, False, {"treatAsUnit": ["with fasteners"], "includePhysicalProperties": True}), @@ -175,25 +175,25 @@ _T = [ # ── CAD & 3D export ──────────────────────────────────────────────────── ("fusion_export_step", "Export the active design to STEP (needs the 3D view).",- {"outputPath": "required str"}, {"format": "str", "fileSizeKB": "int"}, 300, None, True, {"outputPath": "C:/out/board.step"}),- ("fusion_export_iges", "Export to IGES.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/board.iges"}),- ("fusion_export_stl", "Export to STL mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.stl"}),- ("fusion_export_3mf", "Export to 3MF mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.3mf"}),- ("fusion_export_usdz", "Export to USDZ (AR / Hydrogen 3D).", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.usdz"}),- ("fusion_export_optimized_glb", "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "jobId": "str", "pending": "bool", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool"}, 200, None, True, {"outputPath": "C:/out/board.glb"}),- ("fusion_fetch_optimized_glb", "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", {"jobId": "required str", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),+ {"outputPath": "required str"}, {"format": "str", "fileSizeKB": "int"}, 300, None, True, {"outputPath": "/tmp/out/board.step"}),+ ("fusion_export_iges", "Export to IGES.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "/tmp/out/board.iges"}),+ ("fusion_export_stl", "Export to STL mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "/tmp/out/part.stl"}),+ ("fusion_export_3mf", "Export to 3MF mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "/tmp/out/part.3mf"}),+ ("fusion_export_usdz", "Export to USDZ (AR / Hydrogen 3D).", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "/tmp/out/part.usdz"}),+ ("fusion_export_optimized_glb", "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "jobId": "str", "pending": "bool", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool"}, 200, None, True, {"outputPath": "/tmp/out/board.glb"}),+ ("fusion_fetch_optimized_glb", "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", {"jobId": "required str", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "/tmp/out/board.glb"}), ("fusion_board_stackup", "Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill): copper layer count + copper/dielectric thicknesses from the EAGLE design rules (layerSetup/mtCopper/mtIsolate) plus the measured FR4 extent. A PCB is Cu/prepreg/Cu/core/.../Cu - the FR4 is NOT one slab. Use to build the stackup table + real-thickness exploded 3D view, and put a stackup table on every exported board's wiki page.", {}, {"layerSetup": "str", "copperLayers": "list", "copperCount": "int", "copperThickness_mm": "list", "dielectricBonds": "list (prepreg/core order)", "fr4": "{x_mm,y_mm,dielectric_mm}"}, 200, None, True, {}),- ("fusion_export_obj", "Export to OBJ mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.obj"}),- ("fusion_export_dxf", "Export 2D DXF.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dxf"}),- ("fusion_export_dwg", "Export 2D DWG.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dwg"}),+ ("fusion_export_obj", "Export to OBJ mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "/tmp/out/part.obj"}),+ ("fusion_export_dxf", "Export 2D DXF.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "/tmp/out/sketch.dxf"}),+ ("fusion_export_dwg", "Export 2D DWG.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "/tmp/out/sketch.dwg"}), # ── Manufacturing ────────────────────────────────────────────────────── ("fusion_export_gerbers", "Run the bundled JLCPCB CAM job (auto 2/4-layer) to produce the gerber + drill ZIP.",- {"outputDir": "optional str (default C:/tmp/adom-gerbers)"}, {"fileCount": "int", "files": "[{name,size}]", "zipPath": "str"}, 180, None, True, {"outputDir": "C:/tmp/jlc"}),+ {"outputDir": "optional str (default /tmp/adom-gerbers)"}, {"fileCount": "int", "files": "[{name,size}]", "zipPath": "str"}, 180, None, True, {"outputDir": "/tmp/jlc"}), ("fusion_export_bom", "Export a Bill of Materials CSV (grouped by value+package) from the open board.",- {"outputPath": "optional str", "grouped": "optional bool (default true)"}, {"componentCount": "int", "grouped": "bool"}, 60, None, False, {"outputPath": "C:/tmp/bom.csv"}),+ {"outputPath": "optional str", "grouped": "optional bool (default true)"}, {"componentCount": "int", "grouped": "bool"}, 60, None, False, {"outputPath": "/tmp/bom.csv"}), ("fusion_export_cpl", "Export the Component Placement List (pick-and-place) CSV.",- {"outputPath": "optional str", "side": "optional str (all|top|bottom)"}, {"topCount": "int", "bottomCount": "int"}, 60, None, False, {"outputPath": "C:/tmp/cpl.csv"}),+ {"outputPath": "optional str", "side": "optional str (all|top|bottom)"}, {"topCount": "int", "bottomCount": "int"}, 60, None, False, {"outputPath": "/tmp/cpl.csv"}), ("fusion_set_design_rules", "Load manufacturing-grade DRC into the open board (JLCPCB 2/4-layer profiles).", {"action": "optional str (apply|export|show)", "layers": "optional str (auto|2|4)"}, {"activeWorkspace": "str", "description": "str"}, 90, None, False, {"action": "apply", "layers": "2"}), ("fusion_load_design_rules", "Load a bundled .edru design-rule set into the board.",@@ -203,7 +203,7 @@ _T = [ ("fusion_dismiss_blocking_dialogs", "Detect + classify (and optionally dismiss) blocking Fusion dialogs.", {}, {"dialogs": "[...]"}, 30, None, False, {}), ("fusion_electron_run", "Run an EAGLE/Electron command in the open board (the generic extension point, e.g. RUN <ulp>).",- {"command": "required str"}, {"success": "bool", "output": "str"}, 90, None, False, {"command": "set confirm yes;RUN 'C:/tmp/jlcpcb_smta_exporter.ulp' 'C:/tmp/jlc'"}),+ {"command": "required str"}, {"success": "bool", "output": "str"}, 90, None, False, {"command": "set confirm yes;RUN '/tmp/jlcpcb_smta_exporter.ulp' '/tmp/jlc'"}), ("fusion_electron_zoom", "Smoothly ZOOM the schematic/2D board for a recording (the whole motion is one call). factor>1 zooms in, <1 out; or fit:true. The loop runs in the add-in with a repaint per frame.", {"factor": "float (default 2.0)", "fit": "optional bool", "steps": "optional int (default 16)", "frameDelayMs": "optional int (default 35)"}, {"success": "bool", "output": "str"}, 60, None, True, {"factor": 3, "steps": 20}),
eagle_lbr.py+2−1@@ -29,8 +29,9 @@ Usage: ], }], )- write_lbr(xml, "C:/tmp/MyLib.lbr")+ write_lbr(xml, "/tmp/MyLib.lbr") """+from __future__ import annotations # PEP-604 hints must parse on py3.9 (mac stock) import xml.etree.ElementTree as ET from xml.dom import minidom
fusion_detect.py+178−636@@ -1,83 +1,61 @@-"""Auto-detect Fusion 360 installation on Windows."""+"""Auto-detect the Fusion 360 installation on macOS.++This bridge is macOS-only (Kyle, 2026-07-31): the Windows detection —+Fusion360.exe / FusionLauncher.exe markers, tasklist walks, Win32 focus+guards — is gone. The hard-won webdeploy lessons (John, 2026-07-14) carry+over with mac markers:++ * Fusion streams into ~/Library/Application Support/Autodesk/webdeploy/+ production/<hash>/, one hash dir per content build; old builds are never+ pruned, so several stale dirs are NORMAL.+ * "Installed" is gated on the real app binary+ (<hash>/Autodesk Fusion.app/Contents/MacOS/Autodesk Fusion, ~1.6 MB+ measured) existing at full size — the bundle dir itself appears early+ in a stream and must never be the completeness marker.+"""+from __future__ import annotations # PEP-604 hints must parse on py3.9 (mac stock) -import ctypes-import ctypes.wintypes import json import os-import re import subprocess-import sys-import threading import time import urllib.error import urllib.request from pathlib import Path +from handlers import mac_ui+ ADDIN_PORT = 8774 -# Fusion 360 uses a web-deploy model — the launcher lives under-# <base>\Autodesk\webdeploy\production\<hash>\FusionLauncher.exe-# where <base> is %LOCALAPPDATA% for a per-user install OR %ProgramFiles%-# (/%ProgramFiles(x86)%/%ProgramW6432%) for a SYSTEM-WIDE `--globalinstall` --# which is exactly what the fusion-onboarding skill runs. We MUST check both, or-# an AI-installed (globalinstall) Fusion is invisible and onboarding "succeeds"-# yet the bridge still reports not-installed. (Found on the ADOMBASELINE test.)-_WEBDEPLOY_BASES = []-for _var in ("LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):- _root = os.environ.get(_var)- if _root:- _base = Path(_root) / "Autodesk" / "webdeploy" / "production"- if _base not in _WEBDEPLOY_BASES:- _WEBDEPLOY_BASES.append(_base)-# Back-compat alias (some callers import WEBDEPLOY_BASE directly).-WEBDEPLOY_BASE = _WEBDEPLOY_BASES[0] if _WEBDEPLOY_BASES else (- Path(os.environ.get("LOCALAPPDATA", "")) / "Autodesk" / "webdeploy" / "production"-)--# macOS uses the same webdeploy model under ~/Library; the env-var loop above-# finds nothing there (no LOCALAPPDATA), so seed the mac paths explicitly.-IS_MACOS = sys.platform == "darwin"-if IS_MACOS:- _mac_base = Path.home() / "Library" / "Application Support" / "Autodesk" / "webdeploy" / "production"- if _mac_base not in _WEBDEPLOY_BASES:- _WEBDEPLOY_BASES.insert(0, _mac_base)- WEBDEPLOY_BASE = _WEBDEPLOY_BASES[0]+# Fusion's webdeploy root on macOS. Kept as a list for parity with callers+# that iterate (and in case Autodesk ever adds a second root).+_WEBDEPLOY_BASES = [+ Path.home() / "Library" / "Application Support" / "Autodesk" / "webdeploy" / "production",+]+WEBDEPLOY_BASE = _WEBDEPLOY_BASES[0] # EVERY known per-user add-in dir, newest convention first. Fusion MOVED this # across versions and silently ignores the others (issue #63: an add-in in the-# legacy API\AddIns dir never loads on 2025+ Fusion, which scans FusionAddins -+# legacy API/AddIns dir never loads on 2025+ Fusion, which scans FusionAddins - # proven live on a fresh install). Track ALL of them; never assume one path. ADDINS_DIR_CANDIDATES = [- Path(os.environ.get("APPDATA", "")) / "Autodesk" / "FusionAddins",- Path(os.environ.get("APPDATA", "")) / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns",- Path(os.environ.get("APPDATA", "")) / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns",-] if sys.platform != "darwin" else [ Path.home() / "Library" / "Application Support" / "Autodesk" / "FusionAddins", Path.home() / "Library" / "Application Support" / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns", Path.home() / "Library" / "Application Support" / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns", ] -# Keep callback references alive to prevent GC-_callbacks = []--# Guard windll so this module imports on non-Windows hosts (galliaApril smoke-# tests, macOS). The Win32-only functions below are never reached off-Windows —-# detect_fusion() short-circuits to installed:False — but the bridge must still-# boot + serve /status on any platform AD spawns it on.-user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None- def detect_fusion() -> dict:- """Detect Fusion 360 installation. Returns a dict with paths and status."""- exe_path = _find_fusion_launcher()+ """Detect the Fusion 360 installation. Returns a dict with paths and status."""+ app_path = _find_fusion_app() addins_dir = _find_addins_dir() addin_installed = _check_addin_installed(addins_dir) if addins_dir else False running = _is_fusion_running() - if exe_path:+ if app_path: return { "installed": True,- "exe_path": str(exe_path),+ "exe_path": str(app_path), "addins_dir": str(addins_dir) if addins_dir else None, "addin_installed": addin_installed, "running": running,@@ -94,33 +72,21 @@ def detect_fusion() -> dict: # ----------------------------------------------------------------------------------------------------# INSTALL-COMPLETENESS DETECTION (root-caused live on a fresh Hyper-V VM, John 2026-07-14)-#-# WHAT A WEBDEPLOY INSTALL ACTUALLY LOOKS LIKE (verified on disk, do not assume):-# %LOCALAPPDATA%\Autodesk\webdeploy\production\ holds one hash dir PER content-build. A COMPLETE-# Fusion install is ~615 files (Fusion360.exe ~880 KB + hundreds of DLLs + Qt/ + Python/ + ...).-# BUT Autodesk ALSO drops a tiny 8-file launcher STUB dir next to it: icons + FusionLauncher.exe-# + a small FusionLauncher.exe.ini (~412 bytes). So at steady state there are TWO hash dirs.+# INSTALL-COMPLETENESS DETECTION #-# THE TRAP that broke the fresh VM (and the two earlier "fixes" that were WRONG):-# * Keying "installed" off FusionLauncher.exe EXISTING -> true mid-stream (launcher lands early) ->-# premature fusion_start -> "Error Launching Streamed Application ... .ini missing/incomplete".-# * Keying off FusionLauncher.exe.ini existing / its SIZE -> ALSO wrong: the COMPLETE app dir has-# NO .ini at all; only the throwaway stub carries a 412-byte .ini. A size gate would REJECT a-# perfectly good install and ACCEPT nothing. (Measured it: complete app dir = 615 files, no .ini.)-#-# THE RELIABLE SIGNAL: the real application binary Fusion360.exe. It only exists (full size) when the-# payload finished streaming. Gate "installed" on Fusion360.exe, NEVER on the launcher or its .ini.+# The reliable signal is the real app binary inside the bundle. It only exists at full size once the+# payload finished streaming. Gate "installed" on it, NEVER on the bundle dir appearing (that lands+# early, mid-stream — the mac analog of the FusionLauncher.exe trap that broke the fresh VM,+# John 2026-07-14). # ----------------------------------------------------------------------------------------------------_FUSION_APP_EXE = "Fusion360.exe" # the ACTUAL app binary (~880 KB); the completeness marker-_MIN_APP_EXE_BYTES = 200_000 # guards against a 0-byte placeholder the streamer writes early+_FUSION_BUNDLE = "Autodesk Fusion.app"+_FUSION_APP_EXE = os.path.join(_FUSION_BUNDLE, "Contents", "MacOS", "Autodesk Fusion")+_MIN_APP_EXE_BYTES = 200_000 # guards against a placeholder the streamer writes early def _app_dir_complete(subdir: Path) -> bool:- """A production hash dir is a COMPLETE Fusion install ONLY when it contains the real app binary- Fusion360.exe at full size. This is process-independent and version-stable. Do NOT substitute- FusionLauncher.exe (lands early, mid-stream) or FusionLauncher.exe.ini (absent from the complete- app dir; only the launcher stub has one). See the block comment above for the on-disk anatomy."""+ """A production hash dir is a COMPLETE Fusion install ONLY when it contains the bundle's real+ binary at full size. Process-independent and version-stable.""" exe = subdir / _FUSION_APP_EXE try: return exe.exists() and exe.stat().st_size >= _MIN_APP_EXE_BYTES@@ -129,8 +95,8 @@ def _app_dir_complete(subdir: Path) -> bool: def _any_app_complete() -> bool:- """True if ANY webdeploy hash dir holds a complete Fusion (Fusion360.exe). This is the single- source of truth for 'Fusion is installed'."""+ """True if ANY webdeploy hash dir holds a complete Fusion. This is the single source of truth+ for 'Fusion is installed'.""" for base in _WEBDEPLOY_BASES: try: if base.exists():@@ -142,48 +108,13 @@ def _any_app_complete() -> bool: return False -def _find_fusion_launcher() -> Path | None:- """Return the FusionLauncher.exe to RUN, but only when Fusion is actually INSTALLED.-- TWO SEPARATE QUESTIONS (conflating them is what broke the launch, John 2026-07-14):- 1. Is Fusion installed? -> a hash dir contains the real app binary Fusion360.exe.- 2. Which launcher do I run? -> a FusionLauncher.exe that HAS its FusionLauncher.exe.ini- BESIDE IT. Running a FusionLauncher.exe WITHOUT its .ini throws Fusion's own- "Error Launching Streamed Application ... FusionLauncher.exe.ini is missing or incomplete".- The complete APP dir (615 files, Fusion360.exe) frequently has the launcher but NO .ini; a- separate tiny STUB dir carries FusionLauncher.exe + its ~412-byte .ini and knows how to load- the app. So we must return the launcher-WITH-.ini, NOT the app dir's bare launcher.-- Because we gate on Fusion360.exe first, the stub's .ini is guaranteed complete by then (mid-stream- there is no Fusion360.exe, so we return None and nothing launches into a half-written install)."""- if IS_MACOS:- # The .app bundle sits inside the hash dir (sometimes directly under- # production/). `open` launches the bundle path; no .ini stub dance.- for base in _WEBDEPLOY_BASES:- if not base.exists():- continue- try:- subdirs = sorted(- [d for d in base.iterdir() if d.is_dir()],- key=lambda d: d.stat().st_mtime,- reverse=True,- )- except OSError:- continue- for subdir in subdirs:- if subdir.name.endswith(".app") and (subdir / "Contents").exists():- return subdir- app = subdir / "Autodesk Fusion.app"- if (app / "Contents").exists():- return app- return None-- if not _any_app_complete():- return None # not installed (or still streaming) -> None+def _find_fusion_app() -> Path | None:+ """The newest COMPLETE 'Autodesk Fusion.app' bundle to launch (via `open`), or None. - runnable = [] # (mtime, launcher) - FusionLauncher.exe that has its .ini beside it- bare = [] # (mtime, launcher) - FusionLauncher.exe with NO .ini (fallback only)- app_exes = [] # (mtime, Fusion360.exe) - last-ditch fallback+ Completeness is gated per-dir on the bundle binary — returning a bundle whose+ binary hasn't finished streaming reproduces the launch-into-half-written-install+ failure."""+ candidates = [] for base in _WEBDEPLOY_BASES: if not base.exists(): continue@@ -192,35 +123,32 @@ def _find_fusion_launcher() -> Path | None: except OSError: continue for subdir in subdirs:+ # The bundle usually sits inside the hash dir; tolerate it directly+ # under production/ as well.+ if subdir.name == _FUSION_BUNDLE and (subdir / "Contents").exists():+ app = subdir+ else:+ app = subdir / _FUSION_BUNDLE+ binary = app / "Contents" / "MacOS" / "Autodesk Fusion" try:- mtime = subdir.stat().st_mtime+ if not (binary.exists() and binary.stat().st_size >= _MIN_APP_EXE_BYTES):+ continue+ candidates.append((subdir.stat().st_mtime, app)) except OSError:- mtime = 0.0- launcher = subdir / "FusionLauncher.exe"- if launcher.exists():- if (subdir / "FusionLauncher.exe.ini").exists():- runnable.append((mtime, launcher))- else:- bare.append((mtime, launcher))- if _app_dir_complete(subdir):- app_exes.append((mtime, subdir / _FUSION_APP_EXE))-- for pool in (runnable, bare, app_exes): # prefer launcher+ini, then bare launcher, then app exe- if pool:- pool.sort(key=lambda c: c[0], reverse=True)- return pool[0][1]- return None+ continue+ if not candidates:+ return None+ candidates.sort(key=lambda c: c[0], reverse=True)+ return candidates[0][1] def _incomplete_webdeploy_present() -> bool: """Disk-state (process-INDEPENDENT) 'a fresh install is actively streaming' signal: some hash dir- has FusionLauncher.exe but NO complete Fusion360.exe exists ANYWHERE yet. Why disk-state and not a- tasklist check: the Autodesk streamer spawns short-lived per-chunk worker processes, so- _installer_running() reads False most of a live multi-GB stream (flickered True only 3 of 25 polls- during a real install, John 2026-07-14). Why gate on 'no complete app anywhere': once the app IS- complete, the leftover launcher stub (FusionLauncher.exe, no Fusion360.exe) is NORMAL and must NOT- read as 'still installing'. (An auto-UPDATE that streams a new build beside a complete old one is- handled separately by fusion_update_in_progress(), not here.) Never raises."""+ has the bundle but NO complete binary exists ANYWHERE yet. Why disk-state and not a process+ check: the Autodesk streamer spawns short-lived per-chunk workers, so a process poll reads False+ for most of a live multi-GB stream. Why gate on 'no complete app anywhere': once the app IS+ complete, leftover partial dirs are NORMAL and must NOT read as 'still installing'. Never+ raises.""" if _any_app_complete(): return False # a complete install exists -> not installing for base in _WEBDEPLOY_BASES:@@ -228,22 +156,21 @@ def _incomplete_webdeploy_present() -> bool: if not base.exists(): continue for subdir in base.iterdir():- if subdir.is_dir() and (subdir / "FusionLauncher.exe").exists():- return True # launcher present, no complete app -> streaming+ if not subdir.is_dir():+ continue+ if (subdir / _FUSION_BUNDLE).exists() or subdir.name == _FUSION_BUNDLE:+ return True # payload started, no complete app -> streaming except OSError: continue return False def _clean_incomplete_webdeploy() -> list:- """Remove the residue of an INTERRUPTED stream so the next fusion_install_fusion re-streams clean.+ """Remove the residue of an INTERRUPTED stream so the next install re-streams clean. Only ever called AFTER a launch failure (we already know something is corrupt). - SAFETY GUARD: if a COMPLETE install (Fusion360.exe) exists anywhere, do NOTHING and return [] -- the launcher-only stub dir beside a complete app is a LEGIT part of the install, not corruption,- and nuking it would break a working Fusion. Only when NO complete app exists (a genuinely half-- streamed state) do we remove the partial launcher-bearing dirs. Never raises. (John 2026-07-14:- the old .ini-size test would have deleted the legit stub of a healthy install.)"""+ SAFETY GUARD: if a COMPLETE install exists anywhere, do NOTHING and return [] — partial-looking+ dirs beside a complete app can be a legit part of the install, not corruption. Never raises.""" import shutil if _any_app_complete(): return [] # healthy install present - never touch it@@ -256,9 +183,10 @@ def _clean_incomplete_webdeploy() -> list: except OSError: continue for subdir in subdirs:- if not (subdir / "FusionLauncher.exe").exists():+ has_bundle = (subdir / _FUSION_BUNDLE).exists() or subdir.name == _FUSION_BUNDLE+ if not has_bundle: continue- if not _app_dir_complete(subdir): # partial: launcher but no real app binary+ if not _app_dir_complete(subdir if subdir.name != _FUSION_BUNDLE else subdir.parent): try: shutil.rmtree(subdir, ignore_errors=True) removed.append(subdir.name)@@ -268,38 +196,34 @@ def _clean_incomplete_webdeploy() -> list: def fusion_update_in_progress() -> bool:- """Best-effort: is Fusion ACTIVELY applying an auto-update right now? While it is,- Fusion crash-restarts ITSELF every ~30-60s, so verbs intermittently see 'not- running' (observed live on a fresh VM 2026-07-05: hashes 441fa88... + 6a0c961...- being streamed side by side). Callers surface a distinct non-fatal `updating`- status instead of a fatal error so a driving AI waits/retries. Never raises.-- IMPORTANT (regression fix 2026-07-06, caught on a real laptop): a bare- "2+ production dirs" count is NOT an update - Fusion NEVER prunes old webdeploy- builds, so a normal machine accumulates many stale production dirs (the laptop had- 5, from April-June, Fusion not even running). That made the flag fire permanently- and falsely. So require ACTIVE streaming: 2+ launcher builds AND at least one build- dir TOUCHED in the last ~20 min (the streamer is writing into it). Stale leftover- dirs have old mtimes and no longer trigger it."""- import time as _t- now = _t.time()+ """Best-effort: is Fusion ACTIVELY applying an auto-update right now? While it is, Fusion+ crash-restarts ITSELF every ~30-60s, so verbs intermittently see 'not running'. Callers surface+ a distinct non-fatal `updating` status instead of a fatal error so a driving AI waits/retries.++ A bare "2+ production dirs" count is NOT an update — Fusion never prunes old webdeploy builds,+ so a normal machine accumulates many stale dirs (regression caught live 2026-07-06). Require+ ACTIVE streaming: 2+ bundle-bearing builds AND at least one build dir TOUCHED in the last+ ~20 min (the streamer is writing into it). Never raises."""+ now = time.time() RECENT_SECS = 20 * 60- launcher_mtimes = []+ bundle_mtimes = [] for base in _WEBDEPLOY_BASES: try: if not base.exists(): continue for d in base.iterdir(): try:- if d.is_dir() and (d / "FusionLauncher.exe").exists():- launcher_mtimes.append(d.stat().st_mtime)+ if not d.is_dir():+ continue+ if (d / _FUSION_BUNDLE).exists() or d.name == _FUSION_BUNDLE:+ bundle_mtimes.append(d.stat().st_mtime) except OSError: continue except OSError: continue- if len(launcher_mtimes) < 2:+ if len(bundle_mtimes) < 2: return False- return any((now - mt) < RECENT_SECS for mt in launcher_mtimes)+ return any((now - mt) < RECENT_SECS for mt in bundle_mtimes) def _find_addins_dir() -> Path | None:@@ -325,80 +249,67 @@ def _check_addin_installed(addins_dir: Path | None) -> bool: return False +def _has_fusion_process() -> bool:+ """Is any Fusion 360 process running? (pgrep on the bundle binary path —+ process check is authoritative; window-title matching false-positives on+ browser tabs that mention 'Autodesk Fusion')."""+ return mac_ui.has_fusion_process()++ def _is_fusion_running() -> bool:- """Check if Fusion 360 is running (process check is authoritative).+ return _has_fusion_process() - Uses tasklist instead of window title matching to avoid false positives- from browser tabs that mention 'Autodesk Fusion' in their title.- """++def is_fusion_running() -> bool:+ """Public API: check if Fusion 360 is currently running (live check).""" return _has_fusion_process() -def _has_fusion_process() -> bool:- """Check if any Fusion 360 process is running (tasklist / pgrep)."""- if IS_MACOS:- try:- r = subprocess.run(- ["pgrep", "-f", r"Autodesk Fusion\.app/Contents/MacOS"],- stdin=subprocess.DEVNULL, capture_output=True, timeout=5,- )- return r.returncode == 0- except Exception:- return False+def family_windows() -> list:+ """Visible normal-layer windows owned by Fusion processes, in the shape the+ launch state machine consumes: {hwnd, title, pid, image, width, height,+ left, top, owner}. 'hwnd' is the CGWindowID; CGWindowList has no owner-popup+ concept, so 'owner' is always 0 and find_licensing_dialog relies on the+ shape gate. Never raises."""+ out = [] try:- # CREATE_NO_WINDOW (0x08000000) on Windows so the child cmd window doesn't- # flash on screen for every poll. The Tauri side spawns the Python bridge- # itself with CREATE_NO_WINDOW, but Python's subprocess.run/Popen creates- # a fresh console window for each child by default — without this flag,- # every fusion_running check would briefly show a black cmd window.- creationflags = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0- if hasattr(subprocess, "CREATE_NO_WINDOW"):- creationflags = subprocess.CREATE_NO_WINDOW- # stdin=DEVNULL is load-bearing: AD spawns the bridge console-less +- # detached, so the inherited stdin handle is invalid. Without an explicit- # DEVNULL, this child can block on a dead handle and deadlock the probe —- # the exact failure mode that bit the KiCad bridge.- output = subprocess.check_output(- ["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],- stdin=subprocess.DEVNULL,- stderr=subprocess.DEVNULL,- text=True,- timeout=5,- creationflags=creationflags,- )- # tasklist returns "INFO: No tasks..." when no match- return "Fusion360.exe" in output+ for w in mac_ui._fusion_windows():+ r = w["rect"]+ if r["width"] <= 0 or r["height"] <= 0:+ continue+ out.append({+ "hwnd": int(w["id"]), "title": w["title"] or "", "pid": int(w["pid"]),+ "image": w.get("owner") or "",+ "width": r["width"], "height": r["height"],+ "left": r["left"], "top": r["top"],+ "owner": 0,+ }) except Exception:- return False---def _find_windows_by_title(substring: str) -> list:- """Find all visible windows whose title contains the given substring."""- results = []- WNDENUMPROC = ctypes.WINFUNCTYPE(- ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM- )-- def callback(hwnd, _lparam):- if user32.IsWindowVisible(hwnd):- length = user32.GetWindowTextLengthW(hwnd)- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- if substring in buf.value:- results.append((hwnd, buf.value))- return True-- cb = WNDENUMPROC(callback)- _callbacks.append(cb)- user32.EnumWindows(cb, 0)- _callbacks.remove(cb)- return results+ pass+ return out -def is_fusion_running() -> bool:- """Public API: check if Fusion 360 is currently running (live check)."""- return _has_fusion_process()+def find_licensing_dialog():+ """The seat/licensing or sign-in modal ("Active Sessions Exceeded" / "Suspend Remote Session" /+ a sign-in prompt), or None.++ CGWindowList exposes no owned-popup relationship, so detection is the SHAPE gate proven on the+ Windows line: a seat/sign-in modal is always WIDE (823x262 'Suspend Remote Session', 1235x527+ 'Active Sessions Exceeded') — far wider than any docked side panel — and never the LARGEST+ Fusion window (a dialog is never bigger than the app that owns it; matching the main window is+ the false-fire that hung readiness, John 2026-07-14). Require width >= 480 (excludes narrow+ panels), 180 <= height <= 900 (excludes toolbar strips), and not the main window."""+ wins = family_windows()+ if not wins:+ return None+ main_hwnd = max(wins, key=lambda w: w["width"] * w["height"])["hwnd"]+ for w in wins:+ if w["hwnd"] == main_hwnd:+ continue+ ww, hh = w["width"], w["height"]+ if 480 <= ww <= 1500 and 180 <= hh <= 900:+ return w+ return None def wait_for_addin(timeout: int = 90, dismiss_dialogs: bool = False) -> bool:@@ -429,18 +340,15 @@ def wait_for_addin(timeout: int = 90, dismiss_dialogs: bool = False) -> bool: def _dismiss_startup_dialogs():- """Dismiss common Fusion startup dialogs (recovery, startup picker).-- Uses Win32 APIs directly — no add-in needed. Called during wait_for_addin- to unblock the main thread so the add-in can become responsive.- """+ """Dismiss common Fusion startup dialogs (recovery, startup picker) so the+ add-in's main thread unblocks and it can become responsive.""" try: from handlers.dismiss_recovery import dismiss_recovery_dialog dismiss_recovery_dialog(max_wait=2.0) except Exception: pass - # Dismiss startup picker ("What do you want to design?") and other+ # Dismiss the startup picker ("What do you want to design?") and other # Fusion dialogs by sending Escape to the Fusion main window. try: from handlers.fusion_ui import send_key_to_fusion@@ -449,366 +357,19 @@ def _dismiss_startup_dialogs(): pass -# ── Background launch: NEVER steal the user's foreground ──────────────────────-# The bridge launches Fusion for AUTOMATION, on a machine the user is actively-# working on. Fusion normally opens its splash + main window in the FOREGROUND,-# yanking focus off whatever the user is doing. That is unacceptable (John, 2026--# 07-06: "make sure new windows ALWAYS only open in the background"). So we (1)-# start the process MINIMIZED + NOT ACTIVATED via STARTUPINFO, and (2) run a short-# focus-guard thread that, whenever a Fusion window steals the foreground during-# the noisy startup window, minimizes it WITHOUT activating - returning focus to-# the user's work. The add-in still loads fully while minimized. To SHOW Fusion to-# the user later, a verb explicitly restores+foregrounds it (the ONLY time we ever-# foreground). See the fusion-bridge-dev skill "Background-launch invariant".-_SW_SHOWMINNOACTIVE = 7 # show minimized, do NOT activate/steal focus---# Every process in the Autodesk launch family whose windows must NEVER steal the-# user's foreground: the main app (Fusion360.exe), the streamed-app launcher +-# splash ("Loading additional modules" - FusionLauncher.exe), and the identity/seat-# manager (the "Signing in" + "Active Sessions Exceeded" dialogs - AdskIdentityManager-# .exe). The 1.6.33 guard only knew Fusion360.exe, so the splash + seat dialog sailed-# straight past it and yanked focus (John, 2026-07-06). Match the whole family.-_FUSION_FAMILY_IMAGES = {- "fusion360.exe", "fusionlauncher.exe", "adskidentitymanager.exe",- "adsso.exe", "adcefwebbrowser.exe", "fusion360bootstrap.exe",-}---def _fusion_family_pids() -> set:- """PIDs of every running Autodesk-launch-family process (via one tasklist CSV)."""- try:- cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0- out = subprocess.check_output(- ["tasklist", "/FO", "CSV", "/NH"],- stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=cnw,- ).decode("utf-8", "replace")- except Exception:- return set()- pids = set()- for m in re.finditer(r'"([^"]+\.exe)","(\d+)"', out):- if m.group(1).lower() in _FUSION_FAMILY_IMAGES:- pids.add(int(m.group(2)))- return pids---def _family_pid_images() -> dict:- """{pid: image_name} for every running Autodesk-launch-family process."""- try:- cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0- out = subprocess.check_output(- ["tasklist", "/FO", "CSV", "/NH"],- stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=cnw,- ).decode("utf-8", "replace")- except Exception:- return {}- m = {}- for mo in re.finditer(r'"([^"]+\.exe)","(\d+)"', out):- img = mo.group(1).lower()- if img in _FUSION_FAMILY_IMAGES:- m[int(mo.group(2))] = img- return m---def family_windows() -> list:- """Visible top-level windows owned by the Autodesk launch family (Fusion360 /- FusionLauncher / AdskIdentityManager). Each dict: {hwnd, title, pid, image, width,- height, left, top}.-- This is how the launch state machine detects the SEAT/licensing dialog- DETERMINISTICALLY - by OWNING PROCESS + size, NOT the "Fusion360" window TITLE that- fooled the title-based classifier for a whole day (the "Active Sessions Exceeded"- dialog's title is literally just "Fusion360"; the real text is CEF body content).- Never raises."""- results = []- try:- user32 = ctypes.windll.user32- HWND = ctypes.wintypes.HWND- user32.GetWindowThreadProcessId.argtypes = [HWND, ctypes.POINTER(ctypes.wintypes.DWORD)]- user32.IsWindowVisible.argtypes = [HWND]- # restype/argtypes MUST be set or the returned HWND is truncated to 32-bit on- # 64-bit Windows and the owner handle comes back wrong (same class of bug that- # silently broke the focus-guard, 2026-07-06).- user32.GetWindow.restype = HWND- user32.GetWindow.argtypes = [HWND, ctypes.c_uint]- fam = _fusion_family_pids()- if not fam:- return results- pid_img = _family_pid_images()- except Exception:- return results- WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)-- def cb(hwnd, _l):- try:- if not user32.IsWindowVisible(hwnd):- return True- wpid = ctypes.wintypes.DWORD(0)- user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid))- if wpid.value not in fam:- return True- n = user32.GetWindowTextLengthW(hwnd)- title = ""- if n:- buf = ctypes.create_unicode_buffer(n + 1)- user32.GetWindowTextW(hwnd, buf, n + 1)- title = buf.value- rect = ctypes.wintypes.RECT()- user32.GetWindowRect(hwnd, ctypes.byref(rect))- w = rect.right - rect.left- h = rect.bottom - rect.top- if w <= 0 or h <= 0:- return True- GW_OWNER = 4- owner = user32.GetWindow(hwnd, GW_OWNER)- results.append({- "hwnd": int(hwnd), "title": title, "pid": int(wpid.value),- "image": pid_img.get(int(wpid.value), ""),- "width": w, "height": h, "left": rect.left, "top": rect.top,- "owner": int(owner) if owner else 0,- })- except Exception:- pass- return True-- cb_c = WNDENUMPROC(cb)- _callbacks.append(cb_c)- try:- user32.EnumWindows(cb_c, 0)- except Exception:- pass- try:- _callbacks.remove(cb_c)- except ValueError:- pass- return results---def find_licensing_dialog():- """The seat/licensing or sign-in modal ("Active Sessions Exceeded" / "Suspend Remote- Session" / a sign-in prompt). Detected DETERMINISTICALLY by the OWNED-POPUP signal,- NOT a fragile height window:-- A seat/sign-in modal is ALWAYS an owned popup of the main Fusion window (confirmed- via desktop_screenshot_window's ownedPopupCount). The splash and the maximized main- app are NEVER owned popups. So "family window that has an owner" is the reliable- tell - it does not care about the exact dialog size or the "Fusion360" title.-- This replaced a size-only filter whose 320px height floor SILENTLY MISSED the- 823x262 "Suspend Remote Session" confirm variant, so readiness reported the dialog- gone while it sat there blocking (John caught it live, 2026-07-06). A size-based- fallback is kept for safety in case owner detection ever returns 0.- Returns the window dict (with 'owner'), or None.-- SHAPE matters, not just ownership: Fusion's own side panels (Data Panel, browser, the- comments rail) are ~300px-wide OWNED popups too, so an owned-only match false-fires the- moment a document is open and reads a drivable Fusion as 'blocked' (caught live on winvm- 2026-07-06, right after this over-broad owned check shipped). The seat/sign-in modal is- always WIDE (823x262 'Suspend Remote Session', 1235x527 'Active Sessions Exceeded') - far- wider than any side panel - so we require dialog PROPORTIONS: width >= 480 (excludes the- narrow panels), height >= 180 (excludes toolbar strips), and smaller than the maximized- main app."""- wins = family_windows()- if not wins:- return None- # NEVER match the MAIN app window. A seat/sign-in modal is a smaller popup; the main Fusion- # window is the LARGEST family window. The old no-owner fallback matched the main window whenever- # it happened to fall inside the shape gate (e.g. a non-maximized 1300x820 window fits- # 480<=w<=1500, 180<=h<=900) - so find_licensing_dialog false-fired on the drivable main app,- # and once ad_client came online the seat resolver churned 8x on it and HUNG readiness (John,- # live 2026-07-14). Excluding the largest window is size-independent and safe: a dialog is never- # bigger than the app that owns it.- main_hwnd = max(wins, key=lambda w: w["width"] * w["height"])["hwnd"]- # Primary: an OWNED popup that is DIALOG-SHAPED (wide modal, not a narrow side panel).- for w in wins:- if w["hwnd"] == main_hwnd:- continue- ww, hh = w["width"], w["height"]- if w.get("owner") and 480 <= ww <= 1500 and 180 <= hh <= 900:- return w- # Fallback (owner==0 for all - owner detection failed): same shape gate, still NOT the main window.- for w in wins:- if w["hwnd"] == main_hwnd:- continue- ww, hh = w["width"], w["height"]- if 480 <= ww <= 1500 and 180 <= hh <= 900:- return w- return None---def _fusion_pids() -> set:- """PIDs of running Fusion360.exe processes (via tasklist CSV). Empty on error."""- try:- cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0- out = subprocess.check_output(- ["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],- stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=cnw,- ).decode("utf-8", "replace")- except Exception:- return set()- return {int(p) for p in re.findall(r'"Fusion360\.exe","(\d+)"', out)}---_SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000-_SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001-_SPI_GETANIMATION = 0x0048-_SPI_SETANIMATION = 0x0049---class _ANIMATIONINFO(ctypes.Structure):- _fields_ = [("cbSize", ctypes.c_uint), ("iMinAnimate", ctypes.c_int)]---def _get_min_animate():- """Current 'animate windows when minimizing/maximizing' setting (1=on), or None."""- try:- user32 = ctypes.windll.user32- ai = _ANIMATIONINFO()- ai.cbSize = ctypes.sizeof(_ANIMATIONINFO)- user32.SystemParametersInfoW(_SPI_GETANIMATION, ai.cbSize, ctypes.byref(ai), 0)- return int(ai.iMinAnimate)- except Exception:- return None---def _set_min_animate(val) -> bool:- """Turn the minimize/maximize ANIMATION on(1)/off(0). We turn it OFF during launch- so the focus-guard's minimize is an INSTANT single-frame vanish rather than the- visible slide-down that read as a 'flash' (John 2026-07-06). Restored to the user's- original value when the launch window ends. Best-effort; no persist/broadcast."""- try:- user32 = ctypes.windll.user32- ai = _ANIMATIONINFO()- ai.cbSize = ctypes.sizeof(_ANIMATIONINFO)- ai.iMinAnimate = int(val)- user32.SystemParametersInfoW(_SPI_SETANIMATION, ai.cbSize, ctypes.byref(ai), 0)- return True- except Exception:- return False---def _get_fg_lock_timeout():- """Current SystemParametersInfo foreground-lock timeout (ms), or None."""- try:- user32 = ctypes.windll.user32- val = ctypes.wintypes.DWORD(0)- user32.SystemParametersInfoW(_SPI_GETFOREGROUNDLOCKTIMEOUT, 0, ctypes.byref(val), 0)- return int(val.value)- except Exception:- return None---def _set_fg_lock_timeout(ms) -> bool:- """Set the foreground-lock timeout. A NONZERO value makes Windows DENY foreground- changes from processes that don't own the foreground / have no recent user input -- they get a taskbar flash instead of an actual foreground grab. We raise it right- before launching Fusion so its splash/main/seat windows can't STEAL focus in the- first place (proactive), rather than relying only on the reactive minimize-guard- (which leaves a visible flash as the window appears-then-shrinks). Restored to the- user's original value when the guard's launch window ends. Best-effort."""- try:- user32 = ctypes.windll.user32- user32.SystemParametersInfoW.argtypes = [- ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint]- user32.SystemParametersInfoW(- _SPI_SETFOREGROUNDLOCKTIMEOUT, 0, ctypes.c_void_p(int(ms)), 0)- return True- except Exception:- return False---def _popen_fusion_background(exe_path: str):- """Launch the Fusion launcher MINIMIZED + NOT ACTIVATED, console-less."""- if IS_MACOS:- # exe_path is the .app bundle — `open -g` launches it in the background- # without stealing focus (the mac analog of SW_SHOWMINNOACTIVE).- return subprocess.Popen(- ["open", "-g", str(exe_path)],- stdin=subprocess.DEVNULL,- stdout=subprocess.DEVNULL,- stderr=subprocess.DEVNULL,- )- cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0- startupinfo = None- try:- startupinfo = subprocess.STARTUPINFO()- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW- startupinfo.wShowWindow = _SW_SHOWMINNOACTIVE- except Exception:- startupinfo = None+def _popen_fusion_background(app_path: str):+ """Launch the Fusion bundle in the BACKGROUND: `open -g` starts it without+ stealing the user's focus (John, 2026-07-06: "new windows ALWAYS only open+ in the background"). The add-in still loads fully unfocused; a verb+ explicitly foregrounds Fusion later when the user should see it.""" return subprocess.Popen(- [exe_path],+ ["open", "-g", str(app_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,- creationflags=cnw,- startupinfo=startupinfo, ) -def _focus_guard(seconds: float = 150.0, restore_fg_lock=None, restore_min_anim=None):- """For `seconds`, demote any Autodesk-FAMILY window that steals the foreground.-- SURGICAL: only acts when the CURRENT foreground window belongs to an Autodesk- launch-family process (Fusion / launcher-splash / identity-seat manager) - i.e.- something just grabbed focus off the user. It minimizes that window without- activating anything, so focus falls back to whatever the user was in. It does- NOT touch the family when the user is working in another app, so it never- fights a user who deliberately foregrounds Fusion themselves. Best-effort;- never raises."""- try:- user32 = ctypes.windll.user32- # ⚠️ CRITICAL: set restype/argtypes or ctypes defaults every HANDLE to c_int- # (32-bit) and TRUNCATES it on 64-bit Windows - GetForegroundWindow then hands- # back a garbage HWND and ShowWindowAsync silently no-ops (the guard fires into- # the void; the window is never minimized - the exact 1.6.34 failure, 2026-07-06).- HWND = ctypes.wintypes.HWND- user32.GetForegroundWindow.restype = HWND- user32.GetForegroundWindow.argtypes = []- user32.GetWindowThreadProcessId.argtypes = [HWND, ctypes.POINTER(ctypes.wintypes.DWORD)]- user32.GetWindowThreadProcessId.restype = ctypes.wintypes.DWORD- user32.ShowWindowAsync.argtypes = [HWND, ctypes.c_int]- user32.ShowWindowAsync.restype = ctypes.wintypes.BOOL- except Exception:- return- end = time.time() + seconds- pids = set()- last_refresh = 0.0- while time.time() < end:- now = time.time()- if now - last_refresh > 2.0:- pids = _fusion_family_pids() # whole family, not just Fusion360.exe- last_refresh = now- try:- fg = user32.GetForegroundWindow()- if fg and pids:- wpid = ctypes.wintypes.DWORD(0)- user32.GetWindowThreadProcessId(fg, ctypes.byref(wpid))- if wpid.value in pids:- # minimize the focus-stealing family window, no activation- user32.ShowWindowAsync(fg, _SW_SHOWMINNOACTIVE)- except Exception:- pass- time.sleep(0.1)- # Launch window over: restore the user's original foreground-lock + minimize- # animation (we only changed them DURING Fusion's noisy startup).- if restore_fg_lock is not None:- _set_fg_lock_timeout(restore_fg_lock)- if restore_min_anim is not None:- _set_min_animate(restore_min_anim)---def _start_focus_guard(seconds: float = 150.0, restore_fg_lock=None, restore_min_anim=None):- """Fire-and-forget the focus guard on a daemon thread. Never raises."""- try:- threading.Thread(- target=_focus_guard, args=(seconds, restore_fg_lock, restore_min_anim),- daemon=True,- ).start()- except Exception:- pass-- def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict | None: """Launch Fusion 360 if not already running. @@ -827,9 +388,9 @@ def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict | "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.",+ "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.", } return None # Already running (and add-in ready if requested) @@ -838,39 +399,22 @@ def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict | return {"success": False, "error": "Fusion 360 executable not found"} try:- # PROACTIVE: raise the foreground-lock timeout so Windows DENIES Fusion's- # splash/main/seat windows the ability to steal focus in the first place (they- # get a taskbar flash, not a real foreground grab). This is what stops the- # visible FLICKER the reactive guard alone leaves. Restored by the guard at the- # end of the launch window.- _orig_fg_lock = _get_fg_lock_timeout()- _set_fg_lock_timeout(300000)- # Disable the minimize ANIMATION so the guard's minimize is an INSTANT vanish- # (no visible slide-down = no perceptible flash). Restored by the guard.- _orig_min_anim = _get_min_animate()- _set_min_animate(0)- # Launch MINIMIZED + NOT ACTIVATED (STARTUPINFO). DEVNULL on all three std- # streams + CREATE_NO_WINDOW: the bridge is a detached, console-less child of- # AD, so inherited handles are invalid. _popen_fusion_background(exe_path)- # Reactive backstop: minimize any family window that still slips to foreground,- # and restore the user's original foreground-lock + animation when done.- _start_focus_guard(seconds=150.0, restore_fg_lock=_orig_fg_lock,- restore_min_anim=_orig_min_anim) except Exception as e: return {"success": False, "error": f"Failed to launch Fusion 360: {e}"} - # Wait for Fusion process to appear (up to 30s for launcher to spawn main process)+ # Wait for the Fusion process to appear (up to 30s) for _ in range(60): time.sleep(0.5) if _has_fusion_process(): break else:- # LAUNCH FAILED - do NOT return a blind "process not found". READ the dialog that is almost- # certainly on screen. The classic fresh-box cause is an INCOMPLETE stream: "Error Launching- # Streamed Application ... FusionLauncher.exe.ini is missing or incomplete" (John, live on a- # fresh Hyper-V VM 2026-07-14). Detect + dismiss it, CLEAN the corrupt webdeploy dir(s), and- # tell the caller to re-stream - instead of silently sitting on a broken install.+ # LAUNCH FAILED - do NOT return a blind "process not found". READ the dialog that may be on+ # screen, and DISTINGUISH the two causes (conflating them mis-blamed a healthy install,+ # John 2026-07-14):+ # (a) app is COMPLETE but launch failed -> the install is FINE; do NOT re-stream.+ # (b) app is NOT complete -> a genuinely half-streamed install; clean the partial dirs so+ # the next install re-streams clean. launch_err = None try: from handlers.dialog_classify import classify_launch_dialogs, close_dialog_bg@@ -881,36 +425,29 @@ def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict | except Exception: pass - # DISTINGUISH the two causes (John 2026-07-14 - conflating them mis-blamed a healthy install):- # (a) app is COMPLETE (Fusion360.exe present) but launch failed -> almost always we launched a- # FusionLauncher.exe WITHOUT its .ini, or a stale lock from a just-killed instance. The- # install is FINE; do NOT tell the user to re-stream. _find_fusion_launcher now prefers the- # launcher-with-.ini, so a retry usually succeeds.- # (b) app is NOT complete -> a genuinely half-streamed install; clean the partial dirs so the- # next fusion_install_fusion re-streams clean. if _any_app_complete(): return { "success": False,- "error": "Fusion is INSTALLED (Fusion360.exe present) but did not start within 30s"+ "error": "Fusion is INSTALLED (complete bundle present) but did not start within 30s" + (" - a launch-error dialog was dismissed" if launch_err else "") + ".", "errorCode": "fusion_launch_failed", "installed": True, "dialog": (launch_err or {}).get("title"),- "_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.",+ "_hint": "The install is COMPLETE - do NOT re-stream. Retry fusion_start (a stale "+ "lock from a just-killed instance clears on a retry). If it keeps failing, "+ "fusion_kill then wait a few seconds before fusion_start.", } removed = _clean_incomplete_webdeploy() return { "success": False,- "error": "Fusion launch failed: the streamed install is INCOMPLETE (no Fusion360.exe yet).",+ "error": "Fusion launch failed: the streamed install is INCOMPLETE (no complete bundle yet).", "errorCode": "fusion_incomplete_install", "cleanedWebdeployDirs": removed, "dialog": (launch_err or {}).get("title"),- "_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.",+ "_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.", } # Give it time to finish initializing UI@@ -933,6 +470,11 @@ def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict | return None +# Back-compat alias: callers that predate the mac-only rewrite import+# _find_fusion_launcher; on macOS the "launcher" IS the app bundle path.+_find_fusion_launcher = _find_fusion_app++ if __name__ == "__main__": info = detect_fusion() print(json.dumps(info, indent=2))
handlers/close_fusion.py+47−123@@ -1,136 +1,60 @@-"""Handler for close_fusion command.+"""Handler for close_fusion command — macOS. -Closes all open documents cleanly via the add-in (avoiding recovered documents-dialog on next launch), then sends WM_CLOSE to all Fusion 360 windows, with-optional force-kill.+Closes all open documents cleanly via the add-in (avoiding the recovered+documents dialog on next launch), then closes every Fusion window (native+Quit for the main window, AX close for dialogs), with optional force-kill. IMPORTANT: Always close documents via the add-in BEFORE killing Fusion.-taskkill //f causes unsaved document recovery on next launch, which shows a+A force-kill causes unsaved document recovery on next launch, which shows a blocking modal dialog that prevents automation. """ -import ctypes import json-import subprocess-import sys import time import urllib.request import urllib.error -IS_MACOS = sys.platform == "darwin"-if IS_MACOS:- from handlers import mac_ui as _mac-else:- import ctypes.wintypes--WM_CLOSE = 0x0010--# Guard windll so the module imports on non-Windows hosts (the bridge must boot-# + serve /status anywhere); these Win32 paths are only reached on Windows.-user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None--_callbacks = []--# Fusion process names to kill when force-stopping-FUSION_PROCESS_NAMES = [- "Fusion360.exe", "FusionLauncher.exe", "Fusion.exe",- "FusionService.exe", "FusionCEF.exe", "Fusion360Helper.exe",-]+from handlers import mac_ui as _mac def _find_windows_by_title(substring: str) -> list:- """Find all visible windows whose title contains the given substring."""- if IS_MACOS:- # mac window titles often omit the app name, so ALSO include every- # normal window owned by a Fusion process when searching for Fusion.- found = dict(_mac.find_windows_by_title(substring))- if "autodesk fusion" in substring.lower():- for w in _mac._fusion_windows():- found.setdefault(w["id"], w["title"])- return list(found.items())- results = []- WNDENUMPROC = ctypes.WINFUNCTYPE(- ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM- )-- def callback(hwnd, _lparam):- if user32.IsWindowVisible(hwnd):- length = user32.GetWindowTextLengthW(hwnd)- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- if substring in buf.value:- results.append((hwnd, buf.value))- return True-- cb = WNDENUMPROC(callback)- _callbacks.append(cb)- user32.EnumWindows(cb, 0)- _callbacks.remove(cb)- return results---def _close_and_wait(hwnd: int, title: str, timeout: float = 15.0) -> bool:- """Send WM_CLOSE and wait for the window to disappear.-- Fusion 360 may take longer to close than typical apps, so default timeout is 15s.- """- if IS_MACOS:- # Prefer the app-level native quit for a MAIN Fusion window (macOS apps- # close cleanly via Quit, not per-window close), fall back to the AX- # close button for dialogs/secondary windows.- if hwnd == _mac.find_fusion_main_winid():- _mac.quit_fusion_native()- deadline = time.monotonic() + timeout- while time.monotonic() < deadline:- time.sleep(0.3)- if not _mac.window_valid(hwnd):- return True- return False- return _mac.close_and_wait(hwnd, timeout)- user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)-- deadline = time.monotonic() + timeout- while time.monotonic() < deadline:- time.sleep(0.3)- if not user32.IsWindow(hwnd) or not user32.IsWindowVisible(hwnd):- return True- return False+ """(winid, title) pairs of visible windows whose title contains the substring.++ mac window titles often omit the app name, so ALSO include every normal+ window owned by a Fusion process when searching for Fusion."""+ found = dict(_mac.find_windows_by_title(substring))+ if "autodesk fusion" in substring.lower():+ for w in _mac._fusion_windows():+ found.setdefault(w["id"], w["title"])+ return list(found.items())+++def _close_and_wait(winid: int, title: str, timeout: float = 15.0) -> bool:+ """Close a window and wait for it to disappear.++ Prefer the app-level native quit for a MAIN Fusion window (macOS apps+ close cleanly via Quit, not per-window close); fall back to the AX close+ button for dialogs/secondary windows. Fusion may take longer to close+ than typical apps, so the default timeout is 15s."""+ if winid == _mac.find_fusion_main_winid():+ _mac.quit_fusion_native()+ deadline = time.monotonic() + timeout+ while time.monotonic() < deadline:+ time.sleep(0.3)+ if not _mac.window_valid(winid):+ return True+ return False+ return _mac.close_and_wait(winid, timeout) def _force_kill_fusion() -> int: """Force kill all Fusion processes. Returns number of processes killed."""- if IS_MACOS:- return _mac.kill_fusion()- killed = 0- for name in FUSION_PROCESS_NAMES:- try:- result = subprocess.run(- ["taskkill", "/F", "/IM", name],- stdout=subprocess.DEVNULL,- stderr=subprocess.DEVNULL,- )- if result.returncode == 0:- killed += 1- except Exception:- pass- return killed+ return _mac.kill_fusion() def _has_fusion_process() -> bool: """Check if any Fusion process is still running."""- if IS_MACOS:- return _mac.has_fusion_process()- try:- output = subprocess.check_output(- ["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],- stderr=subprocess.DEVNULL,- text=True,- timeout=5,- )- return "Fusion360.exe" in output- except Exception:- return False+ return _mac.has_fusion_process() ADDIN_PORT = 8774@@ -226,7 +150,7 @@ def handle_close_fusion(fusion_info: dict, args: dict) -> dict: return { "success": False, "error": "Some Fusion processes survived force-kill.",- "_hint": "Report to the user and ask them to manually kill remaining Fusion processes via Task Manager, then retry.",+ "_hint": "Report to the user and ask them to manually kill remaining Fusion processes via Activity Monitor, then retry.", } return { "success": True,@@ -235,7 +159,7 @@ def handle_close_fusion(fusion_info: dict, args: dict) -> dict: "data": {"documentsClosed": close_report["closed"]}, } - # Graceful close: send WM_CLOSE to windows+ # Graceful close windows = _find_windows_by_title("Autodesk Fusion") if not windows:@@ -249,8 +173,8 @@ def handle_close_fusion(fusion_info: dict, args: dict) -> dict: closed = [] failed = []- for hwnd, title in windows:- if _close_and_wait(hwnd, title):+ for winid, title in windows:+ if _close_and_wait(winid, title): closed.append(title) else: failed.append(title)@@ -262,7 +186,7 @@ def handle_close_fusion(fusion_info: dict, args: dict) -> dict: if not _has_fusion_process(): return { "success": True,- "output": f"Fusion 360 force-killed after graceful close failed.",+ "output": "Fusion 360 force-killed after graceful close failed.", } return { "success": False,@@ -287,8 +211,8 @@ def handle_fusion_stop(fusion_info: dict, args: dict) -> dict: """Gracefully STOP Fusion 360 - the clean opposite of fusion_start. Closes all docs cleanly via the add-in (avoids the 'Recovered Documents' dialog),- then WM_CLOSE every Fusion window and waits. Does NOT force-kill: if a window will- not close (usually a modal dialog blocking WM_CLOSE), it returns failure and tells+ then closes every Fusion window and waits. Does NOT force-kill: if a window will+ not close (usually a modal dialog blocking the close), it returns failure and tells you to use fusion_kill. No surprise escalation - that's the whole point. Args: skipCleanClose (bool, default false) - skip the add-in document close.@@ -311,15 +235,15 @@ def handle_fusion_stop(fusion_info: dict, args: dict) -> dict: return {"success": True, "output": "Fusion 360 is not running."} closed, failed = [], []- for hwnd, title in windows:- (closed if _close_and_wait(hwnd, title) else failed).append(title)+ for winid, title in windows:+ (closed if _close_and_wait(winid, title) else failed).append(title) if failed: return { "success": False, "error": f"Graceful stop failed for: {', '.join(failed)}", "output": f"Closed: {', '.join(closed)}" if closed else "",- "_hint": "A window would not close gracefully (a modal dialog often blocks WM_CLOSE). "+ "_hint": "A window would not close gracefully (a modal dialog often blocks it). " "Use fusion_kill to force-kill, then retry.", } @@ -336,7 +260,7 @@ def handle_fusion_stop(fusion_info: dict, args: dict) -> dict: def handle_fusion_kill(fusion_info: dict, args: dict) -> dict:- """Force-KILL Fusion 360 (taskkill /F) - the 'desperate' path for when fusion_stop+ """Force-KILL Fusion 360 - the 'desperate' path for when fusion_stop can't close it (a stuck modal, a wedged process). Still closes docs cleanly via the add-in FIRST when reachable, to avoid the@@ -356,7 +280,7 @@ def handle_fusion_kill(fusion_info: dict, args: dict) -> dict: return { "success": False, "error": "Some Fusion processes survived force-kill.",- "_hint": "Ask the user to kill remaining Fusion processes via Task Manager, then retry.",+ "_hint": "Ask the user to kill remaining Fusion processes via Activity Monitor, then retry.", } return { "success": True,
handlers/dialog_classify.py+29−64@@ -7,11 +7,11 @@ happens during an add-in command, the add-in's HTTP server can't respond and the bridge reports a misleading "add-in not responding / may have crashed" — which sends callers into a pointless restart loop. -Qt dialog *titles* are enumerable over Win32 even while the add-in is blocked-(unlike the dialogs' CEF/Qt child controls, which are not). So we can cheaply-identify WHICH modal is up and return an actionable resolution instead of a fake-crash. This is bridge-wide: it helps every command that can be blocked by a-modal, not just exports.+Dialog *titles* are enumerable via CGWindowList even while the add-in is+blocked (unlike the dialogs' CEF/Qt child controls, which are not). So we can+cheaply identify WHICH modal is up and return an actionable resolution instead+of a fake crash. This is bridge-wide: it helps every command that can be+blocked by a modal, not just exports. """ from handlers.fusion_ui import get_fusion_window_info@@ -46,10 +46,10 @@ _RULES = [ (("select electronics design", "multiple electronics design", "linked to multiple"), "linked_design_picker", "This file links to multiple Electronics designs. The chooser is a CEF/web "- "modal whose list is NOT keyboard- or Win32-navigable (SendInput reaches only "- "the native Cancel/OK), so it can't be resolved headlessly today — select the "- "design on the desktop. (Headless fix tracked: have the add-in open the specific "- "linked design via the Fusion API, avoiding the picker entirely.)"),+ "modal whose list is NOT keyboard- or accessibility-navigable (input reaches "+ "only the native Cancel/OK), so it can't be resolved headlessly today — select "+ "the design on the desktop. (Headless fix tracked: have the add-in open the "+ "specific linked design via the Fusion API, avoiding the picker entirely.)"), (("recovery", "recover unsaved", "document recovery"), "recovery",@@ -104,14 +104,14 @@ _RULES = [ "click), fetch any email OTP via adom-google, and after the browser shows \"You're signed in\" " "click its 'Open Product' button - but NEVER auto-enter the password/2FA. See fusion-aps-signin."), - # webdeploy launch failure - an INCOMPLETE/stale production folder (missing- # FusionLauncher.exe.ini). Benign to dismiss; means you launched the wrong hash.+ # webdeploy launch failure - an INCOMPLETE/stale production folder. Benign to+ # dismiss; means the wrong (half-streamed) hash dir was launched. (("error launching streamed application", "missing or incomplete", "please re-install", "re-install the application"), "launch_error",- "Launch error from an INCOMPLETE webdeploy folder (missing FusionLauncher.exe.ini). Safe to "- "dismiss (OK/close) - it is only an acknowledgement. Launch the COMPLETE production folder (the "- "hash dir that HAS FusionLauncher.exe.ini); detect_fusion now prefers it."),+ "Launch error from an INCOMPLETE webdeploy folder. Safe to dismiss (OK/close) - it is only an "+ "acknowledgement. Launch the COMPLETE production folder (the hash dir whose app bundle binary "+ "finished streaming); detect_fusion only returns complete bundles."), # ⚠️ OPERATION FAILED. A bare "Error"-titled owned popup means the LAST command did # NOT succeed even if the API call returned ok - e.g. an .lbr that "has errors and@@ -120,7 +120,7 @@ _RULES = [ # command's success to False when it sees this (see server._apply_failure_dialogs), so # a malformed library can never again be reported as "opened". Keep this rule LAST so # the specific "error launching..."/seat rules above win first. It is a pure OK- # acknowledgement, so it is safe to close (WM_CLOSE) after the failure is surfaced.+ # acknowledgement, so it is safe to close in the background after the failure is surfaced. (("error", "cannot be opened", "has errors", "failed to", "could not", "invalid"), "operation_error", "The last operation FAILED - Fusion is showing an Error dialog (the API call may still have "@@ -139,7 +139,7 @@ FAILURE_CATEGORIES = {"operation_error"} # Categories that require a USER decision — the launch code must NOT auto-dismiss # these; it surfaces them + fires a notify_user + asks. DECISION_CATEGORIES = {"session_conflict_decision", "session_suspend_confirm"}-# Categories the launch code MAY auto-dismiss (a benign ack; WM_CLOSE == Cancel/OK).+# Categories the launch code MAY auto-dismiss (a benign ack; a background close == Cancel/OK). # Everything else (decisions, sign-in) is SURFACED, never auto-closed. AUTO_DISMISS_CATEGORIES = {"launch_error", "already_open"} # Launch/setup categories enumerated across ALL top-level windows (not just Fusion).@@ -155,7 +155,7 @@ def _category_for_title(title: str): return category, resolution # A bare "Fusion"/"Fusion360"-titled modal is generic - its body text (the part # that tells you what it actually IS) lives in CEF/Qt child controls that are NOT- # Win32-enumerable, so we cannot classify it by title alone. The two it most often+ # window-list-enumerable, so we cannot classify it by title alone. The two it most often # is, depending on context: during a file OPEN, the "Select Electronics Design File" # chooser; during a CLOSE or SAVE, the Hub "packages are being uploaded... sure you # want to close?" data-loss confirm. So the only safe instruction is: READ the@@ -163,13 +163,13 @@ def _category_for_title(title: str): # dismiss - clicking Yes on the upload confirm destroys the packages. if t in ("fusion", "fusion360", "autodesk fusion 360", "fusion 360"): return ("generic_modal_read_screenshot",- "Generic Fusion modal - its body is not Win32-readable, so a screenshot "+ "Generic Fusion modal - its body is not readable from the window list, so a screenshot " "is attached: READ it and ANALYZE before acting. Do NOT blind-dismiss. " "If it mentions uploading / 'lose these changes' / 'sure you want to " "close' -> click No and wait for the Hub upload to drain (fusion-cloud-save). " "If it is the 'Select Electronics Design File' chooser during an open, its "- "list is not keyboard/Win32-navigable; open the specific design by URN or "- "select on the desktop.")+ "list is not keyboard/accessibility-navigable; open the specific design by "+ "URN or select on the desktop.") return ("unknown", "Unrecognized modal - a screenshot is attached: READ it and ANALYZE before " "acting. Do NOT blindly dismiss (some modals lose work if confirmed wrong). "@@ -181,41 +181,14 @@ def _enumerate_all_top_level(): Unlike get_fusion_window_info (scoped to the Fusion process), this catches dialogs owned by the Autodesk FAMILY - AdskIdentityManager (sign-in / seat- conflict) and FusionLauncher (streamed-app launch errors) - which are the+ conflict) and the launcher (streamed-app launch errors) - which are the ones that block a first launch. Best-effort; never raises. """- import ctypes- import sys- if sys.platform == "darwin":- try:- from handlers import mac_ui as _mac- return _mac.enumerate_all_top_level()- except Exception:- return []- results = []- user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None- if not user32:- return results- WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)-- def _cb(hwnd, _lparam):- try:- if user32.IsWindowVisible(hwnd):- n = user32.GetWindowTextLengthW(hwnd)- if n:- buf = ctypes.create_unicode_buffer(n + 1)- user32.GetWindowTextW(hwnd, buf, n + 1)- if buf.value.strip():- results.append((hwnd, buf.value))- except Exception:- pass- return True- try:- user32.EnumWindows(WNDENUMPROC(_cb), 0)+ from handlers import mac_ui as _mac+ return _mac.enumerate_all_top_level() except Exception:- pass- return results+ return [] def classify_launch_dialogs() -> list:@@ -245,21 +218,13 @@ def classify_launch_dialogs() -> list: def close_dialog_bg(hwnd) -> bool:- """WM_CLOSE a window in the BACKGROUND (PostMessage = no focus steal). Used to- auto-dismiss benign launch-error acks. Never raises; returns True if posted."""- import ctypes- import sys- if sys.platform == "darwin":- try:- from handlers import mac_ui as _mac- return _mac.close_window_bg(hwnd)- except Exception:- return False- user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None- if not user32 or not hwnd:+ """Close a window in the BACKGROUND (AX close button = no focus steal). Used to+ auto-dismiss benign launch-error acks. Never raises; returns True if closed."""+ if not hwnd: return False try:- return bool(user32.PostMessageW(ctypes.c_void_p(int(hwnd)), 0x0010, 0, 0)) # WM_CLOSE+ from handlers import mac_ui as _mac+ return _mac.close_window_bg(hwnd) except Exception: return False
handlers/dismiss_recovery.py+57−200@@ -1,67 +1,40 @@-"""Dismiss Fusion 360 recovery dialogs via Win32 API.+"""Dismiss Fusion 360 recovery dialogs — macOS. After a crash or force-kill, Fusion shows two types of recovery dialogs: -1. "Recovered Documents" list dialog (Qt window titled "Fusion360"):+1. "Recovered Documents" list dialog (window titled "Fusion360"): Shows all recovery files with Open/Delete/Close buttons.- Dismissed via WM_CLOSE.+ Dismissed via the AX close button (mac_ui.close_window_bg). -2. "Open recovery document instead?" prompt (Qt window with that title):+2. "Open recovery document instead?" prompt: Appears when opening a file that has a recovery document.- Has Yes/No buttons. We click No (Tab + Enter) to skip recovery- and open the cloud version instead.--Both are Qt-rendered dialogs with no Win32 child controls, so we use-keyboard navigation (Tab/Enter) instead of button clicking.+ Has Yes/No buttons. We press Tab + Enter to choose No, skipping+ recovery and opening the cloud version instead. Recovery files are NOT deleted — they are moved to ~/.adom/recovery/fusion/ so the user can manually recover them if needed. Each batch is timestamped. """ -import ctypes-import glob import os import shutil-import sys import time -IS_MACOS = sys.platform == "darwin"-if IS_MACOS:- from handlers import mac_ui as _mac-else:- import ctypes.wintypes--# Guard windll so the module imports on non-Windows hosts (the bridge must boot-# + serve /status anywhere); these Win32 paths are only reached on Windows.-user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None--WM_CLOSE = 0x0010-WM_COMMAND = 0x0111-BM_CLICK = 0x00F5--# Keep callback references alive to prevent GC-_callbacks = []+from handlers import mac_ui as _mac def _get_fusion_crash_recovery_dirs() -> list:- """Find all CrashRecovery directories under Fusion's local app data.+ """Find all CrashRecovery directories under Fusion's app-support data. Fusion stores recovery files at:- %LOCALAPPDATA%/Autodesk/Autodesk Fusion 360/<USER_ID>/CrashRecovery/+ ~/Library/Application Support/Autodesk/Autodesk Fusion (360)/<USER_ID>/CrashRecovery/ There may be multiple user ID folders. Returns list of existing CrashRecovery dirs that contain at least one file. """- if IS_MACOS:- bases = [- os.path.expanduser("~/Library/Application Support/Autodesk/Autodesk Fusion 360"),- os.path.expanduser("~/Library/Application Support/Autodesk/Autodesk Fusion"),- ]- else:- bases = [os.path.join(- os.environ.get("LOCALAPPDATA", ""),- "Autodesk", "Autodesk Fusion 360"- )]+ bases = [+ os.path.expanduser("~/Library/Application Support/Autodesk/Autodesk Fusion 360"),+ os.path.expanduser("~/Library/Application Support/Autodesk/Autodesk Fusion"),+ ] dirs = [] for base in bases:@@ -142,146 +115,39 @@ def relocate_recovery_files() -> dict: def _find_window_by_title(title_substring: str) -> int:- """Find a visible window whose title contains the given substring."""- if IS_MACOS:- found = _mac.find_windows_by_title(title_substring)- return found[0][0] if found else 0- result = [0]- WNDENUMPROC = ctypes.WINFUNCTYPE(- ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM- )-- def callback(hwnd, _lparam):- if user32.IsWindowVisible(hwnd):- length = user32.GetWindowTextLengthW(hwnd)- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- if title_substring in buf.value:- result[0] = hwnd- return False # Stop enumeration- return True-- cb = WNDENUMPROC(callback)- _callbacks.append(cb)- user32.EnumWindows(cb, 0)- _callbacks.remove(cb)- return result[0]+ """CGWindowID of the first visible window whose title contains the substring, or 0."""+ found = _mac.find_windows_by_title(title_substring)+ return found[0][0] if found else 0 def _find_fusion_dialog_windows() -> list:- """Find small Qt dialog windows that belong to Fusion 360.-- Fusion's recovery dialog is a Qt window with class 'Qt655QWindowIcon'- and title 'Fusion360' (NOT 'Recovered Documents'). It's smaller than- the main Fusion window. We detect it by finding Qt windows titled- 'Fusion360' that are NOT the main Fusion window (which has- 'Autodesk Fusion' in its title).- """- if IS_MACOS:- # Fusion-owned non-main windows titled like the recovery list dialog.- return [d["hwnd"] for d in _mac.find_dialog_windows()- if (d.get("title") or "").strip().lower()- in ("fusion360", "fusion", "autodesk fusion")]- results = []- WNDENUMPROC = ctypes.WINFUNCTYPE(- ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM- )-- def callback(hwnd, _lparam):- if user32.IsWindowVisible(hwnd):- # Get class name- cls_buf = ctypes.create_unicode_buffer(256)- user32.GetClassNameW(hwnd, cls_buf, 256)- class_name = cls_buf.value-- # Get title- length = user32.GetWindowTextLengthW(hwnd)- title = ""- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- title = buf.value-- # Look for Qt dialog windows titled "Fusion360" (not the main window)- if "Qt" in class_name and title == "Fusion360":- results.append(hwnd)- return True-- cb = WNDENUMPROC(callback)- _callbacks.append(cb)- user32.EnumWindows(cb, 0)- _callbacks.remove(cb)- return results---def _find_child_button(parent_hwnd: int, button_text: str) -> int:- """Find a child button control with the given text."""- result = [0]- WNDENUMPROC = ctypes.WINFUNCTYPE(- ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM- )-- def callback(hwnd, _lparam):- # Get the control text- length = user32.GetWindowTextLengthW(hwnd)- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- if buf.value.strip().lower() == button_text.lower():- result[0] = hwnd- return False- return True-- cb = WNDENUMPROC(callback)- _callbacks.append(cb)- user32.EnumChildWindows(parent_hwnd, cb, 0)- _callbacks.remove(cb)- return result[0]---VK_ESCAPE = 0x1B-VK_TAB = 0x09-VK_RETURN = 0x0D-WM_KEYDOWN = 0x0100-WM_KEYUP = 0x0101---_VK_TO_NAME = {0x1B: "escape", 0x09: "tab", 0x0D: "enter"}---def _send_key(hwnd: int, vk: int, pause: float = 0.05):- """Send a key press (down + up) to a window."""- if IS_MACOS:- # CGEvent keys land in the FOCUSED app (there is no post-to-window on- # mac), so raise the target window first to avoid typing into whatever- # the user has focused.- if hwnd:- _mac.raise_window(hwnd)- time.sleep(0.1)- _mac.send_named_key(_VK_TO_NAME.get(vk, ""))- time.sleep(pause)- return- user32.PostMessageW(hwnd, WM_KEYDOWN, vk, 0)+ """Fusion-owned non-main windows titled like the recovery list dialog."""+ return [d["hwnd"] for d in _mac.find_dialog_windows()+ if (d.get("title") or "").strip().lower()+ in ("fusion360", "fusion", "autodesk fusion")]+++def _send_key(winid: int, key: str, pause: float = 0.05):+ """Send a named key to a window. CGEvent keys land in the FOCUSED app+ (there is no post-to-window on mac), so raise the target window first to+ avoid typing into whatever the user has focused."""+ if winid:+ _mac.raise_window(winid)+ time.sleep(0.1)+ _mac.send_named_key(key) time.sleep(pause)- user32.PostMessageW(hwnd, WM_KEYUP, vk, 0) -def _dismiss_recovery_prompt(hwnd: int) -> bool:- """Dismiss an 'Open recovery document instead?' Qt dialog by clicking No.+def _dismiss_recovery_prompt(winid: int) -> bool:+ """Dismiss an 'Open recovery document instead?' dialog by choosing No. - The dialog has Yes (focused by default) and No buttons.- Tab moves focus to No, Enter clicks it. This skips the recovery- and opens the cloud version instead.- """- if IS_MACOS:- _mac.raise_window(hwnd)- else:- user32.SetForegroundWindow(hwnd)+ The dialog has Yes (focused by default) and No buttons. Tab moves focus+ to No, Enter clicks it — skipping recovery and opening the cloud version."""+ _mac.raise_window(winid) time.sleep(0.2)- _send_key(hwnd, VK_TAB)+ _send_key(winid, "tab") time.sleep(0.2)- _send_key(hwnd, VK_RETURN)+ _send_key(winid, "enter") return True @@ -292,10 +158,11 @@ def dismiss_recovery_dialog(max_wait: float = 5.0) -> bool: directory to ~/.adom/recovery/fusion/<timestamp>/ so the user can manually recover them if needed. - Handles two dialog types:- 1. "Open recovery document instead?" — Qt dialog, dismissed via Tab+Enter (No)- 2. "Fusion360" titled Qt dialog — the Recovered Documents list, dismissed via WM_CLOSE- 3. Fallback: Send Escape to Fusion main window+ Handles the dialog types in priority order:+ 1. "Open recovery document instead?" — dismissed via Tab+Enter (No)+ 2. "Fusion360" titled dialog — the Recovered Documents list, closed in background+ 3. A window explicitly titled "Recovered Documents" — closed in background+ 4. Fallback: Send Escape to the Fusion main window Loops to handle multiple recovery prompts (one per recovered file). Returns True if any dismiss attempt was made.@@ -318,36 +185,26 @@ def dismiss_recovery_dialog(max_wait: float = 5.0) -> bool: while time.monotonic() < deadline: # Priority 1: "Open recovery document instead?" prompt- prompt_hwnd = _find_window_by_title("Open recovery document instead?")- if prompt_hwnd:- _dismiss_recovery_prompt(prompt_hwnd)+ prompt_id = _find_window_by_title("Open recovery document instead?")+ if prompt_id:+ _dismiss_recovery_prompt(prompt_id) dismissed_any = True time.sleep(0.5) continue # Check for more prompts - # Priority 2: "Fusion360" titled Qt dialogs (Recovered Documents list)- dialog_hwnds = _find_fusion_dialog_windows()- if dialog_hwnds:- for hwnd in dialog_hwnds:- if IS_MACOS:- _mac.close_window_bg(hwnd)- else:- user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)+ # Priority 2: "Fusion360" titled dialogs (Recovered Documents list)+ dialog_ids = _find_fusion_dialog_windows()+ if dialog_ids:+ for winid in dialog_ids:+ _mac.close_window_bg(winid) dismissed_any = True time.sleep(0.5) continue # Check for more - # Priority 3: Look for a window explicitly titled "Recovered Documents"- hwnd = _find_window_by_title("Recovered Documents")- if hwnd:- if IS_MACOS:- _mac.close_window_bg(hwnd)- else:- close_btn = _find_child_button(hwnd, "Close")- if close_btn:- user32.PostMessageW(close_btn, BM_CLICK, 0, 0)- else:- user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)+ # Priority 3: A window explicitly titled "Recovered Documents"+ winid = _find_window_by_title("Recovered Documents")+ if winid:+ _mac.close_window_bg(winid) dismissed_any = True time.sleep(0.5) continue@@ -363,9 +220,9 @@ def dismiss_recovery_dialog(max_wait: float = 5.0) -> bool: continue # Priority 4: Send Escape to the main Fusion window (last resort)- fusion_hwnd = _find_window_by_title("Autodesk Fusion")- if fusion_hwnd:- _send_key(fusion_hwnd, VK_ESCAPE)+ fusion_id = _find_window_by_title("Autodesk Fusion")+ if fusion_id:+ _send_key(fusion_id, "escape") dismissed_any = True time.sleep(1.0) continue
handlers/fusion_ui.py+18−768@@ -1,771 +1,21 @@-"""Win32-based tools for interacting with Fusion 360's UI.+"""Fusion 360 UI interaction — macOS. -Provides screenshot, click, and keyboard input functions that work with-CEF (Chromium Embedded Framework) modal dialogs inside Fusion 360.-These dialogs can't be accessed via standard Win32 child window enumeration,-so we use screen-DC capture (BitBlt) instead of PrintWindow, and SendInput-instead of PostMessage for input.+This bridge is macOS-only; the implementation lives in handlers/mac_ui.py+(CGWindowListCopyWindowInfo for discovery, screencapture -l for shots,+CGEvent for input, AX/System Events for close/raise). This module is the+stable import surface server.py and the other handlers use; "hwnd"+throughout is the CGWindowID. """ -import ctypes-import ctypes.wintypes-import os-import struct-import time--# Guard windll so the module imports on non-Windows hosts (the bridge must boot-# + serve /status anywhere); these Win32 paths are only reached on Windows.-_have_windll = hasattr(ctypes, "windll")-user32 = ctypes.windll.user32 if _have_windll else None-gdi32 = ctypes.windll.gdi32 if _have_windll else None-kernel32 = ctypes.windll.kernel32 if _have_windll else None--# ⚠️ HWND TRUNCATION BUG (fixed 2026-07-08, cost hours): with NO argtypes declared, ctypes-# defaults every function arg + return to 32-bit `c_int`, which TRUNCATES 64-bit HWND handles.-# So `_find_fusion_hwnd`'s GetWindowTextW/IsWindowVisible ran on a garbage handle and matched-# NOTHING - get_fusion_window_info returned mainHwnd:None even though Fusion's window ("Untitled-# - Autodesk Fusion") plainly existed, which blinded all background driving. (Verified live: the-# window's real hwnd via `(Get-Process Fusion360).MainWindowHandle` worked fine.) Declaring HWND-# argtypes/restypes makes ctypes marshal the full 64-bit handle. If the finder ever returns 0/None-# while Fusion IS running, the process-handle fallback in _find_fusion_hwnd covers it.-if _have_windll:- _W = ctypes.wintypes- for _fn, _arg, _ret in (- ("IsWindowVisible", [_W.HWND], ctypes.c_bool),- ("IsWindowEnabled", [_W.HWND], ctypes.c_bool),- ("GetWindowTextLengthW", [_W.HWND], ctypes.c_int),- ("GetWindowTextW", [_W.HWND, _W.LPWSTR, ctypes.c_int], ctypes.c_int),- ("GetClassNameW", [_W.HWND, _W.LPWSTR, ctypes.c_int], ctypes.c_int),- ("GetWindow", [_W.HWND, ctypes.c_uint], _W.HWND),- ("GetParent", [_W.HWND], _W.HWND),- ("IsWindow", [_W.HWND], ctypes.c_bool),- ):- try:- _f = getattr(user32, _fn); _f.argtypes = _arg; _f.restype = _ret- except Exception:- pass--# Enable DPI awareness so GetWindowRect returns physical pixel coordinates-# (not logical/scaled). Without this, screenshots are cropped on HiDPI displays.-try:- ctypes.windll.shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE-except Exception:- try:- user32.SetProcessDPIAware() # Fallback for older Windows- except Exception:- pass--# --- Constants -----SRCCOPY = 0x00CC0020-DIB_RGB_COLORS = 0-BI_RGB = 0-SM_CXVIRTUALSCREEN = 78-SM_CYVIRTUALSCREEN = 79--INPUT_MOUSE = 0-INPUT_KEYBOARD = 1-MOUSEEVENTF_ABSOLUTE = 0x8000-MOUSEEVENTF_MOVE = 0x0001-MOUSEEVENTF_LEFTDOWN = 0x0002-MOUSEEVENTF_LEFTUP = 0x0004-KEYEVENTF_KEYUP = 0x0002--# Virtual key codes-VK_MAP = {- "enter": 0x0D, "return": 0x0D,- "escape": 0x1B, "esc": 0x1B,- "tab": 0x09,- "space": 0x20,- "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,- "backspace": 0x08, "delete": 0x2E,- "home": 0x24, "end": 0x23,- "pageup": 0x21, "pagedown": 0x22,- "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,- "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,- "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,-}--SCREENSHOT_DIR = "C:/tmp/conduit-screenshots"--# Keep callback references alive to prevent GC-_callbacks = []---# --- SendInput structures -----class MOUSEINPUT(ctypes.Structure):- _fields_ = [- ("dx", ctypes.wintypes.LONG),- ("dy", ctypes.wintypes.LONG),- ("mouseData", ctypes.wintypes.DWORD),- ("dwFlags", ctypes.wintypes.DWORD),- ("time", ctypes.wintypes.DWORD),- ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),- ]---class KEYBDINPUT(ctypes.Structure):- _fields_ = [- ("wVk", ctypes.wintypes.WORD),- ("wScan", ctypes.wintypes.WORD),- ("dwFlags", ctypes.wintypes.DWORD),- ("time", ctypes.wintypes.DWORD),- ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),- ]---class _INPUT_UNION(ctypes.Union):- _fields_ = [- ("mi", MOUSEINPUT),- ("ki", KEYBDINPUT),- ]---class INPUT(ctypes.Structure):- _fields_ = [- ("type", ctypes.wintypes.DWORD),- ("union", _INPUT_UNION),- ]---def _send_input(*inputs):- """Send one or more INPUT structures via SendInput."""- n = len(inputs)- arr = (INPUT * n)(*inputs)- user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))---# --- Window finding -----def _find_fusion_hwnd() -> int:- """Find Fusion 360's main window by title containing 'Autodesk Fusion'."""- result = [0]- WNDENUMPROC = ctypes.WINFUNCTYPE(- ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM- )-- def callback(hwnd, _lparam):- if user32.IsWindowVisible(hwnd):- length = user32.GetWindowTextLengthW(hwnd)- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- if "Autodesk Fusion" in buf.value:- result[0] = hwnd- return False- return True-- cb = WNDENUMPROC(callback)- _callbacks.append(cb)- user32.EnumWindows(cb, 0)- _callbacks.remove(cb)- return result[0]---def _find_qt_dialog_windows() -> list:- """Find visible Qt windows that may be Fusion 360 dialogs.-- Returns list of dicts with hwnd, title, rect, className.- Excludes the main Fusion window (which has 'Autodesk Fusion' in title).- """- results = []- WNDENUMPROC = ctypes.WINFUNCTYPE(- ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM- )-- def callback(hwnd, _lparam):- if user32.IsWindowVisible(hwnd):- cls_buf = ctypes.create_unicode_buffer(256)- user32.GetClassNameW(hwnd, cls_buf, 256)- class_name = cls_buf.value-- length = user32.GetWindowTextLengthW(hwnd)- title = ""- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- title = buf.value-- # Qt windows belonging to Fusion (not the main window)- if "Qt" in class_name and "Autodesk Fusion" not in title:- # Some Fusion dialogs (including the "What do you want to- # design?" startup picker) use Qt Tool window classes like- # "Qt655QWindowToolSaveBits". We used to skip ALL Tool- # windows (they're usually docked panels — Browser, Timeline).- # But that caused the startup picker to be invisible to- # dialog detection. Now we only skip SMALL Tool windows- # (docked panels are narrow sidebar panels, typically- # <400px wide). Large Tool windows (>400px wide AND >300px- # tall) are kept — they're blocking dialogs like the picker.- if "Tool" in class_name:- rect = ctypes.wintypes.RECT()- user32.GetWindowRect(hwnd, ctypes.byref(rect))- w = rect.right - rect.left- h = rect.bottom - rect.top- if w < 400 or h < 300:- return True # small tool panel, skip- # ASPECT GUARD (2026-07-23): the size test alone is not enough. Fusion's- # docked side panels (BROWSER, Timeline, Comments) are TALL and NARROW and- # sail past 400x300 - the BROWSER measured 450x1194 and got reported as a- # blocking dialog on an idle Fusion, which makes a driving AI believe it is- # blocked when nothing is wrong. Real blocking dialogs (the startup picker,- # licensing, recovery) are landscape or roughly square. So: anything markedly- # taller than it is wide is a docked panel, not a dialog.- if h > w * 1.5:- return True # tall narrow docked panel, skip-- rect = ctypes.wintypes.RECT()- user32.GetWindowRect(hwnd, ctypes.byref(rect))- results.append({- "hwnd": hwnd,- "title": title,- "className": class_name,- "rect": {- "left": rect.left, "top": rect.top,- "right": rect.right, "bottom": rect.bottom,- "width": rect.right - rect.left,- "height": rect.bottom - rect.top,- },- })- return True-- cb = WNDENUMPROC(callback)- _callbacks.append(cb)- user32.EnumWindows(cb, 0)- _callbacks.remove(cb)- return results---def _get_window_rect(hwnd: int) -> tuple:- """Get window rect as (left, top, right, bottom)."""- rect = ctypes.wintypes.RECT()- user32.GetWindowRect(hwnd, ctypes.byref(rect))- return (rect.left, rect.top, rect.right, rect.bottom)---# --- Screenshot -----def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):- """Write a BMP file from raw BGR pixel data (bottom-up row order).-- pixel_data must be width*height*4 bytes (32-bit BGRA from GetDIBits).- We write a 24-bit BMP (strip alpha) for broad compatibility.- """- row_size_24 = (width * 3 + 3) & ~3 # rows padded to 4 bytes- pixel_size_24 = row_size_24 * height- file_size = 14 + 40 + pixel_size_24 # BMP header + DIB header + pixels-- with open(path, "wb") as f:- # BMP file header (14 bytes)- f.write(b"BM")- f.write(struct.pack("<I", file_size))- f.write(struct.pack("<HH", 0, 0)) # reserved- f.write(struct.pack("<I", 14 + 40)) # offset to pixel data-- # DIB header (BITMAPINFOHEADER, 40 bytes)- f.write(struct.pack("<I", 40)) # header size- f.write(struct.pack("<i", width))- f.write(struct.pack("<i", height)) # positive = bottom-up- f.write(struct.pack("<HH", 1, 24)) # planes, bpp- f.write(struct.pack("<I", BI_RGB)) # compression- f.write(struct.pack("<I", pixel_size_24))- f.write(struct.pack("<ii", 2835, 2835)) # pixels/meter (~72 DPI)- f.write(struct.pack("<II", 0, 0)) # colors-- # Write pixel rows (convert 32-bit BGRA to 24-bit BGR, bottom-up)- src_row_size = width * 4- for y in range(height):- row_start = y * src_row_size- row_24 = bytearray()- for x in range(width):- px = row_start + x * 4- row_24 += pixel_data[px:px + 3] # BGR (skip alpha)- # Pad row to 4-byte boundary- padding = row_size_24 - len(row_24)- if padding > 0:- row_24 += b"\x00" * padding- f.write(bytes(row_24))---PW_RENDERFULLCONTENT = 0x00000002---def screenshot_hwnd(hwnd: int, label: str = "") -> dict:- """Capture any window by HWND. Returns {success, savedTo, sizeKB}."""- left, top, right, bottom = _get_window_rect(hwnd)- width = right - left- height = bottom - top-- if width <= 0 or height <= 0:- return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}-- hdc_screen = user32.GetDC(hwnd)- hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)- hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)- old_bmp = gdi32.SelectObject(hdc_mem, hbmp)- user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)-- class BITMAPINFOHEADER(ctypes.Structure):- _fields_ = [- ("biSize", ctypes.wintypes.DWORD),- ("biWidth", ctypes.wintypes.LONG),- ("biHeight", ctypes.wintypes.LONG),- ("biPlanes", ctypes.wintypes.WORD),- ("biBitCount", ctypes.wintypes.WORD),- ("biCompression", ctypes.wintypes.DWORD),- ("biSizeImage", ctypes.wintypes.DWORD),- ("biXPelsPerMeter", ctypes.wintypes.LONG),- ("biYPelsPerMeter", ctypes.wintypes.LONG),- ("biClrUsed", ctypes.wintypes.DWORD),- ("biClrImportant", ctypes.wintypes.DWORD),- ]-- bmi = BITMAPINFOHEADER()- bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)- bmi.biWidth = width- bmi.biHeight = height- bmi.biPlanes = 1- bmi.biBitCount = 32- bmi.biCompression = BI_RGB-- buf_size = width * height * 4- pixel_buf = ctypes.create_string_buffer(buf_size)- gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)-- gdi32.SelectObject(hdc_mem, old_bmp)- gdi32.DeleteObject(hbmp)- gdi32.DeleteDC(hdc_mem)- user32.ReleaseDC(hwnd, hdc_screen)-- os.makedirs(SCREENSHOT_DIR, exist_ok=True)- timestamp = int(time.time() * 1000)- suffix = f"-{label}" if label else ""-- try:- from PIL import Image- img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")- img = img.transpose(Image.FLIP_TOP_BOTTOM)- MAX_DIM = 1568- if max(width, height) > MAX_DIM:- ratio = MAX_DIM / max(width, height)- img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)- try:- path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.webp")- img.save(path, "WEBP", lossless=True, quality=100)- except Exception:- path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.png")- img.save(path, "PNG", optimize=True)- except ImportError:- path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.bmp")- _write_bmp(path, width, height, pixel_buf.raw)-- size_kb = os.path.getsize(path) / 1024- return {- "success": True,- "savedTo": path.replace("\\", "/"),- "sizeKB": round(size_kb, 1),- }---def screenshot_fusion_window(use_bitblt: bool = False) -> dict:- """Capture the Fusion 360 window.-- By default uses PrintWindow with PW_RENDERFULLCONTENT (works without- foreground, captures Qt content but NOT CEF modal overlays).-- If use_bitblt=True, brings window to foreground and uses BitBlt from- screen DC (captures CEF overlays but requires foreground).-- Saves PNG to C:/tmp/conduit-screenshots/ and returns the path.- """- hwnd = _find_fusion_hwnd()- if not hwnd:- return {- "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.",- }-- # Get window rect- left, top, right, bottom = _get_window_rect(hwnd)- width = right - left- height = bottom - top-- if width <= 0 or height <= 0:- return {"success": False, "error": f"Invalid window rect: {left},{top},{right},{bottom}"}-- if use_bitblt:- # Screen DC capture — needs foreground- SW_RESTORE = 9- user32.ShowWindow(hwnd, SW_RESTORE)- user32.SetForegroundWindow(hwnd)- time.sleep(1.0)-- hdc_screen = user32.GetDC(0)- hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)- hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)- old_bmp = gdi32.SelectObject(hdc_mem, hbmp)- gdi32.BitBlt(hdc_mem, 0, 0, width, height, hdc_screen, left, top, SRCCOPY)- else:- # PrintWindow capture — works without foreground- hdc_screen = user32.GetDC(hwnd)- hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)- hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)- old_bmp = gdi32.SelectObject(hdc_mem, hbmp)- user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)-- # Read pixel data via GetDIBits- class BITMAPINFOHEADER(ctypes.Structure):- _fields_ = [- ("biSize", ctypes.wintypes.DWORD),- ("biWidth", ctypes.wintypes.LONG),- ("biHeight", ctypes.wintypes.LONG),- ("biPlanes", ctypes.wintypes.WORD),- ("biBitCount", ctypes.wintypes.WORD),- ("biCompression", ctypes.wintypes.DWORD),- ("biSizeImage", ctypes.wintypes.DWORD),- ("biXPelsPerMeter", ctypes.wintypes.LONG),- ("biYPelsPerMeter", ctypes.wintypes.LONG),- ("biClrUsed", ctypes.wintypes.DWORD),- ("biClrImportant", ctypes.wintypes.DWORD),- ]-- bmi = BITMAPINFOHEADER()- bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)- bmi.biWidth = width- bmi.biHeight = height # positive = bottom-up (standard BMP order)- bmi.biPlanes = 1- bmi.biBitCount = 32- bmi.biCompression = BI_RGB-- buf_size = width * height * 4- pixel_buf = ctypes.create_string_buffer(buf_size)- gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)-- # Clean up GDI resources- gdi32.SelectObject(hdc_mem, old_bmp)- gdi32.DeleteObject(hbmp)- gdi32.DeleteDC(hdc_mem)- if use_bitblt:- user32.ReleaseDC(0, hdc_screen)- else:- user32.ReleaseDC(hwnd, hdc_screen)-- # Save to file as PNG (try PIL first, fall back to BMP)- os.makedirs(SCREENSHOT_DIR, exist_ok=True)- timestamp = int(time.time() * 1000)-- try:- from PIL import Image- # pixel_buf is BGRA bottom-up; convert to RGBA top-down- img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")- img = img.transpose(Image.FLIP_TOP_BOTTOM)-- # Downscale to max 1568px on longest side (saves tokens for AI vision)- MAX_DIM = 1568- if max(width, height) > MAX_DIM:- ratio = MAX_DIM / max(width, height)- new_size = (int(width * ratio), int(height * ratio))- img = img.resize(new_size, Image.LANCZOS)-- # Try WebP first (much smaller than PNG for screenshots)- try:- path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.webp")- img.save(path, "WEBP", lossless=True, quality=100)- except Exception:- # Fall back to PNG if WebP not available- path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.png")- img.save(path, "PNG", optimize=True)- except ImportError:- # No PIL — save as BMP (readable by most tools, but not Claude Read)- path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.bmp")- _write_bmp(path, width, height, pixel_buf.raw)-- size_kb = os.path.getsize(path) / 1024- return {- "success": True,- "savedTo": path.replace("\\", "/"),- "sizeKB": round(size_kb, 1),- "dimensions": {"width": width, "height": height},- }---# --- Click -----def click_fusion(x, y, relative=True, hwnd=None) -> dict:- """Click at coordinates in a window (Fusion main window or any dialog).-- Args:- x: X coordinate. If relative=True, a float 0.0-1.0 (percentage of window width).- If relative=False, pixel offset from window's top-left corner.- y: Y coordinate. Same convention as x but for height.- relative: If True (default), x/y are percentages. If False, pixel offsets.- hwnd: Optional HWND to click on. If omitted, finds the main Fusion window.- Use this to click buttons inside Qt dialogs by passing the dialog's HWND.-- Uses SendInput (NOT PostMessage) because CEF doesn't respond to WM messages.- """- if hwnd:- hwnd = int(hwnd)- # Validate the HWND is a real window- if not user32.IsWindow(hwnd):- return {- "success": False,- "error": f"HWND {hwnd} is not a valid window.",- "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",- }- else:- hwnd = _find_fusion_hwnd()- if not hwnd:- return {- "success": False,- "error": "Fusion 360 window not found.",- "_hint": "Call fusion_start to start Fusion 360, then retry.",- }-- left, top, right, bottom = _get_window_rect(hwnd)- width = right - left- height = bottom - top-- # Bring to front- user32.SetForegroundWindow(hwnd)- time.sleep(0.1)-- # Calculate screen coordinates- if relative:- screen_x = left + int(float(x) * width)- screen_y = top + int(float(y) * height)- else:- screen_x = left + int(x)- screen_y = top + int(y)-- # Convert to absolute coordinates for SendInput (0-65535 range)- screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN (primary monitor)- screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN- abs_x = int(screen_x * 65535 / screen_w)- abs_y = int(screen_y * 65535 / screen_h)-- # Move mouse- move = INPUT()- move.type = INPUT_MOUSE- move.union.mi.dx = abs_x- move.union.mi.dy = abs_y- move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE-- # Click down- down = INPUT()- down.type = INPUT_MOUSE- down.union.mi.dx = abs_x- down.union.mi.dy = abs_y- down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN-- # Click up- up = INPUT()- up.type = INPUT_MOUSE- up.union.mi.dx = abs_x- up.union.mi.dy = abs_y- up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP-- _send_input(move, down, up)-- return {- "success": True,- "clickedAt": {"screenX": screen_x, "screenY": screen_y},- "windowOffset": {- "x": screen_x - left,- "y": screen_y - top,- },- "relative": relative,- }---# --- Keyboard -----def send_key_to_fusion(key: str, hwnd=None) -> dict:- """Send a key to Fusion 360 (or a specific dialog) via SendInput.-- Args:- key: One of: "enter", "escape", "tab", "space", "up", "down",- "left", "right", "f1"-"f12", "backspace", "delete",- "home", "end", "pageup", "pagedown", or a single character.- hwnd: Optional HWND to target. If omitted, targets the main Fusion window.- """- if hwnd:- hwnd = int(hwnd)- if not user32.IsWindow(hwnd):- return {- "success": False,- "error": f"HWND {hwnd} is not a valid window.",- "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",- }- else:- hwnd = _find_fusion_hwnd()- if not hwnd:- return {- "success": False,- "error": "Fusion 360 window not found.",- "_hint": "Call fusion_start to start Fusion 360, then retry.",- }-- # Bring to front- user32.SetForegroundWindow(hwnd)- time.sleep(0.1)-- key_lower = key.lower().strip()- vk = VK_MAP.get(key_lower)-- if vk is None:- if len(key) == 1:- # Single character: use VkKeyScanW to get the virtual key code- vk_scan = user32.VkKeyScanW(ord(key))- vk = vk_scan & 0xFF- shift = (vk_scan >> 8) & 0x01- if vk == 0xFF:- return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}-- inputs = []- # Press shift if needed- if shift:- s_down = INPUT()- s_down.type = INPUT_KEYBOARD- s_down.union.ki.wVk = 0x10 # VK_SHIFT- inputs.append(s_down)-- # Key down- kd = INPUT()- kd.type = INPUT_KEYBOARD- kd.union.ki.wVk = vk- inputs.append(kd)-- # Key up- ku = INPUT()- ku.type = INPUT_KEYBOARD- ku.union.ki.wVk = vk- ku.union.ki.dwFlags = KEYEVENTF_KEYUP- inputs.append(ku)-- # Release shift if needed- if shift:- s_up = INPUT()- s_up.type = INPUT_KEYBOARD- s_up.union.ki.wVk = 0x10- s_up.union.ki.dwFlags = KEYEVENTF_KEYUP- inputs.append(s_up)-- _send_input(*inputs)- return {"success": True, "key": key, "vk": vk, "shift": bool(shift)}- else:- return {"success": False, "error": f"Unknown key: '{key}'. Use a named key or single character."}-- # Named key: simple down + up- kd = INPUT()- kd.type = INPUT_KEYBOARD- kd.union.ki.wVk = vk-- ku = INPUT()- ku.type = INPUT_KEYBOARD- ku.union.ki.wVk = vk- ku.union.ki.dwFlags = KEYEVENTF_KEYUP-- _send_input(kd, ku)- return {"success": True, "key": key_lower, "vk": vk}---def close_window(hwnd: int) -> dict:- """Close a window by sending WM_CLOSE via PostMessage.-- Unlike Escape (which many Fusion dialogs ignore — e.g. Recovered Documents),- WM_CLOSE is the standard Windows mechanism for closing a window and is- handled by virtually all Qt dialogs.-- This does NOT force-kill the window — the dialog can still prompt for- confirmation. It's equivalent to clicking the X button in the title bar.-- Args:- hwnd: HWND of the window to close.- """- hwnd = int(hwnd)- if not user32.IsWindow(hwnd):- return {- "success": False,- "error": f"HWND {hwnd} is not a valid window.",- }-- WM_CLOSE = 0x0010- user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)- return {"success": True, "hwnd": hwnd, "action": "WM_CLOSE sent"}---# --- Window info -----def get_fusion_window_info() -> dict:- """Get Fusion 360 window info: hwnd, title, rect, and any Qt dialog windows.-- Returns the main Fusion window details plus a list of other Qt windows- that may be Fusion dialogs (file pickers, recovery prompts, etc.).- """- hwnd = _find_fusion_hwnd()- if not hwnd:- return {- "success": False,- "error": "Fusion 360 window not found.",- "_hint": "Call fusion_start to start Fusion 360, then retry.",- }-- # Get title- length = user32.GetWindowTextLengthW(hwnd)- title = ""- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- title = buf.value-- left, top, right, bottom = _get_window_rect(hwnd)-- # Find potential dialog windows- dialogs = _find_qt_dialog_windows()-- # A real modal dialog DISABLES its owner window; docked panels (Browser,- # Timeline) leave it ENABLED. This is the clean signal that separates an- # actual blocking modal from a tool window that merely shares the "Fusion360"- # title and slips past the size filter. Callers use it to suppress- # false-positive "dialogs" when nothing is actually blocking.- try:- main_enabled = bool(user32.IsWindowEnabled(hwnd))- except Exception:- main_enabled = True-- return {- "success": True,- "hwnd": hwnd,- "title": title,- "mainEnabled": main_enabled,- "rect": {- "left": left, "top": top,- "right": right, "bottom": bottom,- "width": right - left,- "height": bottom - top,- },- "dialogs": dialogs,- }---# --- macOS: swap in the CGWindow/CGEvent implementations --------------------# Rebinding at module level means every `from handlers.fusion_ui import X`-# (server.py, dialog_classify.py) transparently gets the mac versions; the-# Win32 defs above are unreachable dead code on darwin. "hwnd" on macOS is the-# CGWindowID. See handlers/mac_ui.py.-import sys as _sys-if _sys.platform == "darwin":- from handlers import mac_ui as _mac- screenshot_hwnd = _mac.screenshot_hwnd- screenshot_fusion_window = _mac.screenshot_fusion_window- click_fusion = _mac.click_fusion- send_key_to_fusion = _mac.send_key_to_fusion- close_window = _mac.close_window- get_fusion_window_info = _mac.get_fusion_window_info- _find_fusion_hwnd = _mac.find_fusion_main_winid- _find_qt_dialog_windows = _mac.find_dialog_windows- _get_window_rect = _mac.get_window_rect- SCREENSHOT_DIR = _mac.SCREENSHOT_DIR+from handlers.mac_ui import ( # noqa: F401+ SCREENSHOT_DIR,+ click_fusion,+ close_window,+ get_fusion_window_info,+ screenshot_fusion_window,+ screenshot_hwnd,+ send_key_to_fusion,+)+from handlers.mac_ui import find_fusion_main_winid as _find_fusion_hwnd # noqa: F401+from handlers.mac_ui import find_dialog_windows as _find_qt_dialog_windows # noqa: F401+from handlers.mac_ui import get_window_rect as _get_window_rect # noqa: F401
handlers/mac_ui.py+5−5@@ -10,10 +10,10 @@ dismiss_recovery.py / dialog_classify.py need, using: - /usr/sbin/screencapture -l <windowid> for window capture (works while the window is backgrounded; needs the Screen Recording TCC permission). - CGEvent (CGEventCreateMouseEvent / CGEventCreateKeyboardEvent) for input —- the SendInput analog. Unicode-string key events cover arbitrary characters+ synthetic input. Unicode-string key events cover arbitrary characters without a keycode table. - System Events (osascript/JXA) for accessibility actions: raising a window,- clicking a window's close button in the BACKGROUND (the WM_CLOSE analog),+ clicking a window's close button in the BACKGROUND (no focus steal), and app activation by pid. Needs the Accessibility TCC permission. Response shapes match fusion_ui.py exactly so server.py and the callers see@@ -351,7 +351,7 @@ def raise_window(winid) -> bool: def _ax_close_window(winid) -> bool: """Click the window's close button via Accessibility — background-safe- (no raise, no focus steal). The WM_CLOSE analog."""+ (no raise, no focus steal).""" w = _window_by_id(winid) if not w: return False@@ -700,7 +700,7 @@ def send_key_to_fusion(key: str, hwnd=None) -> dict: def close_window(hwnd) -> dict:- """Close a window via its Accessibility close button (the WM_CLOSE analog).+ """Close a window via its Accessibility close button (background-safe). Background-safe: does not raise or focus the window. The window can still prompt for confirmation — this is equivalent to clicking the red close@@ -720,7 +720,7 @@ def close_window(hwnd) -> dict: def _main_enabled(pid, dialog_count: int) -> bool: """A modal AX window means the main window is blocked — the mac analog of- Win32's IsWindowEnabled(main)==False. Best-effort; defaults to True."""+ a disabled main window under a modal. Best-effort; defaults to True.""" try: ok, out, _ = _osascript( f'tell application "System Events" to tell '
install_addin.py+9−16@@ -5,9 +5,9 @@ Fusion has MOVED its per-user add-in directory across versions, and an add-in in the wrong one is silently ignored (Fusion loads fine, the add-in never runs, port 8774 stays down - issue #63, root-caused live on a fresh 2026 install): - NEW (2025+): %APPDATA%/Autodesk/FusionAddins/AdomBridge/ <- current Fusion scans THIS- legacy: %APPDATA%/Autodesk/Autodesk Fusion/API/AddIns/ <- older builds- legacy: %APPDATA%/Autodesk/Autodesk Fusion 360/API/AddIns/ <- oldest naming+ NEW (2025+): ~/Library/Application Support/Autodesk/FusionAddins/AdomBridge/ <- current Fusion scans THIS+ legacy: ~/Library/Application Support/Autodesk/Autodesk Fusion/API/AddIns/ <- older builds+ legacy: ~/Library/Application Support/Autodesk/Autodesk Fusion 360/API/AddIns/ <- oldest naming Verified live (ADOMBASELINE, Fusion 2026.x): the add-in sat in the legacy API/AddIns dir through a full launch and NEVER loaded; moved to FusionAddins it@@ -20,6 +20,7 @@ Usage: python install_addin.py python install_addin.py --uninstall """+from __future__ import annotations # PEP-604 hints must parse on py3.9 (mac stock) import shutil import sys@@ -31,19 +32,11 @@ SOURCE_DIR = Path(__file__).resolve().parent / "addin" / "AdomBridge" # EVERY known Fusion add-in directory convention, newest first. Keep this list # complete - see the module docstring for why (a single-path assumption caused # the #63 silent-failure). FusionAddins is created even on a fresh profile.-import sys as _sys-if _sys.platform == "darwin":- TARGET_CANDIDATES = [- Path.home() / "Library" / "Application Support" / "Autodesk" / "FusionAddins",- Path.home() / "Library" / "Application Support" / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns",- Path.home() / "Library" / "Application Support" / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns",- ]-else:- TARGET_CANDIDATES = [- Path.home() / "AppData" / "Roaming" / "Autodesk" / "FusionAddins",- Path.home() / "AppData" / "Roaming" / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns",- Path.home() / "AppData" / "Roaming" / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns",- ]+TARGET_CANDIDATES = [+ Path.home() / "Library" / "Application Support" / "Autodesk" / "FusionAddins",+ Path.home() / "Library" / "Application Support" / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns",+ Path.home() / "Library" / "Application Support" / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns",+] def _sync_directory(source: Path, target: Path):
package.json+2−3@@ -1,8 +1,8 @@ { "slug": "adom-desktop-fusion-bridge-macos",- "version": "1.8.4",+ "version": "1.8.5", "type": "app",- "description": "macOS build of the Adom Desktop Fusion 360 bridge. Same fusion_* verbs and skills as the canonical (Windows) bridge, with the window-automation layer natively ported to macOS (CGWindowList + screencapture + CGEvent + System Events). Installs the SKILLS into your container; Adom Desktop loads the runtime from the release zip.",+ "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Desktop loads the bridge runtime itself from the release zip.", "dependencies": { "adom/adom-desktop": "*" },@@ -20,7 +20,6 @@ "modeling" ], "hero": {- "type": "image", "path": "screenshots/hero.png" }, "discovery_triggers": [
page.json+2−2@@ -1,8 +1,8 @@ { "slug": "adom-desktop-fusion-bridge-macos",- "version": "1.8.4",+ "version": "1.8.5", "type": "app",- "description": "macOS build of the Adom Desktop Fusion 360 bridge. Same fusion_* verbs and skills as the canonical (Windows) bridge, with the window-automation layer natively ported to macOS (CGWindowList + screencapture + CGEvent + System Events). Installs the SKILLS into your container; Adom Desktop loads the runtime from the release zip.",+ "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Desktop loads the bridge runtime itself from the release zip.", "dependencies": { "adom/adom-desktop": "*" },
server.py+166−365@@ -2,12 +2,13 @@ """Adom Fusion 360 Bridge Server — localhost HTTP server for Fusion 360 integration. Receives commands from the Adom Desktop (Tauri app) and controls-Fusion 360 via Win32 API, os.startfile, and the AdomBridge add-in.+Fusion 360 via macOS window automation (handlers/mac_ui) and the AdomBridge add-in. Usage: python server.py python server.py --port 8773 """+from __future__ import annotations # PEP-604 hints must parse on py3.9 (mac stock) import json import os@@ -125,7 +126,7 @@ def _resolve_seat_via_uia(dlg: dict) -> bool: THE breakthrough (John 2026-07-06, after a long day of failing): UIA Invoke does NOT foreground the window, so this grabs the license without ever disturbing the- user - unlike SendInput coordinate clicks (need foreground) or kill+relaunch (the+ user - unlike synthetic coordinate clicks (need foreground) or kill+relaunch (the server keeps the seat). Proven live: `desktop_ui_click {hwnd, name:"Continue"}` returned "Clicked in the BACKGROUND - the window did NOT come to the foreground" and the dialog cleared. The bridge calls the AD `desktop_ui_click` verb on ITSELF@@ -206,18 +207,31 @@ def _resolve_seat_dialog(max_clicks: int = 3) -> dict: def _find_adom_desktop_cli() -> str | None: """Locate the adom-desktop CLI/exe so the bridge can drive AD's relay directly (used when the- in-process ad_client is unavailable, e.g. on a headless VM). Checks the per-user Windows install- dir, then PATH, then the common Linux/dev locations. Returns a path or None."""+ in-process ad_client is unavailable). Probes the .app bundles first, then the well-known+ absolute dirs, then PATH. Returns a path or None.++ PATH alone is NOT enough. A GUI-launched HD/AD spawns this bridge with launchd's minimal+ environment (no /usr/local/bin), and the mac CLI ships INSIDE the .app bundle (never symlinked+ onto PATH), so a PATH-only lookup returns None on a perfectly healthy machine and+ _cli_notify_all silently degrades to "CLI not found" — the human-wall toast never leaves the+ box, which is the exact failure John called out. Probe the bundle paths explicitly.""" import shutil- la = os.environ.get("LOCALAPPDATA", "")- candidates = [- os.path.join(la, "Adom Desktop", "adom-desktop.exe") if la else None,- os.path.join(la, "Programs", "Adom Desktop", "adom-desktop.exe") if la else None,- ]+ home = os.path.expanduser("~")+ candidates = []+ for app_dir in (+ "/Applications/Adom Desktop.app",+ "/Applications/Hydrogen Desktop.app",+ os.path.join(home, "Applications", "Adom Desktop.app"),+ os.path.join(home, "Applications", "Hydrogen Desktop.app"),+ ):+ candidates.append(os.path.join(app_dir, "Contents", "Resources", "adom-desktop-cli"))+ candidates.append(os.path.join(app_dir, "Contents", "MacOS", "adom-desktop"))+ candidates.append("/usr/local/bin/adom-desktop")+ candidates.append(os.path.join(home, ".local", "bin", "adom-desktop")) for c in candidates:- if c and os.path.exists(c):+ if c and os.path.exists(c) and os.access(c, os.X_OK): return c- return shutil.which("adom-desktop") or shutil.which("adom-desktop.exe")+ return shutil.which("adom-desktop") def _cli_notify_all(title: str, body: str, level: str) -> dict:@@ -238,11 +252,10 @@ def _cli_notify_all(title: str, body: str, level: str) -> dict: "buttons": [{"label": "Got it"}]}) try: r = _sp.run([exe, "--target", "all", "notify_user", payload],- capture_output=True, text=True, timeout=30,- creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0))+ capture_output=True, text=True, timeout=30) out = (r.stdout or "") + (r.stderr or "") # AD's CLI returns {status:'ok', action:'displayed'} per target; treat a zero exit or a- # 'displayed'/'ok' in the output as delivered. The Windows exe may print nothing yet still+ # 'displayed'/'ok' in the output as delivered. The CLI may print nothing yet still # deliver, so a clean exit code is accepted too. delivered = (r.returncode == 0) or ("displayed" in out) or ('"status": "ok"' in out) or ("status':'ok" in out) return {"delivered": bool(delivered), "targets": "all", "error": None if delivered else out[:200]}@@ -311,7 +324,7 @@ _last_fg_notice = [0.0] def _notify_before_foreground(reason: str) -> None: """Baked-in courtesy toast BEFORE the bridge foregrounds Fusion (John, 2026-07-08). - Some Fusion interactions (SendInput key/click, CEF modal dialogs) can ONLY be driven+ Some Fusion interactions (synthetic key/click input, CEF modal dialogs) can ONLY be driven with Fusion in the FOREGROUND, which steals the user's focus mid-work. The standing rule: ALWAYS drive in the background; foreground ONLY as a last resort. When we truly must, TELL the user why (via an AD notify, so every Adom user learns the principle) and@@ -363,15 +376,15 @@ def _handle_new_electronics_from_eagle(args: dict) -> dict: but that FOREGROUNDS Fusion, so use the `.brd`+`fusion_open_board` route whenever you can place the parts yourself. The native `.fsch/.fbrd` container is opaque BINARY, un-authorable. - args: {schPath: Windows path to the .sch (required), brdPath: Windows path to the .brd- (optional; defaults to the .sch's sibling .brd)}+ args: {schPath: absolute path to the .sch on this Mac (required), brdPath: absolute path+ to the .brd (optional; defaults to the .sch's sibling .brd)} """ import time as _t sch = (args.get("schPath") or args.get("filePath") or "").replace("\\", "/") if not sch or not sch.lower().endswith(".sch"):- return {"success": False, "error": "schPath (a .sch file on the Windows host) is required.",+ return {"success": False, "error": "schPath (a .sch file on this machine) is required.", "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both "- "to Windows (send_files), then call with {schPath, brdPath}."}+ "onto this machine (send_files), then call with {schPath, brdPath}."} brd = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/") if not ad_client.available(): # Do NOT dead-end here (old bug: hard-failed even on latest AD when this bridge process@@ -515,12 +528,12 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: # WATCH for launch/licensing dialogs the moment the process is up - IN CODE, so # a launch is never blind to them (the seat-conflict + streamed-app-error dialogs- # are owned by AdskIdentityManager/FusionLauncher, invisible to the Fusion-scoped+ # are owned by AdskIdentityManager/the launcher, invisible to the Fusion-scoped # classifier). Auto-dismiss BENIGN errors. launch_dialogs = classify_launch_dialogs() for _dlg in launch_dialogs: if _dlg.get("category") in AUTO_DISMISS_CATEGORIES:- close_dialog_bg(_dlg.get("hwnd")) # benign ack (WM_CLOSE == Cancel/OK)+ close_dialog_bg(_dlg.get("hwnd")) # benign ack (background close == Cancel/OK) # ── DETERMINISTIC launch state loop (John 2026-07-06: track + handle state in # CODE, in the BACKGROUND, never punt to the user, never foreground). Poll the@@ -772,10 +785,10 @@ def _post_open_screenshot(result: dict, settle_time: float = 2.0) -> dict: def _capture_dialog_array(settle: float = 0.6) -> dict | None: """After a mutating op, enumerate + screenshot any owned popups / modal dialogs so the AI can SEE and ANALYZE them. Titles alone are not enough: a dialog's body text- lives in CEF/Qt child controls that are NOT Win32-readable, so we attach a per-dialog+ lives in CEF/Qt child controls that are NOT window-list-readable, so we attach a per-dialog screenshot (hwnd-targeted PrintWindow, background - never foreground/fullscreen). - Cheap when nothing is up: it enumerates windows (Win32 EnumWindows) and only+ Cheap when nothing is up: it enumerates windows (CGWindowList) and only screenshots when a real dialog is present. Returns None when no dialog is up; otherwise {dialogsDetected, dialogs:[{hwnd,title,category,resolution,screenshot}], _hint}. Never raises - best effort.@@ -876,7 +889,7 @@ def _apply_failure_dialogs(result: dict) -> dict: any up dialog; if one is in FAILURE_CATEGORIES it: 1. SCREENSHOTS the popup (hwnd-targeted, background) so the AI SEES it, 2. flips result.success -> False + sets errorCode/error/_hint,- 3. dismisses the pure-ack popup (WM_CLOSE) so it can't block the next command.+ 3. dismisses the pure-ack popup (background close) so it can't block the next command. Best-effort + cheap when nothing is up (title enumeration, no screenshot). Never raises.""" try: from handlers.dialog_classify import classify_blocking_dialogs, FAILURE_CATEGORIES@@ -943,7 +956,7 @@ def _handle_open_cloud_file(fusion_info: dict, args: dict) -> dict: "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 "+ "desktop (its list isn't keyboard/accessibility navigable); recovery prompts " "use fusion_dismiss_recovery; an update nag must be cleared on the desktop." ) # Screenshot even on failure — shows what went wrong@@ -982,8 +995,8 @@ def _handle_click_fusion(fusion_info: dict, args: dict) -> dict: y = args.get("y", 0.5) relative = args.get("relative", True) hwnd = args.get("hwnd") or args.get("dialogHwnd")- # SendInput clicks REQUIRE foreground (steals the user's focus). Announce it first -- # prefer background verbs (desktop_ui_click by name); this path is the last resort.+ # Synthetic (CGEvent) clicks REQUIRE foreground (steals the user's focus). Announce it+ # first - prefer background verbs (desktop_ui_click by name); this path is the last resort. _notify_before_foreground(args.get("reason") or "clicking a control Fusion only accepts via a foreground click") result = click_fusion(x, y, relative, hwnd=hwnd) if result.get("success"):@@ -992,13 +1005,13 @@ def _handle_click_fusion(fusion_info: dict, args: dict) -> dict: def _handle_send_key(fusion_info: dict, args: dict) -> dict:- """Send a key to Fusion or a specific dialog via SendInput."""+ """Send a key to Fusion or a specific dialog via synthetic (CGEvent) input.""" key = args.get("key", "") if not key: return {"success": False, "error": "No key specified. Use 'enter', 'escape', 'tab', etc."} hwnd = args.get("hwnd") or args.get("dialogHwnd")- # SendInput keystrokes REQUIRE foreground (steals the user's focus). Announce it first -- # prefer background verbs (desktop_ui_set / WM_CLOSE); this path is the last resort.+ # Synthetic keystrokes land in the FOCUSED app (steals the user's focus). Announce it+ # first - prefer background verbs (desktop_ui_set / fusion_close_window); last resort. _notify_before_foreground(args.get("reason") or f"sending the '{key}' key, which Fusion only accepts in the foreground") result = send_key_to_fusion(key, hwnd=hwnd) if result.get("success"):@@ -1007,18 +1020,19 @@ def _handle_send_key(fusion_info: dict, args: dict) -> dict: def _handle_close_window(fusion_info: dict, args: dict) -> dict:- """Close a dialog window by sending WM_CLOSE (equivalent to clicking X).+ """Close a dialog window via its AX close button (equivalent to clicking the+ close control), without stealing focus. Unlike Escape, this works on dialogs that don't have Cancel/Escape handling (e.g. the Recovered Documents dialog). Does NOT force-kill — the dialog can- still intercept WM_CLOSE and prompt for confirmation.+ still intercept the close and prompt for confirmation. """ hwnd = args.get("hwnd") if not hwnd: return {"success": False, "error": "Missing required arg: hwnd"} result = close_window(int(hwnd)) if result.get("success"):- result["output"] = f"Sent WM_CLOSE to hwnd {hwnd}."+ result["output"] = f"Closed window {hwnd} in the background." return result @@ -1086,8 +1100,8 @@ def _handle_screenshot_all_fusion(fusion_info: dict, args: dict) -> dict: "success": True, "screenshots": screenshots, "dialogCount": len(info.get("dialogs", [])) if info.get("success") else 0,- "_hint": f"Captured {len(screenshots)} screenshot(s). Screenshots saved on Windows — "- "use pull_file to get them to Docker, or Read tool to view each path directly. "+ "_hint": f"Captured {len(screenshots)} screenshot(s) to /tmp/conduit-screenshots — "+ "use pull_file to fetch them remotely, or the Read tool to view each path directly. " "If dialogs are present, dismiss with fusion_dismiss_blocking_dialogs.", } @@ -1127,28 +1141,13 @@ def _handle_relocate_recovery(fusion_info: dict, args: dict) -> dict: } -def _installer_running() -> bool:- """Is the Fusion Client Downloader / streamer currently installing? Bridge-side- check (subprocess from OUR process = no AD shell-approval gate)."""- try:- import subprocess as _sp- out = _sp.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True,- timeout=15, creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0)).stdout- return ("streamer.exe" in out) or ("FusionDL.exe" in out) or ("Fusion Client Downloader" in out)- except Exception:- return False-- def _handle_install_fusion(fusion_info: dict, args: dict) -> dict:- """Install Fusion 360 FOR the user - no shell_execute, no AD approval gate.-- Declared as detect.installVerb (AD >=1.9.79) so bridge_readiness recommends- THIS over the generic winget fallback. Downloads the official Fusion Client- Downloader and runs it silently: --globalinstall when elevated, PER-USER when- not (learned live: a non-admin shell makes --globalinstall die silently on- UAC; the per-user install needs no elevation and lands in %LOCALAPPDATA%,- which detect covers). Returns PROMPTLY - the stream takes 10-30 min; poll- fusion_readiness until installed:true."""+ """Report install state / guide a Fusion install on macOS.++ There is no verified silent-install path for Fusion on macOS yet, so this+ verb does not attempt one —+ blind-launching an unverified installer would be worse than an honest hint.+ It reports the live state and hands back the manual route.""" # LIVE detect only - never trust the bridge-start fusion_info snapshot, which can be # stale-True after Fusion was removed while the bridge kept running (found live on a # fresh VM 2026-07-05: this verb refused with "already installed" though disk was@@ -1156,42 +1155,19 @@ def _handle_install_fusion(fusion_info: dict, args: dict) -> dict: if detect_fusion().get("installed"): return {"success": True, "alreadyInstalled": True, "_hint": "Fusion 360 is already installed - call fusion_start to launch it."}- if _installer_running():+ if _incomplete_webdeploy_present(): return {"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."}-- import tempfile, urllib.request as _ur, subprocess as _sp, ctypes as _ct- stub = os.path.join(tempfile.gettempdir(), "FusionClientDownloader.exe")- try:- _ur.urlretrieve(- "https://dl.appstreaming.autodesk.com/production/installers/Fusion%20Client%20Downloader.exe",- stub)- except Exception as e:- return {"success": False, "error": f"Installer download failed: {e}",- "errorCode": "installer_download_failed",- "_hint": "Check the box is online; retry fusion_install_fusion. The stub URL is "- "Autodesk's official appstreaming installer."}- try:- elevated = bool(_ct.windll.shell32.IsUserAnAdmin()) if hasattr(_ct, "windll") else False- except Exception:- elevated = False- cmd = [stub, "--quiet"] + (["--globalinstall"] if elevated else [])- try:- _sp.Popen(cmd, stdin=_sp.DEVNULL, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,- creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0) | getattr(_sp, "DETACHED_PROCESS", 0))- except Exception as e:- return {"success": False, "error": f"Installer launch failed: {e}",- "errorCode": "installer_launch_failed"} return {- "success": True, "installing": True, "elevated": elevated,- "mode": "globalinstall" if elevated else "per-user",- "statusVerb": "fusion_readiness",- "_hint": ("Fusion 360 install STARTED (" + ("system-wide" if elevated else- "per-user - no admin needed; a non-elevated --globalinstall dies silently on UAC") +- "). 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."),+ "success": False, "installed": False,+ "errorCode": "fusion_install_manual_on_macos",+ "_hint": ("Fusion 360 is not installed and this bridge has no automated macOS "+ "installer yet. Ask the user to install it: download 'Autodesk Fusion' "+ "from autodesk.com (or their Autodesk account portal) and run the "+ "installer; it streams into ~/Library/Application Support/Autodesk/"+ "webdeploy/production/. Poll fusion_readiness until installed:true, "+ "then fusion_start."), } @@ -1217,18 +1193,18 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict: # (found live on a fresh VM 2026-07-05: NO_WEBDEPLOY on disk, yet readiness said # ready). detect_fusion() is a fast filesystem scan, so pay it every call. raw_installed = bool(detect_fusion().get("installed"))- # `streaming` must be RELIABLE. _installer_running() is a tasklist check, but the Autodesk streamer- # spawns short-lived per-chunk workers, so it reads False most of a live multi-GB stream (flickered- # True only 3 of 25 polls during a real install, John 2026-07-14). OR in the disk-state signal - a- # webdeploy hash dir with FusionLauncher.exe but a missing/truncated .ini - which is present for the- # WHOLE stream. Now `installing` is trustworthy, not racy.- streaming = _installer_running() or _incomplete_webdeploy_present()+ # `streaming` must be RELIABLE. A process check is useless here: the Autodesk streamer spawns+ # short-lived per-chunk workers, so it reads False most of a live multi-GB stream (flickered+ # True only 3 of 25 polls during a real install, John 2026-07-14). Use the disk-state signal -+ # a webdeploy hash dir with the app bundle but no complete binary anywhere - which is present+ # for the WHOLE stream. `installing` is trustworthy, not racy.+ streaming = _incomplete_webdeploy_present() running = _is_fusion_running() if raw_installed else False # FRESH-INSTALL COMPLETENESS GUARD (John caught this live 2026-07-14 on a fresh Hyper-V VM):- # the streamer writes FusionLauncher.exe BEFORE it finishes writing FusionLauncher.exe.ini, so- # detect_fusion() flips installed:true mid-stream. Starting into that half-written install throws- # "Error Launching Streamed Application ... FusionLauncher.exe.ini is missing or incomplete" and- # leaves TWO corrupt webdeploy production dirs. So: while the streamer is running and Fusion is+ # the streamer writes the app-bundle skeleton BEFORE its binary finishes, so a naive detect+ # could flip installed:true mid-stream. Starting into a half-written install throws+ # "Error Launching Streamed Application ..." and leaves corrupt webdeploy production dirs.+ # So: while the streamer is running and Fusion is # NOT already up (i.e. this is a first install, not a background auto-update of a running Fusion), # the install is STILL STREAMING - report installed:false + installing:true so nothing calls # fusion_start yet. Only treat it as installed once the streamer has exited.@@ -1349,14 +1325,12 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict: "(2) ASK the user FIRST (AskUserQuestion): do they have a warm Google/Apple/Microsoft " "session for the account LINKED to their Autodesk login? Offer to drive it. A truly " "fresh box has NO warm browser session, so 'Continue with Google' still hits a password "- "wall. (3) Click Fusion 'Sign In' -> it opens the DEFAULT browser (Edge) to the OAuth. "- "Edge windows DO exist even if PowerShell EnumWindows looks empty - that is CLIXML "- "progress noise; set $ProgressPreference='SilentlyContinue', and just "- "desktop_screenshot_window the Edge hwnd (PrintWindow works backgrounded). "+ "wall. (3) Click Fusion 'Sign In' -> it opens the DEFAULT browser to the OAuth; "+ "desktop_screenshot_window the browser window to read it (background-safe). " "(4) In the browser: 'Continue with Google' -> type the user's EMAIL (not secret) -> if " "it asks for a PASSWORD/2FA, STOP and fusion_notify_owner {title, body} to toast their " "MAIN computer so THEY type it; NEVER enter password/2FA yourself. (5) Handoff back to "- "Fusion is via the 'Autodesk Identity Manager' protocol - Edge shows a 'This site is "+ "Fusion is via the 'Autodesk Identity Manager' protocol - the browser shows a 'This site is " "trying to open...' overlay: tick 'Always allow' + click 'Open'. (6) SPEED MATTERS: the " "sign-in code EXPIRES in ~2 min; on a high-latency VM slow screenshot/click round-trips " "let it expire ('Sign-in request expired') and you loop. Minimize steps; if it expires, "@@ -1799,7 +1773,7 @@ COMMAND_HANDLERS = { "install_fusion": _handle_install_fusion, "open_design": handle_open_design, "close": handle_close_fusion, # deprecated alias - use stop (graceful) / kill (force)- "stop": handle_fusion_stop, # graceful: close docs + WM_CLOSE, NO force-kill+ "stop": handle_fusion_stop, # graceful: close docs + background window close, NO force-kill "kill": handle_fusion_kill, # force: taskkill /F (the desperate path) "launch": _handle_launch, "start": _handle_launch, # alias — CLI's fusion_start delegates here on Docker@@ -2235,7 +2209,7 @@ def _diagnose_addin_timeout(command: str, original_result: dict) -> dict: except Exception: pass # Best effort — don't let screenshot failure mask the real error - # Identify WHICH modal is blocking (titles enumerate over Win32 even while the+ # Identify WHICH modal is blocking (titles enumerate via CGWindowList even while the # add-in's main thread is stuck). Turns the opaque "add-in not responding" into # an actionable cause + resolution — and stops the needless restart loop. blocking_dialogs = classify_blocking_dialogs()@@ -2317,7 +2291,7 @@ def _diagnose_addin_timeout(command: str, original_result: dict) -> dict: "errorCode": "fusion_addin_not_responding", "_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).",+ "~/Library/Application Support/Autodesk/FusionAddins, and runOnStartup reloads it).", } @@ -2459,7 +2433,7 @@ def _orchestrate_attach_3d_package(args: dict) -> dict: Fusion then shows a modal Save dialog (an OWNED popup) — that single click is the only desktop-side step; this returns the exact instruction for it. - args: {filePath: Windows path to the .lbr, modelPath: Windows path to the+ args: {filePath: absolute path to the .lbr, modelPath: absolute path to the STEP, packageName: optional str} """ import time@@ -2469,8 +2443,8 @@ def _orchestrate_attach_3d_package(args: dict) -> dict: orient_flag = args.get("orient", True) # 2026-07-07: expose orient (was hard-on) if not file_path or not model_path: return {"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."}+ "error": "filePath (.lbr) and modelPath (.step) are required (absolute paths).",+ "_hint": "Stage both onto this machine first (send_files). See the fusion-libraries skill."} steps = [] open_res = _orchestrate_open_lbr({"filePath": file_path}) if not open_res.get("success"):@@ -2611,7 +2585,7 @@ def _discover_upload_target(args: dict) -> tuple: def _capture_labeled(label) -> str | None: """Background-capture the main Fusion window to a labeled PNG on the box- (C:/tmp/conduit-screenshots). Returns the saved path or None. Never fullscreen+ (/tmp/conduit-screenshots). Returns the saved path or None. Never fullscreen (hwnd-targeted PrintWindow, so it captures Fusion in the background per fusion-driving).""" if not label: return None@@ -2691,7 +2665,7 @@ def _orchestrate_make_3d_package(args: dict) -> dict: footprint (an incomplete package); (2) a raw STEP upload does not render at all ("Thumbnail download failed"). See the fusion-multipart-libraries skill. - args: {lbrPath: Windows .lbr whose FIRST package is the footprint, modelPath: Windows .step,+ args: {lbrPath: local .lbr whose FIRST package is the footprint, modelPath: local .step, projectId, folderId (both from fusion_aps_browse), fileName?: str, orient?: bool} """ import os as _os, time as _time@@ -2700,7 +2674,7 @@ def _orchestrate_make_3d_package(args: dict) -> dict: project_id, folder_id = _discover_upload_target(args) # an AI-owned non-root folder (never project root) if not lbr_path or not model_path: return {"success": False,- "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}+ "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (absolute paths)."} if not folder_id: return {"success": False, "errorCode": "no_work_folder", "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",@@ -2783,7 +2757,7 @@ def _orchestrate_make_3d_package(args: dict) -> dict: if not f3d_path or not str(f3d_path).endswith(".f3d"): return {"success": False, "error": "f3d saveAs did not produce a .f3d path: " + str(res), "before": before_shot, "after": after_shot,- "_hint": "Confirm modelPath is a valid Windows .step path and Fusion is running."}+ "_hint": "Confirm modelPath is a valid local .step path and Fusion is running."} up = aps.handle_upload({"projectId": project_id, "folderId": folder_id, "localPath": f3d_path, "fileName": f3d_name}) up_d = up.get("data", up) if isinstance(up, dict) else {}@@ -2876,7 +2850,7 @@ def _build_library_3d_core(args: dict) -> dict: + manual binding the AI used to do. args: {- lbrPath: combined .lbr to bind + open (Windows path),+ lbrPath: combined .lbr to bind + open (absolute path), parts: [{package, lbrPath (per-part .lbr whose FIRST package is this footprint), modelPath (.step)}], outLbrPath?: where to write the bound .lbr (default: overwrite lbrPath),@@ -2980,7 +2954,7 @@ def _build_library_3d_core(args: dict) -> dict: "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 "+ "on this machine (/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 "@@ -2989,7 +2963,7 @@ def _build_library_3d_core(args: dict) -> dict: "adom-desktop 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 "+ "/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 "@@ -3001,9 +2975,9 @@ def _build_library_3d_core(args: dict) -> dict: def _dismiss_dialogs_bg() -> list: """Dismiss any blocking Fusion dialog in the BACKGROUND, without stealing focus. - Uses close_window (WM_CLOSE via PostMessage) = Cancel/No on the dialog - the background-safe- dismiss. ⛔ NEVER use send_key/Escape for this: send_key calls SetForegroundWindow and YANKS- Fusion to the foreground, disrupting the user (learned 2026-06-29). WM_CLOSE is also more reliable+ Uses close_window (the AX close button) = Cancel/No on the dialog - the background-safe+ dismiss. ⛔ NEVER use send_key/Escape for this: send_key raises + focuses Fusion and YANKS+ it to the foreground, disrupting the user (learned 2026-06-29). The background close is also more reliable than Escape on Qt dialogs. Returns the titles dismissed.""" try: info = get_fusion_window_info()@@ -3012,7 +2986,7 @@ def _dismiss_dialogs_bg() -> list: hwnd = d.get("hwnd") if hwnd: try:- close_window(hwnd) # background WM_CLOSE = Cancel/No (never creates a stray)+ close_window(hwnd) # background close = Cancel/No (never creates a stray) dismissed.append(d.get("title") or "") except Exception: pass@@ -3029,15 +3003,15 @@ def _orchestrate_capture_library_views(args: dict) -> dict: Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). For each package it runs the EAGLE/Electron EDIT <pkg>.sym / .pac / .dev, WINDOW FIT to frame, and a- background hwnd screenshot (never fullscreen). Returns the saved PNG paths on the Windows box- (C:/tmp/conduit-screenshots - pull them with desktop_pull_file).+ background hwnd screenshot (never fullscreen). Returns the saved PNG paths+ (/tmp/conduit-screenshots - pull them with desktop_pull_file). args: {packages: [<deviceset name>, ...] (or a single 'package'), views?: subset of ['component','symbol','footprint'] (default all three), settle?: seconds to wait after each EDIT before framing/capturing (default 1.5 - raise it if a shot shows the PREVIOUS part, lower it to go faster on a snappy machine)} NOTE: ~2s per view * parts * views can exceed the ~60s relay request timeout - the captures still- complete server-side; verify the PNGs landed in C:/tmp/conduit-screenshots and pull them.+ complete server-side; verify the PNGs landed in /tmp/conduit-screenshots and pull them. """ import time as _t pkgs = args.get("packages") or ([args["package"]] if args.get("package") else [])@@ -3073,7 +3047,7 @@ def _orchestrate_capture_library_views(args: dict) -> dict: "success": n_ok > 0, "captured": out, "_hint": (- f"Captured {n_ok}/{len(out)} library-editor views to C:/tmp/conduit-screenshots (pull with "+ f"Captured {n_ok}/{len(out)} library-editor views to /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 "@@ -3248,13 +3222,13 @@ def _orchestrate_generate_package(args: dict) -> dict: import sys, glob, os, importlib import adsk.core, adsk.fusion -# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).-_bases = []-for env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):- root = os.environ.get(env)- if root:- _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',- 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))+# Locate EPG across install versions (NEVER hardcode the webdeploy hash). Verified on disk+# 2026-07-31: <production>/<hash>/Autodesk Fusion.app/Contents/Libraries/Neutron/Api/+# InternalAddins/ElectronicsPackageGenerator.+_bases = glob.glob(os.path.join(+ os.path.expanduser('~'), 'Library', 'Application Support', 'Autodesk', 'webdeploy',+ 'production', '*', 'Autodesk Fusion.app', 'Contents', 'Libraries', 'Neutron',+ 'Api', 'InternalAddins', 'ElectronicsPackageGenerator')) if not _bases: raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install') epg_dir = max(_bases, key=os.path.getmtime)@@ -3625,7 +3599,7 @@ def _orchestrate_export_optimized_glb(args: dict) -> dict: output_path = args.get("outputPath") or args.get("output_path") if not output_path: return {"success": False, "error": "No outputPath specified.",- "_hint": 'Usage: fusion_export_optimized_glb {"outputPath":"C:/tmp/board.glb", "silkscreen":true, "pin":"medium"}'}+ "_hint": 'Usage: fusion_export_optimized_glb {"outputPath":"/tmp/board.glb", "silkscreen":true, "pin":"medium"}'} if not output_path.lower().endswith(".glb"): output_path = output_path + ".glb" pin = args.get("pin", "medium")@@ -3705,7 +3679,7 @@ def _orchestrate_fetch_optimized_glb(args: dict) -> dict: job_id = args.get("jobId") if not job_id: return {"success": False, "error": "No jobId.",- "_hint": 'Usage: fusion_fetch_optimized_glb {"jobId":"...","outputPath":"C:/tmp/board.glb"}'}+ "_hint": 'Usage: fusion_fetch_optimized_glb {"jobId":"...","outputPath":"/tmp/board.glb"}'} out_path = args.get("outputPath") or args.get("output_path") if out_path and not out_path.lower().endswith(".glb"): out_path = out_path + ".glb"@@ -3858,7 +3832,7 @@ def _find_update_offer(hwnd=None) -> dict: # is open. Checking both is why this runs on a schedule rather than once. candidates = [int(hw)] try:- for w in (family_windows(int(hw)) or []):+ for w in (family_windows() or []): h2 = w.get("hwnd") if isinstance(w, dict) else w if h2 and int(h2) != int(hw): candidates.append(int(h2))@@ -4117,96 +4091,30 @@ def _handle_mcp_resources(fusion_info: dict, args: dict) -> dict: def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:- """Turn Autodesk's MCP server ON by driving Fusion's Preferences dialog.+ """Check / guide enabling Autodesk's MCP server. Autodesk exposes NO API for this toggle (apiPreferences has debuggingPort and- isDeveloperToolsEnabled but nothing for MCP), so the UI is the only route. Proven live- 2026-07-23. The tree children under General are rendered lazily by Qt and are NOT in the- UIA tree, so the section click is image-space and DOES take the foreground - announced.+ isDeveloperToolsEnabled but nothing for MCP), and mac Fusion's Preferences is a+ flat section list with no scripted open path (`Commands.Start PreferencesCommand`+ is a no-op on mac). Rather than blind-click unverified coordinates, we detect the+ already-on case and otherwise hand back the manual toggle. Once ticked, ALL the+ fusion_mcp_* client verbs work (pure loopback HTTP to 127.0.0.1:27182) — the+ toggle is the only manual step. """- import time as _t- if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):- return {"success": False, "error": "Fusion is not running.",- "_hint": "fusion_start first, then fusion_mcp_enable."} already = _mcp_new_session() if not already.get("error"): return {"success": True, "enabled": True, "alreadyOn": True, "_hint": "Already enabled; nothing to do. fusion_mcp_status shows the tools."}-- # macOS: the automated toggle below is Windows-specific — it uses- # `Commands.Start PreferencesCommand` (a no-op on mac Fusion; the mac open- # path is Cmd+, / the app menu), Windows-measured section coordinates (the- # mac Preferences layout is a flat section list, no "API" tree row under- # General), and AD's desktop_screenshot_window. Rather than blind-click- # unverified mac coordinates, hand back the manual toggle. Once ticked, ALL- # the fusion_mcp_* client verbs work identically on mac (pure loopback HTTP).- if sys.platform == "darwin":- return {"success": False, "enabled": False, "platform": "macos",- "error": "Automated MCP enable is Windows-only for now.",- "_hint": ("Turn it on manually in Fusion: press Cmd+, (or profile icon top-right "- "-> Preferences), select General, tick 'Fusion MCP Server (runs locally "- "on this device)', then click Apply and OK. It does NOT survive if Fusion "- "is killed before Apply. Once on, fusion_mcp_status / fusion_mcp_tools / "- "fusion_mcp_call / fusion_mcp_resources all work on macOS unchanged."),- "statusVerb": "fusion_mcp_status"}-- _announce_foreground("turning on Fusion's MCP server in preferences")- steps = []- r = _unwrap(_ad_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45))- _t.sleep(3)- dlg = None- for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):- if str(w.get("title", "")).strip() == "Preferences":- dlg = w.get("hwnd")- if not dlg:- return {"success": False, "error": "Preferences dialog did not open.",- "steps": steps, "_hint": _mcp_off_hint()}- steps.append("opened Preferences")-- def shot():- s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))- cm = s.get("coordMap") or {}- img = cm.get("image") or {}- return cm.get("shotId"), (img.get("w") or 1400), (img.get("h") or 836)-- sid_, W, H = shot()- if not sid_:- return {"success": False, "error": "could not capture Preferences", "steps": steps}- # fractional coords, measured on the real dialog (1400x836): expand arrow, then API row- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.029), "y": int(H * 0.092)}, timeout=30) # expand General- steps.append("expanded General")- _t.sleep(2)- sid_, W, H = shot()- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.071), "y": int(H * 0.135)}, timeout=30) # API row- steps.append("selected API")- _t.sleep(2)- sid_, W, H = shot()- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.493), "y": int(H * 0.569)}, timeout=30) # the checkbox- steps.append("ticked 'Fusion MCP Server'")- _t.sleep(1)- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30) # Apply- _t.sleep(2)- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30) # OK- steps.append("clicked Apply then OK")- _t.sleep(4)-- s = _mcp_new_session()- ok = not s.get("error")- return {"success": ok, "enabled": ok, "steps": steps,- "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),- "narrate": ("Fusion's MCP server is on. I enabled it for you in Preferences."- if ok else "I drove the Preferences dialog but the MCP port is still closed."),- "_hint": ("MCP server ENABLED - fusion_mcp_status lists its tools. Tell the user it is "- "handled; they do not need to touch Preferences."- if ok else- "The toggle did not take. Ask the user to set it manually: profile icon (top "- "right) -> Preferences -> General -> API -> tick 'Fusion MCP Server'. Do NOT "- "restart or kill Fusion before they click Apply, that discards the setting."),+ if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):+ return {"success": False, "error": "Fusion is not running.",+ "_hint": "fusion_start first, then enable MCP (see fusion_mcp_enable's hint)."}+ return {"success": False, "enabled": False,+ "error": "The MCP toggle must be set manually in Fusion's Preferences.",+ "_hint": ("Turn it on manually in Fusion: press Cmd+, (or profile icon top-right "+ "-> Preferences), select General, tick 'Fusion MCP Server (runs locally "+ "on this device)', then click Apply and OK. It does NOT survive if Fusion "+ "is killed before Apply. Once on, fusion_mcp_status / fusion_mcp_tools / "+ "fusion_mcp_call / fusion_mcp_resources all work."), "statusVerb": "fusion_mcp_status"} @@ -4217,146 +4125,39 @@ COMMAND_HANDLERS["mcp_resources"] = _handle_mcp_resources COMMAND_HANDLERS["mcp_enable"] = _handle_mcp_enable -# ── FUSION PREFERENCES, DRIVEN THROUGH THE UI (John, 2026-07-23) ─────────────────────────────+# ── FUSION PREFERENCES ─────────────────────────────────────────────────────────────────────── # Fusion's Python API exposes only a THIN slice of preferences (generalPreferences: theme, orbit, # units; apiPreferences: debuggingPort, isDeveloperToolsEnabled). Everything else - including the-# Fusion MCP Server toggle - is UI-only.-#-# But the Preferences dialog IS reachable programmatically:-# Commands.Start PreferencesCommand opens it (no menu hunting, no profile-icon click)-# and its LEFT-HAND SECTION TREE is exposed to UIA, so sections can be found by name.-#-# The catch, learned live: the CHILD sections under General (API, Design, Manufacture,-# Electronics, Render, Drawing, Simulation) are rendered LAZILY by Qt and never appear in the-# UIA tree, even after desktop_ui_expand reports success. So navigating into a child section-# needs an image-space click, which takes the foreground and is therefore announced with a-# caption. Everything up to that point is background.+# Fusion MCP Server toggle - is UI-only, and mac Fusion has no scripted open path for the dialog+# (`Commands.Start PreferencesCommand` is a no-op; the open path is Cmd+, / the app menu). Its+# section list is FLAT (General, Tokens, Material, Graphics, Network, ...), and no verified+# coordinate map has been measured for it, so these verbs guide rather than blind-click. # # ⛔ The setting is DISCARDED unless Apply/OK is clicked. Killing or restarting Fusion with the # dialog still open loses it (that is exactly why an earlier MCP enable silently reverted).-_PREF_SECTIONS = {- # section -> (parent-to-expand or None, fractional x, fractional y) on the 1400x836 dialog- "general": (None, 0.071, 0.092),- "api": ("general", 0.071, 0.135),- "design": ("general", 0.071, 0.179),- "manufacture": ("general", 0.071, 0.222),- "electronics": ("general", 0.071, 0.265),- "render": ("general", 0.071, 0.310),- "drawing": ("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),-}---def _prefs_dialog_hwnd():- for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):- if str(w.get("title", "")).strip() == "Preferences":- return w.get("hwnd")- return None---def _prefs_shot(hwnd):- s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))- cm = s.get("coordMap") or {}- img = cm.get("image") or {}- return (cm.get("shotId"), img.get("w") or 1400, img.get("h") or 836,- s.get("localSafePath")) def _handle_prefs_open(fusion_info: dict, args: dict) -> dict:- """Open Fusion Preferences, optionally navigate to a section, and hand back a screenshot the- AI can click in. This is how you reach ANY preference, not just the API-exposed few."""- import time as _t+ """Guide opening Fusion Preferences. Scripted navigation is pending a measured, verified+ coordinate map for the mac dialog — until then this hands back the manual path rather than+ blind-clicking unverified coordinates.""" if not _unwrap(_handle_fusion_readiness({}, {})).get("running"): return {"success": False, "error": "Fusion is not running.", "_hint": "fusion_start first."}- # macOS: same Windows-coupling as fusion_mcp_enable (PreferencesCommand text- # command + Windows section coordinates + AD desktop_screenshot_window). The- # mac Preferences layout differs (flat section list), so the fractional- # section coordinates don't transfer. Point at the manual open path until the- # mac coordinate map is measured + verified live.- if sys.platform == "darwin":- return {"success": False, "platform": "macos",- "error": "Scripted Preferences navigation is Windows-only for now.",- "_hint": ("Open Preferences on mac with Cmd+, (or the profile icon top-right -> "- "Preferences). The section list is flat (General, Tokens, Material, "- "Graphics, Network, ...) — no nested API row — so the Windows section "- "coordinates don't apply. Full mac automation is pending a measured "- "coordinate map.")}- section = str((args or {}).get("section") or "").strip().lower()- steps = []- hwnd = _prefs_dialog_hwnd()- if not hwnd:- _announce_foreground("opening Fusion preferences")- _ad_call("fusion_execute_text_command",- {"command": "Commands.Start PreferencesCommand"}, timeout=45)- _t.sleep(3)- hwnd = _prefs_dialog_hwnd()- steps.append("opened Preferences")- if not hwnd:- return {"success": False, "error": "Preferences dialog did not open.", "steps": steps}-- if section:- spec = _PREF_SECTIONS.get(section)- if not spec:- return {"success": False, "error": "Unknown section %r." % section,- "knownSections": sorted(_PREF_SECTIONS),- "_hint": ("Pass one of knownSections, or omit `section` and click the returned "- "shotId yourself with desktop_click {space:'image'}.")}- parent, fx, fy = spec- sid_, W, H, _p = _prefs_shot(hwnd)- if parent:- pspec = _PREF_SECTIONS[parent]- # click the expand arrow, left of the parent label- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * 0.029), "y": int(H * pspec[2])}, timeout=30)- steps.append("expanded %s" % parent)- _t.sleep(2)- sid_, W, H, _p = _prefs_shot(hwnd)- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * fx), "y": int(H * fy)}, timeout=30)- steps.append("selected %s" % section)- _t.sleep(2)-- sid_, W, H, path = _prefs_shot(hwnd)- return {"success": True, "hwnd": hwnd, "section": section or "general",- "shotId": sid_, "imageWidth": W, "imageHeight": H, "screenshot": path,- "steps": steps, "knownSections": sorted(_PREF_SECTIONS),- "_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"}+ return {"success": False,+ "error": "Scripted Preferences navigation is not available on this build.",+ "_hint": ("Open Preferences with Cmd+, (or the profile icon top-right -> Preferences). "+ "The section list is flat (General, Tokens, Material, Graphics, Network, ...). "+ "⛔ Nothing is saved until Apply/OK is clicked - killing or restarting Fusion "+ "first DISCARDS the change.")} def _handle_prefs_close(fusion_info: dict, args: dict) -> dict:- """Apply+OK (save:true, default) or Cancel the Preferences dialog."""- import time as _t- save = (args or {}).get("save", True)- hwnd = _prefs_dialog_hwnd()- if not hwnd:- return {"success": True, "closed": False,- "_hint": "Preferences was not open; nothing to do."}- sid_, W, H, _p = _prefs_shot(hwnd)- if save:- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30) # Apply- _t.sleep(2)- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30) # OK- else:- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * 0.951), "y": int(H * 0.948)}, timeout=30) # Cancel- _t.sleep(2)- return {"success": True, "closed": _prefs_dialog_hwnd() is None, "saved": bool(save),- "narrate": ("Saved your Fusion preferences." if save else "Closed preferences without saving."),- "_hint": ("Applied and closed. Some settings (the MCP server is one) only take effect "- "once saved this way." if save else "Cancelled; nothing was changed."),- "statusVerb": "fusion_get_preferences"}+ """Guide closing the Preferences dialog (no scripted navigation on this build)."""+ return {"success": False,+ "error": "Scripted Preferences navigation is not available on this build.",+ "_hint": ("Close Preferences in Fusion directly: click Apply then OK to SAVE (required "+ "for the MCP toggle to stick), or Cancel to discard.")} COMMAND_HANDLERS["prefs_open"] = _handle_prefs_open@@ -4404,11 +4205,11 @@ def _chrome_authorize_urls(max_age_min: int = 15): Returns a list of {url, sourceProfileDir, browser, ageSec} newest first. """ import glob, sqlite3, shutil, tempfile, time as _t- la = os.environ.get("LOCALAPPDATA", "")- roots = []- if la:- roots.append(("chrome", os.path.join(la, "Google", "Chrome", "User Data")))- roots.append(("edge", os.path.join(la, "Microsoft", "Edge", "User Data")))+ app_support = os.path.expanduser("~/Library/Application Support")+ roots = [+ ("chrome", os.path.join(app_support, "Google", "Chrome")),+ ("edge", os.path.join(app_support, "Microsoft Edge")),+ ] now = _t.time() found = [] for browser, root in roots:@@ -4471,7 +4272,7 @@ def _fusion_window_hwnd(): # ── NEVER STEAL THE USER'S FOREGROUND (John, 2026-07-22) ───────────────────────────────────── # "try to NEVER bring windows to the foreground cuz its disruptive."-# desktop_click is SendInput and FOREGROUNDS the window. desktop_ui_click/desktop_ui_set are UIA+# desktop_click is synthetic input and FOREGROUNDS the window. desktop_ui_click/desktop_ui_set are accessibility-tree # Invoke/SetValue: programmatic, NO focus steal, NO cursor move, and Chromium exposes its a11y # tree so page buttons/fields ARE reachable by accessible name. So: ALWAYS try background UIA # first and only fall back to a foreground click for things with no UIA node (Fusion's Sign In@@ -4576,7 +4377,7 @@ def _announce_foreground(reason: str) -> bool: def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reason=None) -> dict: """Click preferring the BACKGROUND UIA path (no focus steal, no cursor move). Only falls back- to SendInput - which DOES foreground - when there is no UIA node, and in that case announces+ to a synthetic click - which DOES foreground - when there is no accessible node, and in that case announces WHY to the user first. Returns {clicked, via, why, announced}.""" if name: r = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))@@ -4588,7 +4389,7 @@ def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reaso _ad_call("desktop_click", {"space": "image", "shotId": shot_id, "x": int(x), "y": int(y), "hwnd": int(hwnd)}, timeout=30) return {"clicked": True, "via": "foreground_click", "announced": announced,- "why": "no UIA node; used SendInput (DOES foreground - user was told why)"}+ "why": "no accessible node; used a synthetic click (DOES foreground - user was told why)"} return {"clicked": False, "via": None, "announced": False, "why": "no UIA node and no shot coords given"} @@ -4706,7 +4507,7 @@ def _fusion_click_signin() -> dict: return {"clicked": False, "why": "could not find the Fusion window"} # A MINIMIZED Fusion captures as a ~237x39 title-bar sliver, so the click lands on nothing.- # Restore it first (seen live: rect was at -32000,-32000, the Windows minimized position).+ # Restore it first (a minimized/parked window reports an off-screen rect). rect = win.get("rect") or {} if (rect.get("left") is not None and rect["left"] <= -30000) or \ (0 < (rect.get("width") or 0) < 600) or (0 < (rect.get("height") or 0) < 400):@@ -5589,12 +5390,12 @@ def dispatch_command(command: str, args: dict) -> dict: "success": False, "error": "Fusion 360 is running but AdomBridge add-in not responding.", "errorCode": "fusion_addin_not_responding",- "_hint": "The add-in isn't responding. Fix it YOURSELF - never ask the user: the bridge auto-installs the add-in to ALL Fusion add-in dirs (2025+ Fusion scans %APPDATA%/Autodesk/FusionAddins; the legacy API/AddIns dirs are silently ignored - issue #63), so restart Fusion via fusion_stop + fusion_start and it loads (runOnStartup). Verify with fusion_addin_status.",+ "_hint": "The add-in isn't responding. Fix it YOURSELF - never ask the user: the bridge auto-installs the add-in to ALL Fusion add-in dirs (2025+ Fusion scans ~/Library/Application Support/Autodesk/FusionAddins; the legacy API/AddIns dirs are silently ignored - issue #63), so restart Fusion via fusion_stop + fusion_start and it loads (runOnStartup). Verify with fusion_addin_status.", } # ── Busy gate: reject add-in commands immediately if a long command is running ── # Bridge-level commands (COMMAND_HANDLERS) already returned above — they use- # Win32 APIs and don't touch the add-in, so they always work during a walk.+ # the window list and don't touch the add-in, so they always work during a walk. # But add-in commands (and orchestrated commands that proxy to the add-in) # would pile up behind _main_thread_lock for 300s and crash the host. busy = _get_long_command()@@ -5705,9 +5506,9 @@ def dispatch_command(command: str, args: dict) -> dict: if info.get("dialogs"): for d in info["dialogs"]: try:- # Use WM_CLOSE via PostMessage — doesn't steal foreground- # (unlike send_key which uses SendInput + SetForegroundWindow).- # WM_CLOSE is also more reliable than Escape for Qt dialogs.+ # Background AX close — doesn't steal foreground (unlike+ # send_key, which raises + focuses the window). It is also+ # more reliable than Escape for Qt dialogs. close_window(d.get("hwnd")) except Exception: pass@@ -5767,12 +5568,12 @@ def _build_status() -> dict: addin = _probe_addin() if running else None addin_ok = bool(addin) - # Windows and macOS are both fully supported (macOS via handlers/mac_ui.py);- # only other platforms (Linux has no Fusion) report unsupported.- if platform.system() not in ("Windows", "Darwin"):+ # This bridge is macOS-only (window automation via handlers/mac_ui.py);+ # any other platform reports unsupported.+ if platform.system() != "Darwin": led, summary = "yellow", "Unsupported OS"- tooltip = ("Bridge running, but this host is not Windows or macOS. Fusion 360 "- "verbs need a host with Fusion 360 installed.")+ tooltip = ("Bridge running, but this host is not macOS. This build of the Fusion "+ "bridge is macOS-only.") elif not installed: led, summary = "yellow", "Fusion not installed" tooltip = ("Bridge running, but Fusion 360 is not installed. The AI can install it "@@ -5954,9 +5755,9 @@ def main(): print(f"[Fusion Bridge] Available commands: {', '.join(all_commands)}") # AD (>=1.9.63) passes ADOM_BIND_HOST (always 127.0.0.1) to every bridge it- # spawns. Honor it and NEVER bind 0.0.0.0/'' by default - a public bind pops a- # Windows Firewall "allow access?" dialog, and AD's guarantee to users is no- # firewall prompts. Default to loopback if the var is absent (e.g. local dev).+ # spawns. Honor it and NEVER bind 0.0.0.0/'' by default - a public bind can pop+ # the OS firewall's "accept incoming connections?" dialog, and AD's guarantee to+ # users is no firewall prompts. Default to loopback if the var is absent (e.g. local dev). bind_host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1") server = ThreadingHTTPServer((bind_host, port), FusionBridgeHandler) server.daemon_threads = True
skills/fusion-aps-signin/SKILL.md+1−1@@ -235,7 +235,7 @@ This whole flow was driven live and works. Replicate it per org: ## Port choice for the callback Check availability LIVE on the box (no AD verb exists): `python` bind-test on 127.0.0.1 + `netsh int ipv4 show excludedportrange protocol=tcp`. 8917/8918/8920 verified free + outside-Windows reserved ranges (5357, 27339, 50000-50059). The box runs ~67 listeners — don't assume.+OS-reserved ranges. The box runs many listeners — don't assume. ## Gotchas - `nbrowser_eval/type/click` without `sessionId` hit the foreground tab → often the wrong
skills/fusion-autodesk-signin/SKILL.md+1−1@@ -45,7 +45,7 @@ will take you to your web browser to sign in" and opens the **default browser** ## Step 3 — Find the Edge OAuth window (it IS there) The Edge window can look invisible: `Get-Process msedge` shows background procs and your window-enumeration may print nothing. That "nothing" is almost always **PowerShell CLIXML progress noise+enumeration may print nothing. That "nothing" is often just **shell progress noise eating your output**, NOT a missing window. Fixes: - Put `$ProgressPreference='SilentlyContinue'` at the TOP of the PS script. - Enumerate `Chrome_WidgetWin` class windows (the OAuth tab is titled "Sign in - Autodesk"); a 2-tab
skills/fusion-bridge-dev/SKILL.md+7@@ -5,6 +5,13 @@ description: How to develop, version, and PUBLISH the Adom Desktop Fusion 360 br # Fusion bridge — dev + publish workflow +> **⚠ macOS-only line (Kyle, 2026-07-31).** This page's bridge (`adom/adom-desktop-fusion-bridge-macos`)+> is a **macOS-only** bridge: every Windows code path (Win32 window automation, tasklist/netstat+> walks, `.exe` markers, the Windows installer flow) has been removed. Sections below that recount+> Win32-era mechanisms (SendInput, HWND truncation, UIA, PowerShell) are **development history from+> the Windows line** — port the *lessons* (background-only windows, deterministic detection), never+> the mechanics. The Windows bridge lives separately at `adom/adom-desktop-fusion-bridge`.+ ## ★ THE CORE GOAL: the AI runs EVERYTHING so the user never has to (why this bridge exists) The entire reason adom-desktop and its bridges exist is so that **the AI drives the whole flow
skills/fusion-build-parts-library/SKILL.md+4−4@@ -38,11 +38,11 @@ Gives, per combo, a `.lbr` with one `<package>` + `<symbol>` + `<deviceset>` (na ## Step 4 - build the 3D once per footprint (~10 calls, not 138) Merge the ~10 base `.lbr` into a combined `base10.lbr` (concatenate `<packages>`/`<symbols>`/-`<devicesets>`), stage to Windows, then:+`<devicesets>`), stage onto this machine, then: ```jsonc-fusion_build_library_3d { "lbrPath":"C:/.../base10.lbr",+fusion_build_library_3d { "lbrPath":"/path/to/base10.lbr", "parts":[{package, lbrPath (per-combo .lbr), modelPath (.step)}, ...],- "outLbrPath":"C:/.../base10_bound.lbr", "task":"AdomBasicParts-3D" }+ "outLbrPath":"/path/to/base10_bound.lbr", "task":"AdomBasicParts-3D" } ``` This makes a footprint+chip `.f3d` per combo, uploads it **into `Adom AI Workspace/<task>`** (never the project root - see [fusion-cloud-hygiene](../fusion-cloud-hygiene/SKILL.md)), and injects the@@ -91,7 +91,7 @@ exports STEP in ONE call. Params are datasheet **mm** (auto-converted). markings are separate `Marking` bodies (white in Fusion and in the colored STEP). ```bash-adom-desktop fusion_generate_package '{"type":"soic","params":{"DPins":8,"D":4.9,"E":6.0,"E1":3.9,"e":1.27,"A":1.6,"A1":0.1,"b":0.41,"L":0.84},"etch":"LM358","outputStep":"C:/tmp/ADOM-LM358-SOIC8.step"}'+adom-desktop fusion_generate_package '{"type":"soic","params":{"DPins":8,"D":4.9,"E":6.0,"E1":3.9,"e":1.27,"A":1.6,"A1":0.1,"b":0.41,"L":0.84},"etch":"LM358","outputStep":"/tmp/ADOM-LM358-SOIC8.step"}' ``` Read the verb's `_hint` on every call - it carries the band/margin/contrast rules and the per-family
skills/fusion-driving/SKILL.md+2−2@@ -46,7 +46,7 @@ ways to get the array, in order of preference: - AD core `desktop_screenshot_window {hwnd}` saves the PNG **into your container** (e.g. `/tmp/adom-desktop-screenshots/`) - Read it directly. - The bridge's own captures (the auto `dialogArray[].screenshot` paths and `fusion_screenshot_all`)- are saved on the **Windows box** (`C:/tmp/conduit-screenshots/`). To see one, either re-capture that+ are saved on this machine (`/tmp/conduit-screenshots/`). To see one, either re-capture that hwnd with `desktop_screenshot_window {hwnd}` (lands in your container) or `desktop_pull_file` it over. Either way, LOOK - do not describe it from memory.@@ -133,7 +133,7 @@ mid-run, that was almost certainly YOU, not the user - own it, don't blame them. commands and told the AI to read them. That same capture now fires after **all mutating verbs** via `_capture_dialog_array()`, which: -- cheaply enumerates dialog windows (`get_fusion_window_info`, a Win32 `EnumWindows` - no screenshot+- cheaply enumerates dialog windows (`get_fusion_window_info`, a CGWindowList enumeration - no screenshot when nothing is up), - suppresses false positives: a docked panel (Browser/Timeline) shares the "Fusion360" title and can slip past the size filter, but it does NOT disable the main window. So a generic-titled candidate is
skills/fusion-eagle-commands/SKILL.md+2−2@@ -103,8 +103,8 @@ These commands open modal prompts EAGLE waits on forever. Your call will hang un | Adom command | Output format | Notes | |---|---|---|-| `fusion_export_eagle_source {"outputPath":"C:/out/X.brd"}` | Plain EAGLE XML (.sch / .brd) | Extracts from Fusion's ZIP container. Parseable by standalone EAGLE, KiCad import, or any XML tool. |-| `fusion_export_source {"outputPath":"C:/out/X.fbrd"}` | Fusion-wrapped ZIP (.fsch / .fbrd / .flbr) | Contains the EAGLE XML inside a ZIP with metadata + preview PNGs. |+| `fusion_export_eagle_source {"outputPath":"/tmp/out/X.brd"}` | Plain EAGLE XML (.sch / .brd) | Extracts from Fusion's ZIP container. Parseable by standalone EAGLE, KiCad import, or any XML tool. |+| `fusion_export_source {"outputPath":"/tmp/out/X.fbrd"}` | Fusion-wrapped ZIP (.fsch / .fbrd / .flbr) | Contains the EAGLE XML inside a ZIP with metadata + preview PNGs. | Workflow for both: 1. Open the .fprj: `fusion_open_cloud_file {projectName, fileName, fileExtension:"fprj", folderPath}`
skills/fusion-electronics/SKILL.md+2−2@@ -83,7 +83,7 @@ means an empty/wrong file - see section 1. used to receive an unquoted path and split it at the first space: ```-C:/Users/drew/Downloads/e2e-pmcoil/PM COIL Molecule.brd+~/Downloads/e2e-pmcoil/PM COIL Molecule.brd ^ Fusion only ever saw ".../PM" ``` @@ -96,7 +96,7 @@ Fixed in `_open_local_document()` (`commands/open_electronics_file.py`), now use `fusion_open_board`, `fusion_open_schematic`, `fusion_import_electronics` and `fusion_open_lbr`: 1. **Quote the path**, then-2. if that fails and the path has spaces, retry with the Windows **8.3 short path**+2. quoting means spaces cannot split the argument (`GetShortPathNameW`) - same file, no spaces, nothing copied. **If you are writing a new verb that hands a path to `executeTextCommand`, use that helper.** Never
skills/fusion-libraries/SKILL.md+13−13@@ -1,6 +1,6 @@ --- name: fusion-libraries-description: How an Adom component library (symbol + footprint + 3D chip) is built and gets into Fusion 360 Electronics as a placeable .lbr. Covers the full pipeline (chip-fetcher -> adom-symbol / adom-footprint -> adom-lbr -> fusion_open_lbr), the Windows file-staging gotcha (there is NO container-to-Windows push verb), and attaching the real 3D chip with fusion_attach_3d_package (the Package3D-generator FINISH that binds a package3d wip_urn onto the deviceset). Read before building or opening any library, or when a part shows only a placeholder 3D package. Trigger words - lbr, eagle library, fusion library, open lbr, chip-fetcher, adom-lbr, adom-symbol, adom-footprint, kicad_sym, kicad_mod, deviceset, package3d, 3d package, 3d chip, fusion_attach_3d_package, wip_urn, attach 3d model, placeholder package, content manager, library into fusion, get library into fusion.+description: How an Adom component library (symbol + footprint + 3D chip) is built and gets into Fusion 360 Electronics as a placeable .lbr. Covers the full pipeline (chip-fetcher -> adom-symbol / adom-footprint -> adom-lbr -> fusion_open_lbr), the file-staging gotcha (paths must be local to the machine running Fusion), and attaching the real 3D chip with fusion_attach_3d_package (the Package3D-generator FINISH that binds a package3d wip_urn onto the deviceset). Read before building or opening any library, or when a part shows only a placeholder 3D package. Trigger words - lbr, eagle library, fusion library, open lbr, chip-fetcher, adom-lbr, adom-symbol, adom-footprint, kicad_sym, kicad_mod, deviceset, package3d, 3d package, 3d chip, fusion_attach_3d_package, wip_urn, attach 3d model, placeholder package, content manager, library into fusion, get library into fusion. --- # Fusion libraries - symbol + footprint + 3D into a placeable .lbr@@ -54,24 +54,24 @@ See **[LIBRARY_FINDINGS.md](LIBRARY_FINDINGS.md)** for the full live findings lo ## 2. Opening a .lbr in Fusion (verified working) -`fusion_open_lbr {"filePath": "C:/Users/<user>/.../NAME.lbr", "verify": true}`+`fusion_open_lbr {"filePath": "/Users/<user>/adom-lib/NAME.lbr", "verify": true}` - Under the hood it runs `Document.newDesignFromLocal <path>`, which opens the library and switches to the **Electronics Library** workspace. - `verify:true` re-exports the loaded library to an EAGLE script and returns a preview - proof Fusion actually parsed the deviceset (layers/symbol/package), not just that a window opened. - After it returns, confirm with `fusion_get_app_state`: expect `activeDocument: NAME`, `activeWorkspace: "Electronics Library"`, `isElectronics: true`. Then screenshot the Fusion window (`desktop_find_window {titleContains:"Fusion"}` -> hwnd -> `desktop_screenshot_window {hwnd}`) to show it. -## 3. THE WINDOWS-STAGING GOTCHA (read this first)+## 3. THE FILE-STAGING GOTCHA (read this first) -`fusion_open_lbr` (and `fusion_import_file`) take a **filePath on the WINDOWS machine** where Fusion runs (`C:/...`). The whole chip-fetcher/adom-lbr pipeline runs in the **cloud container**, so the `.lbr` / `.step` is on the Linux box, which Fusion cannot read. **There is NO container-to-Windows push verb** (`desktop_pull_*` only goes Windows -> container).+`fusion_open_lbr` (and `fusion_import_file`) take a **filePath local to the machine running Fusion**. If the chip-fetcher/adom-lbr pipeline ran in a **cloud container**, the `.lbr` / `.step` is on that box, which Fusion cannot read — stage it here first (`send_files`, or serve + curl). -Stage the file onto Windows first. Proven method (same proxy that serves shotlog):+Stage the file onto this machine first. Proven method (same proxy that serves shotlog): 1. Serve it from the container: `cd <dir> && python3 -m http.server 8931` (any proxied port).-2. Download on Windows via `shell_execute`:- `powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path C:\Users\<user>\adom-lib | Out-Null; Invoke-WebRequest -Uri 'https://<workspace>.adom.cloud/proxy/8931/NAME.lbr' -OutFile 'C:\Users\<user>\adom-lib\NAME.lbr'"`-3. Open the Windows path with `fusion_open_lbr`.+2. Download locally via `shell_execute`:+ `mkdir -p ~/adom-lib && curl -fsSL 'https://<workspace>.adom.cloud/proxy/8931/NAME.lbr' -o ~/adom-lib/NAME.lbr`+3. Open the local path with `fusion_open_lbr`. -Confirm the byte count matches after download. Build the `shell_execute` JSON in Python (`json.dumps`), not by hand - nested cmd/PowerShell quoting mangles easily, and Windows paths need `\\` in JSON.+Confirm the byte count matches after download. Build the `shell_execute` JSON in Python (`json.dumps`), not by hand - nested shell quoting mangles easily. ## 4. Attaching the real 3D chip (SOLVED via fusion_attach_3d_package) @@ -84,8 +84,8 @@ urn:adsk.wipprod:fs.file:vf.<id>?version=<N> ``` **The verified flow (proven on nRF54L15; full walkthrough + screenshots in [MAKING_LIBRARIES.md](MAKING_LIBRARIES.md)):**-1. **Model -> cloud -> URN.** Stage the STEP to Windows, then `fusion_aps_upload {projectId, folderId, localPath:"C:/.../<MPN>.step"}` returns a real cloud URN (`urn:adsk.wipprod:dm.lineage:...`). `Electron.getCloudPathFromUrn` confirms the 3D package references a Fusion **file** URN, which this produces. (`fusion_import_file` is NOT a routed verb; do not rely on it.)-2. **Open the generator.** `fusion_execute_text_command {"command":"Electron.Create3DPackage C:/.../<MPN>.lbr"}` opens the **3D PACKAGE Generator**: a Design-workspace doc with the footprint loaded and a PACKAGE / SELECT / FINISH ribbon.+1. **Model -> cloud -> URN.** Stage the STEP locally, then `fusion_aps_upload {projectId, folderId, localPath:"/path/to/<MPN>.step"}` returns a real cloud URN (`urn:adsk.wipprod:dm.lineage:...`). `Electron.getCloudPathFromUrn` confirms the 3D package references a Fusion **file** URN, which this produces. (`fusion_import_file` is NOT a routed verb; do not rely on it.)+2. **Open the generator.** `fusion_execute_text_command {"command":"Electron.Create3DPackage /path/to/<MPN>.lbr"}` opens the **3D PACKAGE Generator**: a Design-workspace doc with the footprint loaded and a PACKAGE / SELECT / FINISH ribbon. 3. **Insert model + FINISH** -> Fusion writes the `package3d` URN back to the library. **SOLVED - use `fusion_attach_3d_package` (shipped in v1.5.7):**@@ -104,10 +104,10 @@ Uploading models into the user's Fusion cloud is an outward, persistent action - ## 5. Pitfalls (each one bit us) -- **Container path passed to `fusion_open_lbr`** -> "file not found" / silent fail. The path must be Windows-local; stage it first (section 3).+- **Container path passed to `fusion_open_lbr`** -> "file not found" / silent fail. The path must be local to this machine; stage it first (section 3). - **Opening a child instead of a library** - a `.lbr` is a library, not an electronics design; it opens into the **Electronics Library** workspace, not the PCB editor. (Different from the project/schematic/board hierarchy - see the `fusion-electronics` skill.) - **Assuming the 3D is there** - adom-lbr output is 2D; the package is a placeholder. A part "looking amazing" in symbol+footprint still has NO 3D chip until the URN is linked (section 4). - **Guessing the `package3d` XML** - no confirmed example exists; reverse-engineer it from a real Fusion-made attachment before writing any.-- **Hand-built `shell_execute` quoting** - use `json.dumps`; escape Windows `\` as `\\` in the JSON.+- **Hand-built `shell_execute` quoting** - use `json.dumps`, never hand-escape. - **`desktop_screenshot_window` needs `hwnd`** (not `titleContains`); resolve it via `desktop_find_window` first. It returns the PNG as inline base64 - decode to a file. - **`adom-lbr validate` takes a positional `<PATH>`**, not `--lbr`.
skills/fusion-molecule-export/SKILL.md+2−2@@ -93,13 +93,13 @@ request timeout), so it is submit-then-poll, never one long blocking call: ``` # 1) export STEP + submit to the optimizer (returns fast with a jobId; small boards may # already come back complete within `wait` seconds)-fusion_export_optimized_glb {"outputPath":"C:/tmp/board.glb", "silkscreen":true, "pin":"medium", "wait":15}+fusion_export_optimized_glb {"outputPath":"/tmp/board.glb", "silkscreen":true, "pin":"medium", "wait":15} # -> { pending:true, jobId:"...", stepPath, resultUrl } (big board) # -> { status:"complete", glbPath, sizeBytes, meshesAfter, moleculeAnchored } (small board) # 2) finish it: re-call until status=complete (each call polls ~15s, well under the relay # timeout, and writes the GLB on the box when ready). No Fusion needed for this verb.-fusion_fetch_optimized_glb {"jobId":"...", "outputPath":"C:/tmp/board.glb", "wait":15}+fusion_fetch_optimized_glb {"jobId":"...", "outputPath":"/tmp/board.glb", "wait":15} # -> { status:"complete", glbPath, sizeBytes } then pull_file it. ```
skills/fusion-multipart-libraries/SKILL.md+8−8@@ -20,13 +20,13 @@ pass, the finished library opened once, plus a BEFORE/AFTER screenshot per part: ```jsonc fusion_build_library_3d {- "lbrPath": "C:/Users/<user>/lib/Combined.lbr", // the merged 2D library to bind + open+ "lbrPath": "/Users/<user>/lib/Combined.lbr", // the merged 2D library to bind + open "parts": [ // one entry per part- {"package":"R_4k7","lbrPath":"C:/.../R_4k7.lbr","modelPath":"C:/.../R_4k7.step"},- {"package":"ESP32-S3FN8","lbrPath":"C:/.../ESP32-S3FN8.lbr","modelPath":"C:/.../ESP32-S3FN8-named.step"}+ {"package":"R_4k7","lbrPath":"/path/to/R_4k7.lbr","modelPath":"/path/to/R_4k7.step"},+ {"package":"ESP32-S3FN8","lbrPath":"/path/to/ESP32-S3FN8.lbr","modelPath":"/path/to/ESP32-S3FN8-named.step"} // ... ],- "outLbrPath": "C:/Users/<user>/lib/Combined_bound.lbr", // optional (default: overwrite lbrPath)+ "outLbrPath": "/Users/<user>/lib/Combined_bound.lbr", // optional (default: overwrite lbrPath) "capture": true // before/after PNGs per part (default true) } ```@@ -38,7 +38,7 @@ fusion_build_library_3d { not have to `fusion_aps_browse` for them. - Returns `{partsBound, partsTotal, parts:[{package,success,wip_urn,dims_mm,error}], screenshots: [{package,stage,path}], boundLbr, opened}`. The before/after PNGs land on the box- (`C:/tmp/conduit-screenshots`) - `desktop_pull_file` them for a demo video.+ (`/tmp/conduit-screenshots`) - `desktop_pull_file` them for a demo video. - **Each part is independent.** If Fusion resets mid-run, the parts already done kept their f3d uploads; just re-run the whole `parts[]` (re-making a few f3ds is cheap) - the injection rewrites all bindings in one pass, so a single final call always yields a complete library.@@ -51,7 +51,7 @@ still running server-side and finishes fine.** Do NOT treat that as failure. Ins 1. Wait, then **verify by the artifacts, not the response**: read the `boundLbr` and count `<package3d name=` (should equal your part count), and list the before/after PNGs in- `C:/tmp/conduit-screenshots`.+ `/tmp/conduit-screenshots`. 2. If a part is missing (the FIRST part of a run is the usual casualty, as Fusion settles from a prior view), **just re-run `fusion_build_library_3d` for the missing parts with `outLbrPath` pointing at the SAME file.** The binding injection is **merge-aware + idempotent** (v1.6.1+): it reads the@@ -112,7 +112,7 @@ A proper component 3D package contains the **FOOTPRINT (pads + courtyard) AND th aligned, so the 3D viewer can verify the chip's pads land on the footprint pads. For each part: 1. **Make the component f3d (footprint + chip) - one verb does it:**- `fusion_make_3d_package {lbrPath:"C:/.../sp_PART.lbr", modelPath:"C:/.../PART.step", projectId, folderId}`+ `fusion_make_3d_package {lbrPath:"/path/to/sp_PART.lbr", modelPath:"/path/to/PART.step", projectId, folderId}` -> returns a `wip_urn`. It opens the lbr, runs `Electron.Create3DPackage` to load the package's FOOTPRINT into a generator doc, imports the chip onto it, orients it flat, `saveAs` an `.f3d` (footprint + chip), uploads it, and constructs the urn. (projectId/folderId from `fusion_aps_browse`.)@@ -127,7 +127,7 @@ aligned, so the 3D viewer can verify the chip's pads land on the footprint pads. app = adsk.core.Application.get(); doc = app.activeDocument # the generator doc (HAS the footprint) d = adsk.fusion.Design.cast(app.activeProduct); root = d.rootComponent im = app.importManager- im.importToTarget(im.createSTEPImportOptions("C:/.../PART.step"), root) # chip ONTO the footprint+ im.importToTarget(im.createSTEPImportOptions("/path/to/PART.step"), root) # chip ONTO the footprint oc = root.occurrences.item(root.occurrences.count-1); bb = oc.boundingBox dx,dy,dz = bb.maxPoint.x-bb.minPoint.x, bb.maxPoint.y-bb.minPoint.y, bb.maxPoint.z-bb.minPoint.z axis = (1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None) # smallest -> flat in Z
skills/fusion-onboarding/SKILL.md+1−1@@ -37,7 +37,7 @@ Download it to a no-space path on the laptop (e.g. C:\tmp\): ``` shell_execute {"command":"curl -L -o C:\\tmp\\FusionDL.exe \"https://dl.appstreaming.autodesk.com/production/installers/Fusion%20Client%20Downloader.exe\""} ```-(PowerShell `Invoke-WebRequest` works too; curl ships on modern Windows.)+(curl ships on macOS.) ## Step 2 — Silent install (elevated) ```