← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.8.4\ No newline at end of file+1.8.5\ No newline at end of file
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.++![Adom basic parts library in the Fusion library manager](screenshots/library-manager-138.png)++- `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>++![138-part overview](screenshots/lib3-138-overview.png)++## 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.++![0603 chip with 103 etched](screenshots/epg-0603-103.png)++```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"+}'+```++![SOIC-8 with white LM358 marking](screenshots/epg-soic8-lm358-white.png)++**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.++![Two-line MPN + variant marking with enforced margins](screenshots/epg-2line-margins.png)++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:++![DRV8411A copper layout in the Fusion Electronics editor](fz-2d.png)++![TI DRV8323SR gate-driver board, rendered in 3D](fz-3d.png)++![ST VL53L8 ToF sensor breakout, populated board](fz-sensor.png)++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)++![Architecture: cloud AI to AD relay to bridge to add-in to Fusion](screenshots/architecture.svg)++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.++![A licensing decision dialog caught during first launch](screenshots/dialog-active-sessions.png)++- 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" }\ No newline at end of file
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"   } }\ No newline at end of file
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) ```