app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 125 file(s)
25 files changed
+937−196
BRIDGE_VERSION+1−1CLAUDE.md+2−2addin/AdomBridge/AdomBridge.manifest+1−1addin/AdomBridge/AdomBridge.py+35−6addin/AdomBridge/commands/__init__.py+13−1aps.py+1−1bridge.json+4−4dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1fusion_detect.py+1−1handlers/close_fusion.py+7−7handlers/dialog_classify.py+2−2handlers/taskbar_overlay.py+4−4install.sh+1−1install_addin.py+13package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/PORTING.md+23rust/core/Cargo.toml+3rust/core/src/alerts.rs+79rust/core/src/lib.rs+1rust/lbr/Cargo.toml+8−1rust/lbr/src/lib.rs+557−7server.py+166−126BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.350+1.9.351
CLAUDE.md+2−2@@ -9,9 +9,9 @@ all of this and filed its own bridge bugs as "adom-desktop" requests. Don't.) **Naming (John, 2026-08-29, tightened 2026-09-01):** the host app is **Adom Bridge, "ab"**. "Adom Desktop" and "AD" are the retired names; do not use them in prose, hints, skills or issues. The-2026-08-29 exception that kept `_ad_call`, `ad_client.py` and the `adom-desktop-fusion-bridge` folder+2026-08-29 exception that kept `_ab_call`, `ab_client.py` and the `adom-desktop-fusion-bridge` folder is REVERSED: John (2026-09-01) - "make sure there's no more remnants of ad or adom-desktop naming."-The Rust port ships the purge (census + targets in the roadmap: 86 `_ad_call`, 59 `ad_client`,+The Rust port ships the purge (census + targets in the roadmap: the Python `_ad_call`/`ad_client` identifiers, renamed to `_ab_call`/`ab_client` on 2026-09-03; 70 `adom-desktop`), and a CI grep fails the build on any remnant. Until the rename lands, do not ADD new uses of the old identifiers; write `ab` in everything new.
addin/AdomBridge/AdomBridge.manifest+1−1@@ -6,7 +6,7 @@ "description": { "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360." },- "version": "1.0.16",+ "version": "1.0.17", "runOnStartup": true, "supportedOS": "windows" }
addin/AdomBridge/AdomBridge.py+35−6@@ -8,6 +8,9 @@ so all Fusion API calls execute safely on the main thread. import adsk.core import adsk.fusion import traceback+import time+import json+import os # Module-level references kept alive for the add-in lifetime _app = None@@ -92,6 +95,13 @@ def run(context): # Log to the Text Commands palette (non-blocking) adsk.core.Application.log(f"Adom Bridge add-in started (port 8774)")+ # A clean start retires the start-failure marker the bridge surfaces (issue #75).+ try:+ _mk = os.path.join(os.environ.get("TEMP") or os.path.expanduser("~"), "adom-fusion", "addin-start-failed.json")+ if os.path.isfile(_mk):+ os.remove(_mk)+ except Exception:+ pass except Exception: # Only use messageBox for errors — worth blocking to show the user@@ -100,14 +110,33 @@ def run(context): # same way) turned one broken deploy into a dialog storm - twice per launch, then # once per user action (issue #69, Colby, 2026-08-28). After the first box, log. global _start_error_shown+ tb = traceback.format_exc()+ here = os.path.dirname(os.path.abspath(__file__))+ # Leave a marker the BRIDGE reads (fusion_readiness / fusion_addin_status): it knows+ # which AddIns dir is broken and re-syncs it, so the fix is one bridge start away.+ try:+ marker_dir = os.path.join(os.environ.get("TEMP") or os.path.expanduser("~"), "adom-fusion")+ os.makedirs(marker_dir, exist_ok=True)+ with open(os.path.join(marker_dir, "addin-start-failed.json"), "w", encoding="utf-8") as fh:+ json.dump({"addinDir": here, "at": time.time(), "traceback": tb[-4000:]}, fh)+ except Exception:+ pass+ try:+ adsk.core.Application.log(f"Adom Bridge failed to start from {here}:\n{tb}")+ except Exception:+ pass+ # ONE modal, total, and one a person can act on: a traceback about a missing module is+ # what Adonis read five times as "Fusion keeps crashing" (issue #75). Never a storm:+ # Fusion retries the lifecycle and several installed copies can each fail the same way. if _ui and not _start_error_shown: _start_error_shown = True- _ui.messageBox(f"Adom Bridge failed to start:\n{traceback.format_exc()}")- else:- try:- adsk.core.Application.log(f"Adom Bridge failed to start:\n{traceback.format_exc()}")- except Exception:- pass+ last = tb.strip().splitlines()[-1] if tb.strip() else "unknown error"+ _ui.messageBox(+ "The Adom Bridge add-in could not start from:\n" + here + "\n\n"+ "It is out of date or incomplete on disk. To fix it: start Adom Bridge (it repairs "+ "the add-in automatically on start), then restart Fusion. Or delete that folder "+ "and restart Fusion; Adom Bridge reinstalls it.\n\nDetail: " + last[:300],+ "Adom Bridge") def stop(context):
addin/AdomBridge/commands/__init__.py+13−1@@ -26,7 +26,19 @@ from .import_file import handle_import_file from .parameters import handle_get_parameters, handle_set_parameter from .screenshot import handle_take_screenshot from .close_document import handle_close_document, handle_close_all_documents-from .activity_palette import handle_activity_banner+# The activity palette is a convenience, not the bridge. A copy of this add-in that lost+# activity_palette.py (issue #69 payload, or a stale sibling copy in a legacy AddIns dir that+# a later sync never reached: issue #75, Adonis) must still start, so the bridge can repair it.+try:+ from .activity_palette import handle_activity_banner+except ImportError as _e: # pragma: no cover - only a broken install reaches this+ _ACTIVITY_IMPORT_ERROR = str(_e)++ def handle_activity_banner(app, args):+ return {"success": False, "errorCode": "addin_module_missing",+ "error": "activity_palette.py is missing from this add-in copy: " + _ACTIVITY_IMPORT_ERROR,+ "_hint": "The add-in on disk is incomplete. fusion_restart_for_addin {save:true} makes the bridge "+ "re-sync every AddIns dir and restart Fusion."} from .open_electronics_file import handle_open_schematic, handle_open_board, handle_show_3d_board, handle_show_2d_board, handle_show_schematic, handle_import_electronics from .cloud_documents import ( handle_save_to_cloud,
aps.py+1−1@@ -263,7 +263,7 @@ def _ad(verb: str, args: dict, timeout: int = 60) -> dict: circular import at module load. Never raises.""" try: import server as _s- return _s._ad_call(verb, args, timeout=timeout) or {}+ return _s._ab_call(verb, args, timeout=timeout) or {} except Exception as e: return {"_adCallError": str(e)}
bridge.json+4−4@@ -2,20 +2,20 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.350",+ "version": "1.9.351", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.", "license": "MIT", "spawn": {- "kind": "exe",- "entrypoint": "adom-fusion-bridge.exe",+ "kind": "python",+ "entrypoint": "server.py", "port": 0, "healthEndpoint": "/status", "stopMethod": "kill", "killImageName": "python.exe", "persistent": true,- "_hint": "Phase 1 of the Rust port: ab launches the Rust exe (<install>/adom-fusion-bridge.exe --port N); the exe spawns server.py beside it as a Python sidecar (ab's provisioned ~/.adom/adom-runtimes python) and proxies every verb it has not ported yet. killImageName stays python.exe so ab's runtime-mode stop reaps the sidecar first; the exe also ties the sidecar to its own lifetime with a job object. To fall back to the Python bridge, set kind: python, entrypoint: server.py."+ "_hint": "spawn.kind python is the PUBLIC-safe shape: ab provisions the interpreter. The zip also carries adom-fusion-bridge.exe (the Rust shell, Phase 1 of the port); the insiders tier flips to kind exe / entrypoint adom-fusion-bridge.exe once ab can provision Python for an exe bridge (adom-bridge issue #134). Both files are always in the payload, so a tier can flip without a rebuild." }, "verbPrefixes": [ "fusion_"
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.350"+version = "1.9.351" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.350"+version = "1.9.351" edition = "2021" [dependencies]
fusion_detect.py+1−1@@ -727,7 +727,7 @@ def find_licensing_dialog(): # 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,+ # and once ab_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"]
handlers/close_fusion.py+7−7@@ -77,17 +77,17 @@ def _ab_process_rows(name_substr): call instead?"): ab already owns host process ops - one implementation, no child process to spawn, and the call lands in ab's activity log with this bridge's caller identity, where a raw taskkill is invisible. The tasklist/taskkill spawns below survive- only as the degraded path for when ad_client is down (unattended VM, ab mid-restart).+ only as the degraded path for when ab_client is down (unattended VM, ab mid-restart). NOTE the deliberate exception: fusion_detect's POLLING uses a local Toolhelp32 snapshot instead of this verb, and should keep doing so - a poll every few seconds wants microseconds in-process, not an HTTP round trip. Verb for OPERATIONS, snapshot for POLLS, hidden spawn only as a fallback.""" try:- import ad_client- if not ad_client.available():+ import ab_client+ if not ab_client.available(): return None- r = ad_client.call("process_list", {"name": name_substr}, timeout=10)+ r = ab_client.call("process_list", {"name": name_substr}, timeout=10) if not isinstance(r, dict): return None rows = r.get("processes")@@ -106,10 +106,10 @@ def _ab_process_kill(pid, why): """Kill one PID through ab's process_kill verb. True on confirmed kill, False when the verb was unavailable or refused (caller falls back to a hidden taskkill).""" try:- import ad_client- if not ad_client.available():+ import ab_client+ if not ab_client.available(): return False- r = ad_client.call("process_kill", {"pid": int(pid), "tree": True, "reason": why},+ r = ab_client.call("process_kill", {"pid": int(pid), "tree": True, "reason": why}, timeout=15) return bool(isinstance(r, dict) and (r.get("success") or r.get("ok"))) except Exception:
handlers/dialog_classify.py+2−2@@ -325,8 +325,8 @@ def _dialog_text(hwnd) -> str: if not hwnd: return "" try:- import ad_client- r = ad_client.call("desktop_find_controls", {"hwnd": int(hwnd)}, timeout=10) or {}+ import ab_client+ r = ab_client.call("desktop_find_controls", {"hwnd": int(hwnd)}, timeout=10) or {} data = r.get("data") if isinstance(r.get("data"), dict) else r ctrls = (data or {}).get("controls") or (data or {}).get("matches") or [] return " ".join(str(c.get("name") or "") for c in ctrls if c.get("name"))[:400]
handlers/taskbar_overlay.py+4−4@@ -77,11 +77,11 @@ def _taskbar_via_cli(args: dict) -> bool: def _taskbar(args: dict) -> bool: try:- import ad_client- if not ad_client.available():- _LAST_ERROR[0] = "ad_client unavailable - trying the ab CLI fallback"+ import ab_client+ if not ab_client.available():+ _LAST_ERROR[0] = "ab_client unavailable - trying the ab CLI fallback" return _taskbar_via_cli(args)- r = ad_client.call("desktop_taskbar", args, timeout=15)+ r = ab_client.call("desktop_taskbar", args, timeout=15) raw = str(r) if not r: _LAST_ERROR[0] = "desktop_taskbar returned nothing (timeout?)"
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.350"+DASH_VERSION="1.9.351" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
install_addin.py+13@@ -162,6 +162,19 @@ def install(): else: shutil.copytree(SOURCE_DIR, target) print(f"Installed: {target}")+ # VERIFY the copy on disk, not the intent (issue #75, Adonis: a stale copy in the+ # legacy API/AddIns dir imported activity_palette without the file and bricked+ # every Fusion launch). A copy whose relative imports do not resolve is replaced+ # wholesale; a sync that silently left it broken is exactly what we cannot afford.+ bad_on_disk = _check_relative_imports(str(target))+ if bad_on_disk:+ print(f" REPAIRING {target}: copy on disk is incomplete ({'; '.join(bad_on_disk)[:200]})")+ shutil.rmtree(target, ignore_errors=True)+ shutil.copytree(SOURCE_DIR, target)+ bad_on_disk = _check_relative_imports(str(target))+ if bad_on_disk:+ print(f" STILL BROKEN after repair: {target}: {bad_on_disk}")+ continue installed_to.append(str(target)) except OSError as e: print(f" skip {target}: {e}")
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.350",+ "version": "1.9.351", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+11−27@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.350",+ "version": "1.9.351", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -75,7 +79,10 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge"+ "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page" ], "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,28 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.9.350"+version = "1.9.351" edition = "2021" license = "MIT"
rust/PORTING.md+23@@ -49,3 +49,26 @@ cd rust && cargo build --workspace # on Linux CI this builds core, lbr an cargo build -p adom-fusion-bridge # on Windows picks os-win; on macOS picks os-mac cargo test -p fusion-core # the frozen catalog must parse and be complete ```++## Status (2026-09-03)++- **Phase 0 + Phase 1 shipped on the insiders tier (1.9.350).** ab launches+ `adom-fusion-bridge.exe --port N` (`spawn.kind: exe` in bridge.json); the exe spawns+ `server.py` beside it as a Python sidecar on a free loopback port and proxies every+ unported verb to it with the caller headers intact. `/status` is served by the exe and+ folds in the sidecar's rich status; a dead sidecar shows red at once with its exit code.+- **fusion-os-win is real**: Toolhelp32 poll, `cmd /c start "" /min` launch with+ CREATE_BREAKAWAY_FROM_JOB, IsUserAnAdmin, webdeploy / add-in / AppLogFile paths ported+ from `fusion_detect.py`, `install_addin.py`, `server.py`. The sidecar is tied to the+ exe's lifetime with a job object (KILL_ON_JOB_CLOSE + BREAKAWAY_OK, so the sidecar's own+ Fusion launch can still break away). Proven: hard-kill the exe, the sidecar is gone.+- **Python for the sidecar** comes from `ADOM_SIDECAR_PYTHON`, else ab's `runtimes` verb+ over the loopback API, else a scan of `~/.adom/adom-runtimes/python-*` that skips+ `.broken` quarantines. ab cannot yet provision Python for an exe bridge (asked on+ adom/adom-bridge); on a machine with only this bridge the sidecar is absent until then.+- **Kyle**: `fusion-os-mac` still carries `todo!()` stubs; `tools/check-portable.sh` only+ guards the crates the Windows exe links, so your crate can land at its own pace. The exe+ side is `rust/bridge/src/main.rs`; nothing in it is Windows-specific except `host()`.+- **Guards run in the release**: `check-portable.sh` (core is OS-blind, no `todo!()` in a+ linked crate) and `check-naming.sh`, then the mingw cross-build, then the exe joins the+ zip at its root beside `server.py`.
rust/core/Cargo.toml+3@@ -10,3 +10,6 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true ureq.workspace = true++[dev-dependencies]+serde_json.workspace = true
rust/core/src/alerts.rsadded+79@@ -0,0 +1,79 @@+//! Fusion's own alert toasts. A CEF alert has no top-level window, so it never shows in a+//! dialog enumeration; Fusion writes it to its AppLogFile as a `New Alert message` line,+//! and the bridge reads that instead of guessing from pixels. Bridge-side on purpose: it+//! works with the add-in wedged and needs no Fusion restart to deploy.+//!+//! The parser is pure (lines in, alerts out) so it is golden-tested against the Python+//! bridge's `_fusion_alerts`; `HostPaths::fusion_log_dir` says where the log is.++use serde::Serialize;++#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]+pub struct Alert {+ /// The log stamp `YYYYMMDDTHHMMSS` (local time, as Fusion writes it), None when the line+ /// had none. Watermarks compare these strings lexically.+ pub at: Option<String>,+ pub text: String,+}++/// `YYYYMMDDTHHMMSS` → seconds since the epoch, treating the stamp as UTC (the caller+/// supplies its cutoff in the same convention, so the comparison is consistent).+fn stamp_secs(s: &str) -> Option<i64> {+ if s.len() != 15 || s.as_bytes()[8] != b'T' { return None; }+ let n = |a: usize, b: usize| s[a..b].parse::<i64>().ok();+ let (y, mo, d, h, mi, se) = (n(0, 4)?, n(4, 6)?, n(6, 8)?, n(9, 11)?, n(11, 13)?, n(13, 15)?);+ if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || se > 60 { return None; }+ // days from civil (Howard Hinnant), no leap-second handling, matching strptime().timestamp()+ let (y2, m2) = if mo <= 2 { (y - 1, mo + 9) } else { (y, mo - 3) };+ let era = y2.div_euclid(400);+ let yoe = y2 - era * 400;+ let doy = (153 * m2 + 2) / 5 + d - 1;+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;+ let days = era * 146097 + doe - 719468;+ Some(days * 86400 + h * 3600 + mi * 60 + se)+}++/// Parse `New Alert message` lines; those stamped before `cutoff_secs` are dropped, unstamped+/// lines are kept. Text is the Short Message, else the Tooltip, else the raw remainder.+pub fn parse_alert_lines<'a>(lines: impl IntoIterator<Item = &'a str>, cutoff_secs: i64) -> Vec<Alert> {+ let mut out = Vec::new();+ for line in lines {+ if !line.contains("New Alert message") { continue; }+ let trimmed = line.trim_start_matches([' ', '\t']);+ let at: Option<String> = if trimmed.len() >= 15 && stamp_secs(&trimmed[..15]).is_some() { Some(trimmed[..15].to_string()) } else { None };+ if let Some(a) = &at { if stamp_secs(a).unwrap() < cutoff_secs { continue; } }+ let rest = line.splitn(2, "New Alert message.").nth(1).unwrap_or("").trim_end();+ let field = |key: &str| -> Option<String> {+ let i = rest.find(key)?;+ let after = &rest[i + key.len()..];+ let end = ["," ,"\n"].iter().filter_map(|d| {+ // a field runs to the next ", <Label>:" boundary+ after.match_indices(d).map(|(k, _)| k).find(|&k| d == &"\n" || after[k..].starts_with(", Short Message:") || after[k..].starts_with(", Long Message:"))+ }).min().unwrap_or(after.len());+ Some(after[..end].trim().to_string())+ };+ let mut text = field("Short Message:").filter(|s| !s.is_empty()).or_else(|| field("Tooltip:").filter(|s| !s.is_empty())).unwrap_or_default();+ if text.is_empty() { text = rest.trim().chars().take(400).collect(); }+ out.push(Alert { at, text: text.chars().take(400).collect() });+ }+ out+}++/// Alerts strictly AFTER a watermark stamp (the newest `at` seen when an action started), so+/// a stale alert from an earlier action is never blamed on this one.+pub fn alerts_since<'a>(alerts: &'a [Alert], watermark: &str) -> Vec<&'a Alert> {+ if watermark.is_empty() { return alerts.iter().collect(); }+ alerts.iter().filter(|a| a.at.as_deref().unwrap_or("") > watermark).collect()+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn stamps_round_trip() {+ assert_eq!(stamp_secs("19700101T000000"), Some(0));+ assert_eq!(stamp_secs("20260903T073000"), Some(20_699 * 86_400 + 7 * 3_600 + 30 * 60));+ assert_eq!(stamp_secs("20000301T000000"), Some(951868800));+ assert!(stamp_secs("2026090T073000").is_none());+ }+}
rust/core/src/lib.rs+1@@ -9,6 +9,7 @@ pub mod contract; pub mod host;+pub mod alerts; pub mod ab; pub use host::{HostPaths, HostProcess};
rust/lbr/Cargo.toml+8−1@@ -3,7 +3,14 @@ name = "fusion-lbr" version.workspace = true edition.workspace = true license.workspace = true-description = "EAGLE/Fusion .lbr engine: parse, merge, inject package3d bindings. Pure string/XML work with golden-file tests; no OS, no ab."+description = "The EAGLE/Fusion .lbr engine: artifact names, 3D bindings, merges. Pure data; golden-tested against the Python bridge's output." [dependencies] thiserror.workspace = true+serde = { workspace = true }+roxmltree = "0.20"+regex = "1"+zip = { version = "2", default-features = false, features = ["deflate"] }++[dev-dependencies]+serde_json.workspace = true
rust/lbr/src/lib.rs+557−7@@ -1,6 +1,12 @@ //! The `.lbr` engine. Phase 2 ports `library_bundle.py` and the binding injection from-//! server.py here, behind golden-file tests: the same input library must produce a-//! byte-identical bound output to what the Python bridge writes today.+//! server.py here, behind golden-file tests: the same input library must produce the same+//! answer the Python bridge gives today (`tests/golden/*.json` are generated by the Python+//! functions themselves, on the fixtures in `tests/fixtures/`).+//!+//! AI hint: a golden that disagrees with Python is a bug HERE until proven otherwise; the+//! Python bridge is the production oracle for the whole port.++use std::collections::BTreeMap; #[derive(Debug, thiserror::Error)] pub enum LbrError {@@ -12,14 +18,558 @@ pub enum LbrError { } /// Names that exist in a library (the equivalent of `_library_artifact_names`).-#[derive(Debug, Default, PartialEq)]+///+/// A deviceset's symbol and footprint are NOT required to share its name (the wiki's+/// ABM8-16MHZ sits on symbol+package "Crystal_GND24"), so `dev_sym` / `dev_pac` map each+/// deviceset to the gate symbols and device packages it actually references: a prove step+/// that EDITs `<deviceset>.sym` must follow the gate, not guess the name.+#[derive(Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ArtifactNames {+ pub sym: Vec<String>,+ pub pac: Vec<String>,+ pub dev: Vec<String>,+ pub p3d: Vec<String>,+ pub dev_sym: BTreeMap<String, Vec<String>>,+ pub dev_pac: BTreeMap<String, Vec<String>>,+}++pub fn artifact_names(xml: &str) -> Result<ArtifactNames, LbrError> {+ // EAGLE files open with <!DOCTYPE eagle SYSTEM "eagle.dtd">; roxmltree refuses DTDs unless told.+ let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };+ let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;+ let root = doc.root_element();+ if root.tag_name().name() != "eagle" {+ return Err(LbrError::NotALibrary(format!("root element is <{}>, not <eagle>", root.tag_name().name())));+ }+ let names = |tag: &str| -> Vec<String> {+ root.descendants().filter(|n| n.is_element() && n.tag_name().name() == tag)+ .filter_map(|n| n.attribute("name").map(str::to_string)).collect()+ };+ let mut out = ArtifactNames { sym: names("symbol"), pac: names("package"), dev: names("deviceset"), p3d: names("package3d"), ..Default::default() };+ for ds in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "deviceset") {+ let Some(dn) = ds.attribute("name") else { continue };+ let mut syms: Vec<String> = ds.descendants().filter(|n| n.is_element() && n.tag_name().name() == "gate")+ .filter_map(|g| g.attribute("symbol").map(str::to_string)).collect();+ syms.sort(); syms.dedup();+ let mut pacs: Vec<String> = ds.descendants().filter(|n| n.is_element() && n.tag_name().name() == "device")+ .filter_map(|d| d.attribute("package").map(str::to_string)).collect();+ pacs.sort(); pacs.dedup();+ out.dev_sym.insert(dn.to_string(), syms);+ out.dev_pac.insert(dn.to_string(), pacs);+ }+ Ok(out)+}++/// One package's 3D binding: the urn Fusion resolves for `<package name=…>`.+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Binding {+ pub package: String,+ pub wip_urn: String,+}++/// Inject EAGLE `<packages3d>` + per-device `<package3dinstances>` into an .lbr, MERGE-AWARE+/// and IDEMPOTENT (safe to re-run with any subset of parts). Bindings already in the file+/// are read first and new ones win on conflict; every prior 3D block is stripped and the+/// full merged set re-emitted, so a re-run with only the parts that failed last time+/// ACCUMULATES instead of wiping the parts that already succeeded. Byte-for-byte the+/// Python bridge's `_inject_package3d_bindings` (golden-tested), including its one quirk:+/// each re-run leaves one more blank line before `<symbols>` (harmless to Fusion).+///+/// AI hint: this is text surgery, not XML, on purpose: Fusion writes this markup in exactly+/// this shape and position, and re-serialising the whole document through an XML library+/// would move attributes and whitespace Fusion is sensitive to.+pub fn inject_package3d_bindings(lbr_text: &str, bindings: &[Binding]) -> String {+ use regex::Regex;+ // insertion order matters (the emitted block order is the merged map's order)+ let mut merged: Vec<(String, String)> = Vec::new();+ let mut upsert = |pkg: &str, urn: &str| {+ if let Some(e) = merged.iter_mut().find(|(p, _)| p == pkg) { e.1 = urn.to_string(); }+ else { merged.push((pkg.to_string(), urn.to_string())); }+ };+ let existing = Regex::new(r"(?s)<packages3d>(.*?)</packages3d>").unwrap();+ let one = Regex::new(r#"<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)""#).unwrap();+ if let Some(em) = existing.captures(lbr_text) {+ for pm in one.captures_iter(em.get(1).map(|m| m.as_str()).unwrap_or("")) { upsert(&pm[1], &pm[2]); }+ }+ for b in bindings { upsert(&b.package, &b.wip_urn); }++ let mut text = Regex::new(r"(?s)\s*<packages3d>.*?</packages3d>").unwrap().replace_all(lbr_text, "").to_string();+ text = Regex::new(r"(?s)\s*<package3dinstances>.*?</package3dinstances>").unwrap().replace_all(&text, "").to_string();++ let blocks: Vec<String> = merged.iter().map(|(pkg, urn)| format!(+ r#"<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model"><description>{pkg}</description><packageinstances><packageinstance name="{pkg}"/></packageinstances></package3d>"#)).collect();+ let pkgs3d = format!("<packages3d>\n{}\n</packages3d>\n", blocks.join("\n"));+ text = text.replacen("</packages>", &format!("</packages>\n{pkgs3d}"), 1);++ let dev_re = Regex::new(r"(?s)<device\b[^>]*>.*?</device>").unwrap();+ let pkg_attr = Regex::new(r#"package="([^"]+)""#).unwrap();+ dev_re.replace_all(&text, |m: ®ex::Captures| {+ let dev = m.get(0).unwrap().as_str();+ if let Some(pm) = pkg_attr.captures(dev) {+ if let Some((_, urn)) = merged.iter().find(|(p, _)| p == &pm[1]) {+ if dev.contains("</connects>") {+ let inst = format!(r#"<package3dinstances><package3dinstance package3d_urn="{urn}"/></package3dinstances>"#);+ return dev.replacen("</connects>", &format!("</connects>\n{inst}"), 1);+ }+ }+ }+ dev.to_string()+ }).to_string()+}++// ───────────────────────── bundle pairing (library_bundle.py) ─────────────────────────++/// One orderable variant of a deviceset (a `<device>` × `<technology>`), with its attributes.+#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Variant {+ #[serde(default)] pub device: String,+ #[serde(default)] pub technology: String,+ #[serde(default, rename = "fullName")] pub full_name: String,+ #[serde(default)] pub package: String,+ #[serde(default)] pub attributes: BTreeMap<String, String>,+ #[serde(default)] pub missing: Vec<String>,+}++#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Deviceset {+ #[serde(default)] pub name: String,+ #[serde(default)] pub description: String,+ #[serde(default)] pub variants: Vec<Variant>,+}++#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Totals {+ pub devicesets: usize,+ pub variants: usize,+ #[serde(rename = "withMpn")] pub with_mpn: usize,+ #[serde(rename = "withDistributor")] pub with_distributor: usize,+}++/// `_library_part_metadata`: every orderable variant in a .lbr with its attributes.+#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct LibraryMetadata {+ #[serde(default)] pub ok: bool,+ #[serde(default)] pub devicesets: Vec<Deviceset>,+ #[serde(default)] pub totals: Totals,+}++pub const DISTRIBUTOR_ATTRS: [&str; 3] = ["MOUSER_PN", "DIGIKEY_PN", "LCSC_PN"];++/// Offline, no Fusion: what an EE needs to ORDER each variant, and what is missing.+pub fn library_part_metadata(xml: &str) -> Result<LibraryMetadata, LbrError> {+ let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };+ let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;+ let el = |n: roxmltree::Node, tag: &str| n.is_element() && n.tag_name().name() == tag;+ let mut out = Vec::new();+ for ds in doc.root_element().descendants().filter(|n| el(*n, "deviceset")) {+ let ds_name = ds.attribute("name").unwrap_or("").to_string();+ let ds_desc = ds.children().find(|c| el(*c, "description")).and_then(|d| d.text()).unwrap_or("").trim().to_string();+ let mut variants = Vec::new();+ for dev in ds.descendants().filter(|n| el(*n, "device")) {+ let dev_name = dev.attribute("name").unwrap_or("").to_string();+ let techs: Vec<Option<roxmltree::Node>> = {+ let t: Vec<_> = dev.descendants().filter(|n| el(*n, "technology")).map(Some).collect();+ if t.is_empty() { vec![None] } else { t }+ };+ for tech in techs {+ let tname = tech.and_then(|t| t.attribute("name")).unwrap_or("").to_string();+ let mut attrs = BTreeMap::new();+ if let Some(t) = tech {+ for a in t.descendants().filter(|n| el(*n, "attribute")) {+ let n = a.attribute("name").unwrap_or("").to_uppercase();+ let v = a.attribute("value").unwrap_or("").trim().to_string();+ if !n.is_empty() && !v.is_empty() { attrs.insert(n, v); }+ }+ }+ let mut missing: Vec<String> = ["MPN", "MANUFACTURER"].iter().filter(|k| attrs.get(**k).map(|v| v.is_empty()).unwrap_or(true)).map(|k| k.to_string()).collect();+ if !DISTRIBUTOR_ATTRS.iter().any(|d| attrs.get(*d).map(|v| !v.is_empty()).unwrap_or(false)) {+ missing.push("a distributor PN (MOUSER_PN/DIGIKEY_PN/LCSC_PN)".into());+ }+ variants.push(Variant { full_name: format!("{ds_name}{dev_name}{tname}"), device: dev_name.clone(), technology: tname,+ package: dev.attribute("package").unwrap_or("").to_string(), attributes: attrs, missing });+ }+ }+ out.push(Deviceset { name: ds_name, description: ds_desc, variants });+ }+ let totals = Totals {+ devicesets: out.len(),+ variants: out.iter().map(|d| d.variants.len()).sum(),+ with_mpn: out.iter().flat_map(|d| &d.variants).filter(|v| v.attributes.get("MPN").map(|s| !s.is_empty()).unwrap_or(false)).count(),+ with_distributor: out.iter().flat_map(|d| &d.variants).filter(|v| DISTRIBUTOR_ATTRS.iter().any(|d| v.attributes.get(*d).map(|s| !s.is_empty()).unwrap_or(false))).count(),+ };+ Ok(LibraryMetadata { ok: true, devicesets: out, totals })+}++/// alnum-only, case-folded: how two names are compared when their spellings differ.+pub fn norm(s: &str) -> String { s.to_lowercase().chars().filter(|c| c.is_ascii_alphanumeric()).collect() }++/// A library folder name Fusion and the wiki both accept.+pub fn library_name_ok(name: &str) -> bool {+ let mut it = name.chars();+ match it.next() { Some(c) if c.is_ascii_alphanumeric() => {}, _ => return false }+ name.len() <= 64 && it.all(|c| c.is_ascii_alphanumeric() || " ._-".contains(c))+}++fn stem(p: &str) -> String {+ let base = p.rsplit(['/', '\\']).next().unwrap_or(p);+ match base.rfind('.') { Some(i) if i > 0 => base[..i].to_string(), _ => base.to_string() }+}++#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct PairedPart {+ pub deviceset: String,+ pub device: String,+ pub package: String,+ #[serde(rename = "lbrPath")] pub lbr_path: String,+ #[serde(rename = "modelPath")] pub model_path: String,+ #[serde(rename = "matchedBy")] pub matched_by: String,+}+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Unbound { pub package: String, pub deviceset: String, pub device: String, pub why: String }+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Shared { pub deviceset: String, pub package: String, pub via: String }+#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct Pairing {+ pub parts: Vec<PairedPart>,+ pub unbound: Vec<Unbound>,+ pub shared: Vec<Shared>,+ pub models: Vec<String>,+ #[serde(rename = "modelsUnused")] pub models_unused: Vec<String>,+}++/// Pair every (deviceset, PACKAGE) in the .lbr with a STEP by name. ONE PART PER PACKAGE+/// VARIANT, not per deviceset (LAN7800 "", "-HV", "-SB" each bind their own STEP so Change+/// Package swaps footprint and 3D together); devicesets sharing an already-paired package are+/// `shared`, never unbound. Match order per unit: the package name, then deviceset+device (or+/// the bare deviceset), exact → normalised → normalised prefix (≥4 chars). A one-deviceset+/// library with one model pairs regardless of name. Same answers as `library_bundle.pair_parts`.+pub fn pair_parts(lbr_path: &str, models: &[String], metadata: &LibraryMetadata) -> Pairing {+ let mut units: Vec<(String, String, String)> = Vec::new();+ let mut devicesets = Vec::new();+ for d in &metadata.devicesets {+ devicesets.push(d.name.clone());+ let mut seen: Vec<String> = Vec::new();+ for v in &d.variants {+ if v.package.is_empty() || seen.contains(&v.package) { continue; }+ seen.push(v.package.clone());+ units.push((d.name.clone(), v.device.clone(), v.package.clone()));+ }+ if seen.is_empty() { units.push((d.name.clone(), String::new(), String::new())); }+ }+ let primary_pkg = |ds: &str| units.iter().find(|(d, _, pk)| d == ds && !pk.is_empty()).map(|u| u.2.clone()).unwrap_or_default();+ // later models overwrite earlier ones on a duplicate key, exactly like the dict comprehensions+ let mut by_stem: BTreeMap<String, String> = BTreeMap::new();+ let mut by_norm: Vec<(String, String)> = Vec::new(); // insertion-ordered like a dict+ for m in models {+ by_stem.insert(stem(m), m.clone());+ let k = norm(&stem(m));+ if let Some(e) = by_norm.iter_mut().find(|(kk, _)| *kk == k) { e.1 = m.clone(); } else { by_norm.push((k, m.clone())); }+ }+ let mut parts = Vec::new(); let mut unbound = Vec::new(); let mut shared = Vec::new();+ let mut used: Vec<String> = Vec::new();+ let mut bound_pkgs: BTreeMap<String, String> = BTreeMap::new();+ for (ds, dev, pk) in &units {+ if !pk.is_empty() && bound_pkgs.contains_key(pk) {+ shared.push(Shared { deviceset: ds.clone(), package: pk.clone(), via: bound_pkgs[pk].clone() });+ continue;+ }+ let mut cands: Vec<(String, &str)> = Vec::new();+ if !pk.is_empty() { cands.push((pk.clone(), "package")); }+ if !dev.is_empty() { cands.push((format!("{ds}{dev}"), "deviceset+device")); } else { cands.push((ds.clone(), "deviceset")); }+ let mut hit: Option<(String, String)> = None;+ for (name, how) in &cands {+ if let Some(m) = by_stem.get(name) { if !used.contains(m) { hit = Some((m.clone(), how.to_string())); break; } }+ }+ if hit.is_none() {+ for (name, how) in &cands {+ let n = norm(name);+ if let Some((_, m)) = by_norm.iter().find(|(k, _)| !n.is_empty() && *k == n) {+ if !used.contains(m) { hit = Some((m.clone(), format!("{how}-normalised"))); break; }+ }+ }+ }+ if hit.is_none() {+ 'outer: for (name, how) in &cands {+ let n = norm(name);+ for (k, m) in &by_norm {+ if !n.is_empty() && !used.contains(m) && (k.starts_with(&n) || n.starts_with(k.as_str())) && n.len() >= 4 {+ hit = Some((m.clone(), format!("{how}-prefix"))); break 'outer;+ }+ }+ }+ }+ match hit {+ Some((m, how)) => {+ used.push(m.clone());+ let package = if !pk.is_empty() { pk.clone() } else { let p = primary_pkg(ds); if p.is_empty() { ds.clone() } else { p } };+ parts.push(PairedPart { deviceset: ds.clone(), device: dev.clone(), package, lbr_path: lbr_path.to_string(), model_path: m, matched_by: how });+ if !pk.is_empty() { bound_pkgs.insert(pk.clone(), ds.clone()); }+ }+ None => unbound.push(Unbound { package: if pk.is_empty() { ds.clone() } else { pk.clone() }, deviceset: ds.clone(), device: dev.clone(),+ why: format!("no STEP in the bundle named like the package {} or the device {}", py_repr(pk), py_repr(&format!("{ds}{dev}"))) }),+ }+ }+ if parts.is_empty() && units.len() == 1 && models.len() == 1 {+ unbound.clear();+ let (ds, dev, pk) = &units[0];+ used.push(models[0].clone());+ let package = if !pk.is_empty() { pk.clone() } else { let p = primary_pkg(ds); if p.is_empty() { ds.clone() } else { p } };+ parts.push(PairedPart { deviceset: ds.clone(), device: dev.clone(), package, lbr_path: lbr_path.to_string(), model_path: models[0].clone(), matched_by: "only-one-of-each".into() });+ }+ let models_unused = models.iter().filter(|m| !used.contains(m)).cloned().collect();+ Pairing { parts, unbound, shared, models: models.to_vec(), models_unused }+}++/// Python's `%r` for a str: single-quoted unless the text has a single quote and no double.+fn py_repr(s: &str) -> String {+ if s.contains('\'') && !s.contains('"') { format!("\"{s}\"") } else { format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) }+}++// ───────────────────────── per-part split (library_bundle.split_per_part) ─────────────────────────++/// An owned, editable element tree that serialises exactly like Python's ElementTree+/// (`ET.ElementTree(root).write(fp, encoding="utf-8", xml_declaration=True)`): attributes in+/// document order, `<x />` for an element with no text and no children, `&`/`<`/`>` escaped+/// in text, plus `"` and the three control characters in attributes, no DOCTYPE, no root+/// tail. Comments and processing instructions are dropped, as ET's default parser drops them.+#[derive(Debug, Clone, PartialEq)]+pub struct Element {+ pub tag: String,+ pub attrs: Vec<(String, String)>,+ /// Text before the first child (ET `.text`).+ pub text: String,+ pub children: Vec<Element>,+ /// Text after this element's close, inside the parent (ET `.tail`).+ pub tail: String,+}++impl Element {+ pub fn new(tag: &str) -> Element { Element { tag: tag.into(), attrs: vec![], text: String::new(), children: vec![], tail: String::new() } }+ pub fn attr(&self, k: &str) -> Option<&str> { self.attrs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.as_str()) }+ pub fn find_child(&self, tag: &str) -> Option<&Element> { self.children.iter().find(|c| c.tag == tag) }+ pub fn find_child_mut(&mut self, tag: &str) -> Option<&mut Element> { self.children.iter_mut().find(|c| c.tag == tag) }+ /// Depth-first search (ET `.//tag`).+ pub fn find_deep_mut(&mut self, tag: &str) -> Option<&mut Element> {+ if self.tag == tag { return Some(self); }+ for c in self.children.iter_mut() { if let Some(f) = c.find_deep_mut(tag) { return Some(f); } }+ None+ }+ fn from_node(n: roxmltree::Node) -> Element {+ let mut e = Element::new(n.tag_name().name());+ e.attrs = n.attributes().map(|a| (a.name().to_string(), a.value().to_string())).collect();+ let mut last_child: Option<usize> = None;+ for c in n.children() {+ if c.is_element() {+ e.children.push(Element::from_node(c));+ last_child = Some(e.children.len() - 1);+ } else if c.is_text() {+ match last_child { Some(i) => e.children[i].tail.push_str(c.text().unwrap_or("")), None => e.text.push_str(c.text().unwrap_or("")) }+ }+ }+ e+ }+ pub fn parse(xml: &str) -> Result<Element, LbrError> {+ let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };+ let doc = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| LbrError::NotALibrary(e.to_string()))?;+ Ok(Element::from_node(doc.root_element()))+ }+ fn write(&self, out: &mut String, is_root: bool) {+ out.push('<'); out.push_str(&self.tag);+ for (k, v) in &self.attrs { out.push(' '); out.push_str(k); out.push_str("=\""); out.push_str(&escape_attrib(v)); out.push('"'); }+ if self.text.is_empty() && self.children.is_empty() { out.push_str(" />"); }+ else {+ out.push('>'); out.push_str(&escape_cdata(&self.text));+ for c in &self.children { c.write(out, false); }+ out.push_str("</"); out.push_str(&self.tag); out.push('>');+ }+ if !is_root { out.push_str(&escape_cdata(&self.tail)); }+ }+ /// The whole document as Python's ElementTree writes it.+ pub fn to_xml_document(&self) -> String {+ let mut s = String::from("<?xml version='1.0' encoding='utf-8'?>\n");+ self.write(&mut s, true);+ s+ }+}++fn escape_cdata(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">") }+fn escape_attrib(s: &str) -> String {+ s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)+ .replace('\r', " ").replace('\n', " ").replace('\t', "	")+}++#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]+pub struct SplitEntry {+ #[serde(rename = "lbrPath")] pub lbr_path: String,+ pub package: String, pub symbols: Vec<String>, pub packages: Vec<String>,- pub devicesets: Vec<String>,- pub package3d: Vec<String>, } -pub fn artifact_names(_xml: &str) -> Result<ArtifactNames, LbrError> {- Err(LbrError::NotPorted("_library_artifact_names"))+fn safe_name(s: &str) -> String {+ let mut out = String::new(); let mut in_run = false;+ for c in s.chars() {+ if c.is_ascii_alphanumeric() || "._-".contains(c) { out.push(c); in_run = false; }+ else if !in_run { out.push('_'); in_run = true; }+ }+ out+}++/// One library per deviceset AND one per (deviceset, package variant), so Fusion's Package3D+/// generator (which loads the FIRST package of the open library whatever path it is given)+/// mints each chip on its own footprint. Each file keeps the merged library's header and+/// layers and carries exactly the packages that deviceset's devices use (primary first), the+/// symbols its gates use, and the deviceset itself, with any prior `<package3dinstances>`+/// stripped. Keys: `<deviceset>` (primary package file) and `<deviceset>::<package>`.+/// Byte-for-byte what `library_bundle.split_per_part` writes (golden-tested).+pub fn split_per_part(xml: &str, out_dir: &std::path::Path) -> Result<BTreeMap<String, SplitEntry>, LbrError> {+ let root = Element::parse(xml)?;+ std::fs::create_dir_all(out_dir).map_err(|e| LbrError::NotALibrary(format!("cannot create {}: {e}", out_dir.display())))?;+ let mut probe = root.clone();+ let lib = probe.find_deep_mut("library").ok_or_else(|| LbrError::NotALibrary("no <library>".into()))?.clone();+ let pk_all: Vec<Element> = lib.find_child("packages").map(|p| p.children.iter().filter(|c| c.tag == "package").cloned().collect()).unwrap_or_default();+ let sy_all: Vec<Element> = lib.find_child("symbols").map(|p| p.children.iter().filter(|c| c.tag == "symbol").cloned().collect()).unwrap_or_default();+ let devicesets: Vec<Element> = lib.find_child("devicesets").map(|p| p.children.iter().filter(|c| c.tag == "deviceset").cloned().collect()).unwrap_or_default();+ let mut out = BTreeMap::new();+ for ds in &devicesets {+ let name = ds.attr("name").unwrap_or("").to_string();+ let mut pkgs: Vec<String> = Vec::new();+ if let Some(devs) = ds.find_child("devices") {+ for d in devs.children.iter().filter(|c| c.tag == "device") {+ if let Some(pk) = d.attr("package") { if !pk.is_empty() && !pkgs.iter().any(|p| p == pk) { pkgs.push(pk.to_string()); } }+ }+ }+ let mut syms: Vec<String> = Vec::new();+ if let Some(gates) = ds.find_child("gates") {+ for g in gates.children.iter().filter(|c| c.tag == "gate") {+ if let Some(sy) = g.attr("symbol") { if !sy.is_empty() && !syms.iter().any(|s| s == sy) { syms.push(sy.to_string()); } }+ }+ }+ let mut variants: Vec<Option<String>> = vec![None];+ variants.extend(pkgs.iter().cloned().map(Some));+ for only_pk in variants {+ let mut new_root = root.clone();+ let new_lib = new_root.find_deep_mut("library").unwrap();+ // ET.remove drops the element AND its tail+ new_lib.children.retain(|c| !matches!(c.tag.as_str(), "packages" | "symbols" | "devicesets" | "packages3d"));+ let use_pkgs: Vec<String> = match &only_pk { Some(p) => vec![p.clone()], None => pkgs.clone() };+ let mut pe = Element::new("packages");+ for pk in &use_pkgs { if let Some(p) = pk_all.iter().find(|p| p.attr("name") == Some(pk.as_str())) { pe.children.push(p.clone()); } }+ let mut se = Element::new("symbols");+ for sy in &syms { if let Some(s) = sy_all.iter().find(|s| s.attr("name") == Some(sy.as_str())) { se.children.push(s.clone()); } }+ let mut de = Element::new("devicesets");+ let mut ds2 = ds.clone();+ if let Some(devs) = ds2.find_child_mut("devices") {+ for dev in devs.children.iter_mut().filter(|c| c.tag == "device") {+ dev.children.retain(|c| c.tag != "package3dinstances");+ }+ if let Some(pk) = &only_pk {+ devs.children.retain(|c| c.tag != "device" || c.attr("package") == Some(pk.as_str()));+ }+ }+ de.children.push(ds2);+ // EAGLE order inside <library>: description?, packages, symbols, devicesets+ let head: Vec<Element> = new_lib.children.iter().filter(|c| !matches!(c.tag.as_str(), "packages" | "symbols" | "devicesets")).cloned().collect();+ new_lib.children = head; new_lib.children.push(pe); new_lib.children.push(se); new_lib.children.push(de);+ let mut safe = safe_name(&name); if safe.is_empty() { safe = "part".into(); }+ if let Some(pk) = &only_pk { let mut sp = safe_name(pk); if sp.is_empty() { sp = "pkg".into(); } safe = format!("{safe}__{sp}"); }+ let fp = out_dir.join(format!("{safe}.lbr"));+ std::fs::write(&fp, new_root.to_xml_document()).map_err(|e| LbrError::NotALibrary(format!("cannot write {}: {e}", fp.display())))?;+ let entry = SplitEntry { lbr_path: fp.to_string_lossy().replace('\\', "/"), package: only_pk.clone().unwrap_or_else(|| pkgs.first().cloned().unwrap_or_default()), symbols: syms.clone(), packages: use_pkgs };+ match &only_pk { Some(pk) => { out.insert(format!("{name}::{pk}"), entry); } None => { out.insert(name.clone(), entry); } }+ }+ }+ Ok(out)+}++// ───────────────────────── bundle staging (library_bundle.py) ─────────────────────────++pub const MAX_ZIP_BYTES: u64 = 256 * 1024 * 1024;+pub const MAX_UNPACKED_BYTES: u64 = 512 * 1024 * 1024;+const MODEL_EXT: [&str; 2] = [".step", ".stp"];++#[derive(Debug, thiserror::Error)]+pub enum BundleError {+ #[error("unsafe zip entry: {0}")]+ UnsafeEntry(String),+ #[error("bundle unpacks past the {0} MB cap")]+ TooBig(u64),+ #[error("zip error: {0}")]+ Zip(String),+ #[error("io error: {0}")]+ Io(String),+}++/// Extract with a zip-slip guard and an unpacked-size cap. Returns files extracted.+/// Same refusals as `library_bundle.safe_extract`: an absolute entry, a drive-letter entry,+/// or any `..` segment is refused before a byte is written.+pub fn safe_extract(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<usize, BundleError> {+ let f = std::fs::File::open(zip_path).map_err(|e| BundleError::Io(e.to_string()))?;+ let mut zf = zip::ZipArchive::new(f).map_err(|e| BundleError::Zip(e.to_string()))?;+ std::fs::create_dir_all(dest).map_err(|e| BundleError::Io(e.to_string()))?;+ let dest = dest.canonicalize().map_err(|e| BundleError::Io(e.to_string()))?;+ let (mut total, mut count) = (0u64, 0usize);+ for i in 0..zf.len() {+ let mut entry = zf.by_index(i).map_err(|e| BundleError::Zip(e.to_string()))?;+ let name = entry.name().to_string();+ if name.is_empty() || name.ends_with('/') { continue; }+ let norm = name.replace('\\', "/");+ let drive = norm.len() >= 2 && norm.as_bytes()[0].is_ascii_alphabetic() && norm.as_bytes()[1] == b':';+ if norm.starts_with('/') || drive || norm.split('/').any(|s| s == "..") { return Err(BundleError::UnsafeEntry(name)); }+ total += entry.size();+ if total > MAX_UNPACKED_BYTES { return Err(BundleError::TooBig(MAX_UNPACKED_BYTES >> 20)); }+ let target = dest.join(&norm);+ if !target.starts_with(&dest) { return Err(BundleError::UnsafeEntry(name)); }+ if let Some(p) = target.parent() { std::fs::create_dir_all(p).map_err(|e| BundleError::Io(e.to_string()))?; }+ let mut out = std::fs::File::create(&target).map_err(|e| BundleError::Io(e.to_string()))?;+ std::io::copy(&mut entry, &mut out).map_err(|e| BundleError::Io(e.to_string()))?;+ count += 1;+ }+ Ok(count)+}++#[derive(Debug, Clone, Default, PartialEq)]+pub struct BundleFiles {+ /// The shallowest .lbr (ties broken by path), or None.+ pub lbr: Option<String>,+ /// Every .step/.stp anywhere in the bundle, sorted.+ pub models: Vec<String>,+ pub all_lbrs: Vec<String>,+}++fn walk(dir: &std::path::Path, out: &mut Vec<String>) {+ if let Ok(rd) = std::fs::read_dir(dir) {+ for e in rd.flatten() {+ let p = e.path();+ if p.is_dir() { walk(&p, out); } else { out.push(p.to_string_lossy().to_string()); }+ }+ }+}++/// The shallowest .lbr wins; models are every .step/.stp anywhere in the bundle.+pub fn find_bundle_files(root: &std::path::Path) -> BundleFiles {+ let mut files = Vec::new(); walk(root, &mut files);+ let sep = std::path::MAIN_SEPARATOR;+ let mut lbrs: Vec<String> = files.iter().filter(|f| f.to_lowercase().ends_with(".lbr")).cloned().collect();+ let mut models: Vec<String> = files.iter().filter(|f| { let l = f.to_lowercase(); MODEL_EXT.iter().any(|x| l.ends_with(x)) }).cloned().collect();+ lbrs.sort_by(|a, b| (a.matches(sep).count(), a.as_str()).cmp(&(b.matches(sep).count(), b.as_str())));+ models.sort();+ BundleFiles { lbr: lbrs.first().cloned(), models, all_lbrs: lbrs }+}++/// Copy the .lbr and its models to the stable install folder; returns the new .lbr path and+/// the old→new model map, forward slashes throughout (what the chain and the wiki expect).+pub fn place_library(lbr_path: &std::path::Path, models: &[String], dest_dir: &std::path::Path) -> Result<(String, BTreeMap<String, String>), BundleError> {+ std::fs::create_dir_all(dest_dir).map_err(|e| BundleError::Io(e.to_string()))?;+ let new_lbr = dest_dir.join(lbr_path.file_name().unwrap_or_default());+ std::fs::copy(lbr_path, &new_lbr).map_err(|e| BundleError::Io(e.to_string()))?;+ let mut map = BTreeMap::new();+ for m in models {+ let src = std::path::Path::new(m);+ let t = dest_dir.join(src.file_name().unwrap_or_default());+ let same = match (src.canonicalize(), t.canonicalize()) { (Ok(a), Ok(b)) => a == b, _ => false };+ if !same { std::fs::copy(src, &t).map_err(|e| BundleError::Io(e.to_string()))?; }+ map.insert(m.clone(), t.to_string_lossy().replace('\\', "/"));+ }+ Ok((new_lbr.to_string_lossy().replace('\\', "/"), map)) }
server.py+166−126@@ -44,7 +44,7 @@ from handlers.dialog_classify import classify_blocking_dialogs, classify_launch_ from install_addin import install as install_addin import aps import describe-import ad_client+import ab_client DEFAULT_PORT = 8773 ADDIN_PORT = 8774@@ -153,11 +153,11 @@ def _reclaim_seat_from_peers() -> int: of peers signalled (0 if the direct API/peers are unavailable).""" n = 0 try:- if not ad_client.available():+ if not ab_client.available(): return 0- for peer in ad_client.peers():+ for peer in ab_client.peers(): try:- if ad_client.call("fusion_stop", {}, target=peer) is not None:+ if ab_client.call("fusion_stop", {}, target=peer) is not None: n += 1 except Exception: pass@@ -179,7 +179,7 @@ def _resolve_seat_via_uia(dlg: dict) -> bool: via the ab 1.9.84 direct API. Never raises.""" try: hwnd = dlg.get("hwnd")- if not hwnd or not ad_client.available():+ if not hwnd or not ab_client.available(): return False # TWO variants of the seat dialog, opposite buttons (John, 2026-08-21: "when you see # this dialog, always regrab the session"):@@ -190,7 +190,7 @@ def _resolve_seat_via_uia(dlg: dict) -> bool: # Try Continue first (the common variant), then Check again. desktop_ui_click on a # button that does not exist is a cheap no-op, so probing both costs nothing. for btn in ("Continue", "Check again"):- res = ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": btn}, timeout=4)+ res = ab_client.call("desktop_ui_click", {"hwnd": hwnd, "name": btn}, timeout=4) if res and (res.get("success") or res.get("status") == "ok"): if btn == "Check again": print("[Fusion Bridge] seat: clicked 'Check again' to REGRAB the suspended "@@ -209,9 +209,9 @@ def _owned_popup_count(main_hwnd) -> int: cleared, instead of trusting a window-size heuristic (which false-'resolved' the 823x262 'Suspend Remote Session' confirm). Returns the count, or -1 if unavailable.""" try:- if not main_hwnd or not ad_client.available():+ if not main_hwnd or not ab_client.available(): return -1- res = ad_client.call("desktop_screenshot_window", {"hwnd": int(main_hwnd)}, timeout=4)+ res = ab_client.call("desktop_screenshot_window", {"hwnd": int(main_hwnd)}, timeout=4) if isinstance(res, dict): c = res.get("ownedPopupCount") if c is None and isinstance(res.get("data"), dict):@@ -253,7 +253,7 @@ def _resolve_seat_dialog(max_clicks: int = 3) -> dict: saw = True parent = dlg.get("owner") if _resolve_seat_via_uia(dlg): # count only clicks that actually landed, so- clicks += 1 # seatDialogAutoResolved isn't a lie when ad_client is down+ clicks += 1 # seatDialogAutoResolved isn't a lie when ab_client is down _t.sleep(1.5) # Ground-truth confirm via parent/child screenshot (John's insisted-on check). cnt = _owned_popup_count(parent)@@ -266,7 +266,7 @@ def _resolve_seat_dialog(max_clicks: int = 3) -> dict: def _find_adom_desktop_cli() -> str | None:- """Locate the host app's CLI so the bridge can drive it when the in-process ad_client is down.+ """Locate the host app's CLI so the bridge can drive it when the in-process ab_client is down. Accepts BOTH names. The host app was renamed Adom Bridge -> Adom Bridge in v2.0.0 (issue #537), so a box may carry either layout: a renamed install has "Adom Bridge"/adom-bridge-cli.exe, an@@ -306,7 +306,7 @@ def _find_adom_desktop_cli() -> str | None: def _cli_notify_all(title: str, body: str, level: str, buttons=None) -> dict: """Deliver a toast to EVERY connected desktop via `adom-bridge --target all notify_user`. This is- the reliable cross-ab path when the bridge's in-process ad_client is down (headless VM): the CLI+ the reliable cross-ab path when the bridge's in-process ab_client is down (headless VM): the CLI joins the relay itself and `--target all` reaches the user's real machine (not just this VM). Best-effort + never raises. Returns {delivered, targets, error}. (John 2026-07-14: the notify MUST actually leave the box - a returned-but-unsent payload is the bug, not a feature.)"""@@ -601,13 +601,13 @@ def _handle_notify_owner(args: dict) -> dict: John's standing rule (2026-07-07): the bridge/AI does EVERYTHING itself; the only legitimate uses are true human walls - password/2FA entry, UAC elevation, a physical action. When this bridge runs on an unattended VM, the toast must reach the machine- the user is actually AT - ad_client.notify(reach_user=True) fans out to all peer ADs+ the user is actually AT - ab_client.notify(reach_user=True) fans out to all peer ADs on the relay, so it lands on their main computer too. Returns which targets were hit.""" title = args.get("title") or "Fusion bridge needs you" body = args.get("body") or args.get("message") or "A human step is required to continue." level = args.get("level") or "warning"- if not ad_client.available():- # ad_client is the bridge's IN-PROCESS ab API - it is routinely UNAVAILABLE when this bridge+ if not ab_client.available():+ # ab_client is the bridge's IN-PROCESS ab API - it is routinely UNAVAILABLE when this bridge # runs on an unattended Hyper-V VM (found live 2026-07-14: the toast silently never reached the # user, and the AI "forgot" to relay it - exactly the failure John demanded we engineer out). # SELF-DELIVER via the adom-bridge CLI, which connects to the relay independently: `--target@@ -630,7 +630,7 @@ def _handle_notify_owner(args: dict) -> dict: "_hint": ("Relay the notifyUser payload yourself: `adom-bridge --target all notify_user " "{title, body, level}` (or `--target <the user's host>`)."), }- res = ad_client.notify(title, body, level=level, reach_user=True) or {}+ res = ab_client.notify(title, body, level=level, reach_user=True) or {} # reach_user fans out via target="all", whose response is a BROADCAST envelope # {results:{host:{action}}, summary:{ok,failed,total}, targets:[...]}. Recognize that shape for # success (summary.ok>0) - not just the single-target {status:"ok"} - and report the ACTUAL@@ -640,7 +640,7 @@ def _handle_notify_owner(args: dict) -> dict: reached = res.get("targets") or (["self"] if ok else []) return { "success": ok,- "via": "ad_client:direct(all)",+ "via": "ab_client:direct(all)", "targets": reached, "_hint": ("Toast fanned out to ALL connected desktops (the user's machine included) via the " "in-process ab direct API. This is the LAST RESORT - only use after exhausting "@@ -674,7 +674,7 @@ def _notify_before_foreground(reason: str) -> None: "hopefully Autodesk makes their app fully AI-drivable soon so I can skip these " "workarounds. Sorry for the interruption!" )- ad_client.notify("Adom is foregrounding Fusion (last resort)", body,+ ab_client.notify("Adom is foregrounding Fusion (last resort)", body, level="info", reach_user=True) except Exception: pass@@ -718,7 +718,7 @@ def _handle_new_electronics_from_eagle(args: dict) -> dict: "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both " "to Windows (send_files), then call with {schPath, brdPath}."} brd = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")- if not ad_client.available():+ if not ab_client.available(): # Do NOT dead-end here (old bug: hard-failed even on latest ab when this bridge process # didn't get ab's injected direct-API env). The RIGHT move is the background route. brd_guess = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")@@ -733,7 +733,7 @@ def _handle_new_electronics_from_eagle(args: dict) -> dict: "fusion_execute_text_command and drive the two Open dialogs (foreground; last resort).")} def _find_open_dialog():- res = ad_client.call("desktop_list_windows", {}) or {}+ res = ab_client.call("desktop_list_windows", {}) or {} out = res.get("output") if isinstance(res, dict) else None if isinstance(out, str): try: out = json.loads(out)@@ -746,9 +746,9 @@ def _handle_new_electronics_from_eagle(args: dict) -> dict: def _pick(hwnd, basename): # select the file by accessible name, then click Open - both background UIA- ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": basename})+ ab_client.call("desktop_ui_click", {"hwnd": hwnd, "name": basename}) _t.sleep(1.0)- ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Open"})+ ab_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Open"}) import os as _os sch_base, brd_base = _os.path.basename(sch), _os.path.basename(brd)@@ -2472,6 +2472,11 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict: "to run it: fusion_set_auto_update {enabled:true} or a " "one-off background click on the next sweep." % ("opted out" if not _auto_update_enabled() else "pending"))+ _asf = _addin_start_failure()+ if _asf:+ result["addinStartFailed"] = _asf+ result["_hint"] = _asf["_hint"] + " " + str(result.get("_hint") or "")+ return result @@ -3170,6 +3175,28 @@ def _check_main_thread_blocked() -> bool: return False ++def _addin_start_failure() -> dict | None:+ """The add-in's own start-failure marker (%TEMP%/adom-fusion/addin-start-failed.json),+ written by AdomBridge.run() when an import blew up (issue #75: a stale copy in the legacy+ AddIns dir). Surfaced by fusion_readiness and fusion_addin_status until the add-in starts+ cleanly again (a clean start deletes the marker)."""+ try:+ base = os.environ.get("TEMP") or os.path.expanduser("~")+ fp = os.path.join(base, "adom-fusion", "addin-start-failed.json")+ if not os.path.isfile(fp):+ return None+ with open(fp, "r", encoding="utf-8") as fh:+ m = json.load(fh)+ m["markerPath"] = fp+ m["_hint"] = ("Fusion loaded a BROKEN copy of the add-in from %s. The bridge re-syncs every "+ "AddIns dir on start; restart Fusion with fusion_restart_for_addin {save:true} "+ "so it loads the repaired copy. If it keeps failing, delete that folder and "+ "restart Fusion: the bridge reinstalls it." % m.get("addinDir"))+ return m+ except Exception:+ return None+ def _probe_addin(timeout: float = 0.5) -> dict | None: """Check if the Fusion add-in HTTP server is running. @@ -3301,6 +3328,9 @@ def _handle_addin_status() -> dict: result = {"success": True, "output": json.dumps(status), **status} if stale.get("addinStale"): result["_hint"] = stale["_staleHint"]+ _asf = _addin_start_failure()+ if _asf and isinstance(result, dict):+ result["addinStartFailed"] = _asf return result @@ -5297,7 +5327,7 @@ def _taskbar_progress(pct=None): progress bar during chain stages, paced by this machine's measured estimates. pct=None clears the bar. Best-effort: never raises, silently no-ops without ab.""" try:- if not ad_client.available():+ if not ab_client.available(): return info = get_fusion_window_info() or {} hwnd = info.get("hwnd") or (info.get("mainWindow") or {}).get("hwnd")@@ -5309,7 +5339,7 @@ def _taskbar_progress(pct=None): else: args["progress"] = {"state": "normal", "value": max(1, min(100, int(pct))), "max": 100}- ad_client.call("desktop_taskbar", args, timeout=4)+ ab_client.call("desktop_taskbar", args, timeout=4) except Exception: pass @@ -6284,10 +6314,10 @@ def _describe_profile(p: dict) -> str: def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | None: """Call an ab verb over the loopback DIRECT API - the correct, non-foregrounding way for a- bridge to reach ab when the in-process ad_client is down (issue #295). ab spawns the bridge+ bridge to reach ab when the in-process ab_client is down (issue #295). ab spawns the bridge with ADOM_DIRECT_API_URL in its env (fallback: ~/.adom/direct-api-port); POST {"command", "args"} to <base>/command. Returns the parsed result dict, or None if the direct- API is unavailable or errors (so _ad_call can fall through). Never shells the GUI exe."""+ API is unavailable or errors (so _ab_call can fall through). Never shells the GUI exe.""" base = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip() if not base: try:@@ -6303,7 +6333,7 @@ def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | No body = json.dumps({"command": verb, "args": args}).encode("utf-8") headers = {"Content-Type": "application/json"} # The bridge token is what keeps a bridge's own callbacks exempt from ab's identity gate- # (ad_client already sends it; this path used to omit it).+ # (ab_client already sends it; this path used to omit it). tok = os.environ.get("ADOM_BRIDGE_TOKEN") or "" if tok: headers["X-Adom-Bridge-Token"] = tok@@ -6334,12 +6364,12 @@ def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | No return None -def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:+def _ab_call(verb: str, args: dict, timeout: int = 60) -> dict: """Call another ab verb (nbrowser_*, desktop_*) from inside this bridge. - Prefers the in-process ad_client; then the loopback DIRECT API (issue #295); only as a last+ Prefers the in-process ab_client; then the loopback DIRECT API (issue #295); only as a last resort shells the host app's **-cli** exe (adom-bridge-cli.exe, NEVER the GUI adom-bridge.exe,- which would foreground ab). Works on an unattended VM where ad_client is down. Never raises -+ which would foreground ab). Works on an unattended VM where ab_client is down. Never raises - returns {} on failure so callers can degrade to instructing the AI instead. Attribution (issue #348) is stamped into args HERE, once, so it survives on ALL THREE paths -@@ -6349,8 +6379,8 @@ def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict: args = _stamp_caller(args) why = [] try:- if ad_client.available():- r = ad_client.call(verb, args, timeout=timeout)+ if ab_client.available():+ r = ab_client.call(verb, args, timeout=timeout) if isinstance(r, dict): inner = r.get("output") if isinstance(inner, str) and inner.strip().startswith("{"):@@ -6360,11 +6390,11 @@ def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict: except Exception: return r return r- why.append("ad_client.call returned %r" % type(r).__name__)+ why.append("ab_client.call returned %r" % type(r).__name__) else:- why.append("ad_client unavailable")+ why.append("ab_client unavailable") except Exception as e:- why.append("ad_client raised %s" % e)+ why.append("ab_client raised %s" % e) # #295: prefer the loopback DIRECT API - it reaches ab without foregrounding anything. direct = _ad_direct_api_call(verb, args, timeout=timeout) if isinstance(direct, dict):@@ -6557,7 +6587,7 @@ def _find_update_offer(hwnd=None) -> dict: pass for h in candidates: for label in _UPDATE_BUTTONS:- r = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))+ r = _find_controls_any(_ab_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20)) best = r.get("best") or {} if best.get("name") and best.get("invokable"): import time as _ut@@ -6592,7 +6622,7 @@ def _apply_fusion_update_silently(hwnd=None) -> dict: "DEFERRED, will retry when idle" % (offer["button"], idle.get("why"))) return {"applied": False, "deferred": True, "button": offer["button"], "why": "exit-class update button deferred: %s" % idle.get("why")}- r = _unwrap(_ad_call("desktop_ui_click",+ r = _unwrap(_ab_call("desktop_ui_click", {"hwnd": int(offer["hwnd"]), "name": offer["button"]}, timeout=30)) ok = bool(r.get("invoked") or r.get("success")) if ok:@@ -6734,13 +6764,13 @@ def _find_save_prompt() -> dict: continue title = str(d.get("title") or "").strip().lower() for label in (() if h in _NO_DONTSAVE_HWNDS else _DONT_SAVE_LABELS):- r = _find_controls_any(_ad_call("desktop_find_control",+ r = _find_controls_any(_ab_call("desktop_find_control", {"hwnd": int(h), "name": label}, timeout=10)) best = r.get("best") or {} if best.get("name") and best.get("invokable"): text = "" try:- t = _find_controls_any(_ad_call("desktop_find_control",+ t = _find_controls_any(_ab_call("desktop_find_control", {"hwnd": int(h), "name": "save"}, timeout=10)) for c in ([t.get("best") or {}] + list(t.get("controls") or [])): nm = str(c.get("name") or "")@@ -6862,7 +6892,7 @@ def _dismiss_save_prompt_if_bridge_doc() -> dict: return {"handled": False, "why": "unattributed save prompt - strike %d of 2 before Escape" % n, "hwnd": prompt["hwnd"]} if prompt.get("button"):- r = _unwrap(_ad_call("desktop_ui_click",+ r = _unwrap(_ab_call("desktop_ui_click", {"hwnd": prompt["hwnd"], "name": prompt["button"]}, timeout=15)) if r.get("invoked") or r.get("success"): print("[Fusion Bridge] save-prompt sentinel: clicked %r for bridge doc %r (background)"@@ -6873,7 +6903,7 @@ def _dismiss_save_prompt_if_bridge_doc() -> dict: # that loop is exactly what John saw twice in a row (2026-08-20). Try the decisive # button first; Escape stays as the fallback when UIA is unavailable. try:- r2 = _unwrap(_ad_call("desktop_ui_click",+ r2 = _unwrap(_ab_call("desktop_ui_click", {"hwnd": prompt["hwnd"], "name": "Don't Save"}, timeout=15)) if r2.get("invoked") or r2.get("success"): print("[Fusion Bridge] save-prompt sentinel: clicked \'Don\'t Save\' for bridge doc %r "@@ -7335,10 +7365,10 @@ def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict: _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))+ r = _unwrap(_ab_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 []):+ for w in (_unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or []): if str(w.get("title", "")).strip() == "Preferences": dlg = w.get("hwnd") if not dlg:@@ -7347,7 +7377,7 @@ def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict: steps.append("opened Preferences") def shot():- s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))+ s = _unwrap(_ab_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)@@ -7356,24 +7386,24 @@ def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict: 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),+ _ab_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),+ _ab_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),+ _ab_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),+ _ab_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),+ _ab_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)@@ -7436,14 +7466,14 @@ _PREF_SECTIONS = { def _prefs_dialog_hwnd():- for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):+ for w in (_unwrap(_ab_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))+ s = _unwrap(_ab_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,@@ -7462,7 +7492,7 @@ def _handle_prefs_open(fusion_info: dict, args: dict) -> dict: hwnd = _prefs_dialog_hwnd() if not hwnd: _announce_foreground("opening Fusion preferences")- _ad_call("fusion_execute_text_command",+ _ab_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45) _t.sleep(3) hwnd = _prefs_dialog_hwnd()@@ -7482,12 +7512,12 @@ def _handle_prefs_open(fusion_info: dict, args: dict) -> dict: 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),+ _ab_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),+ _ab_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)@@ -7515,13 +7545,13 @@ def _handle_prefs_close(fusion_info: dict, args: dict) -> dict: "_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),+ _ab_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),+ _ab_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),+ _ab_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),@@ -7624,7 +7654,7 @@ def _fusion_window(): """Find the main Fusion window via desktop_list_windows. Returns the whole entry (it already carries `rect`, so we get geometry in the SAME call - there is no desktop_window_info verb). Returns {} when not found."""- wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+ wins = _unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or [] cand = {} for w in wins: t = str(w.get("title", ""))@@ -7671,7 +7701,7 @@ def _track_signin_window(session_id, profile, kind="fusion"): def _close_tracked_window(w) -> bool: """Close one window WE opened. nbrowser_close_window only closes our own sessions."""- r = _ad_call("nbrowser_close_window",+ r = _ab_call("nbrowser_close_window", {"sessionId": w["sessionId"], "profile": w.get("profile")}, timeout=30) return isinstance(r, dict) and not r.get("_adCallError") @@ -7903,13 +7933,13 @@ def _seat_sweep_tick(): if _uh: _ours = None for _probe in tuple(_BRIDGE_DOCS)[:8] + _BRIDGE_DOC_BUILTINS:- _rr = _find_controls_any(_ad_call("desktop_find_control",+ _rr = _find_controls_any(_ab_call("desktop_find_control", {"hwnd": int(_uh), "contains": _probe}, timeout=8)) if (_rr.get("best") or {}).get("name"): _ours = _probe break if _ours:- _ck2 = _unwrap(_ad_call("desktop_ui_click",+ _ck2 = _unwrap(_ab_call("desktop_ui_click", {"hwnd": int(_uh), "name": "Don't Save"}, timeout=10)) _bridge_action_ledger("unsaved_changes_dont_save", {"hwnd": int(_uh), "docMatch": _ours,@@ -7941,7 +7971,7 @@ def _seat_sweep_tick(): try: for _d in (classify_blocking_dialogs() or []): if _d.get("category") == "create_confirm":- _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))+ _unwrap(_ab_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12)) print("[Fusion Bridge] janitor: declined a stray Create-new prompt (%s)" % _d.get("matchedOn", "title")) _bridge_action_ledger("create_confirm_declined", {"matchedOn": _d.get("matchedOn")})@@ -8002,7 +8032,7 @@ def _seat_resolve_section(): if now - _last_seat_takeover_notice[0] > 120: _last_seat_takeover_notice[0] = now try:- ad_client.notify(+ ab_client.notify( "Fusion seat taken over on this machine", "Autodesk flagged your account's session limit, so I clicked through the " "'Active Sessions Exceeded' dialogs in the background and took the seat here. "@@ -8083,7 +8113,7 @@ def _announce_foreground(reason: str) -> bool: (no toast stack, auto-clears), so that is what we use. Best-effort; never blocks the action. """ try:- _ad_call("desktop_caption",+ _ab_call("desktop_caption", {"text": "Adom: %s" % reason, "id": "fusion-bridge-foreground", "expiresInMs": 2500}, timeout=15) return True@@ -8096,13 +8126,13 @@ def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reaso to SendInput - which DOES foreground - when there is no UIA 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))+ r = _unwrap(_ab_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30)) if r.get("ok") or r.get("clicked") or r.get("invoked"): return {"clicked": True, "via": "uia_background", "announced": False, "why": "UIA Invoke on %r (background, no focus steal)" % name} if shot_id is not None and x is not None and y is not None: announced = _announce_foreground(reason or "clicking %s for you" % (name or "a button"))- _ad_call("desktop_click", {"space": "image", "shotId": shot_id,+ _ab_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)"}@@ -8117,7 +8147,7 @@ def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reaso # and hand the AI an exact, mechanical way to clear it without them. def _autodesk_window_hwnds() -> set: """hwnds of every Autodesk-ish BROWSER window right now (used to tell NEW from STALE)."""- wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+ wins = _unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or [] out = set() for w in wins: tl = str(w.get("title", "")).lower()@@ -8135,7 +8165,7 @@ def _advance_credentials_page(hwnd, email) -> dict: import time as _t out = {"advanced": False, "wall": "credentials", "detail": ""} try:- found = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+ found = _find_controls_any(_ab_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40)) ctrls = found.get("controls") or [] if not ctrls and found.get("best"): ctrls = [found["best"]]@@ -8149,20 +8179,20 @@ def _advance_credentials_page(hwnd, email) -> dict: for k in ("email", "username", "user id", "userid"))] or ctrls[:1] if email and emailish: c = emailish[0]- r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),+ r = _unwrap(_ab_call("desktop_ui_set", {"hwnd": int(hwnd), "automationId": c.get("automationId"), "name": c.get("name"), "value": email}, timeout=30)) if r.get("success") or r.get("ok"): out["detail"] = "typed email %r into %r (background UIA)" % (email, c.get("name") or c.get("automationId")) for btn in ("Next", "NEXT", "Sign in", "Sign In", "Continue"):- rb = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": btn}, timeout=25))+ rb = _unwrap(_ab_call("desktop_ui_click", {"hwnd": int(hwnd), "name": btn}, timeout=25)) if rb.get("success") or rb.get("ok") or rb.get("status") == "ok": out["advanced"] = True out["detail"] += "; invoked %r" % btn break if out["advanced"]: _t.sleep(4)- found2 = _unwrap(_ad_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+ found2 = _unwrap(_ab_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40)) ctrls2 = found2.get("controls") or found2.get("matches") or [] if not ctrls2 and found2.get("best"): ctrls2 = [found2["best"]]@@ -8183,7 +8213,7 @@ def _detect_signin_wall(ignore_hwnds=None) -> dict: reported as the live wall and we toast the user about nothing (seen live 2026-07-22). """ ignore = set(ignore_hwnds or ())- wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+ wins = _unwrap(_ab_call("desktop_list_windows", {}, timeout=40)).get("windows") or [] best = {} for w in wins: if w.get("hwnd") in ignore:@@ -8326,10 +8356,10 @@ def _fusion_click_signin() -> dict: 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):- _ad_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30)+ _ab_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30) _t.sleep(1.5) - _raw = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45)+ _raw = _ab_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45) shot = _unwrap(_raw) # ab has moved coordMap across envelope levels between versions (sibling of data / # nested / stringified in output on 2.0.46) - search recursively, never guess a level.@@ -8343,7 +8373,7 @@ def _fusion_click_signin() -> dict: W = w_rect.get("width") or 0; H = w_rect.get("height") or 0 if L is not None and W > 400 and H > 300: sx = int(L + W * 0.5); sy = int(T + H * 0.57)- rr = _unwrap(_ad_call("desktop_click", {"x": sx, "y": sy, "hwnd": int(hwnd),+ rr = _unwrap(_ab_call("desktop_click", {"x": sx, "y": sy, "hwnd": int(hwnd), "reason": "signing Fusion in for you (clicking Sign In, screen-space fallback)"}, timeout=30)) okc = rr.get("success") or rr.get("ok") or rr.get("status") == "ok"@@ -8406,7 +8436,7 @@ def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google" steps = [] def ev(expr):- r = _unwrap(_ad_call("nbrowser_eval", {"profile": profile, "tabId": tab_id,+ r = _unwrap(_ab_call("nbrowser_eval", {"profile": profile, "tabId": tab_id, "expression": expr}, timeout=45)) return r.get("result") or r.get("value") @@ -8414,7 +8444,7 @@ def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google" a = {"profile": profile, "tabId": tab_id} if text: a["text"] = text if selector: a["selector"] = selector- r = _unwrap(_ad_call("nbrowser_click", a, timeout=45))+ r = _unwrap(_ab_call("nbrowser_click", a, timeout=45)) return bool(r.get("ok") or r.get("success") or r.get("changed")) btn = _SSO_BUTTONS.get((provider or "google").lower(), _SSO_BUTTONS["google"])@@ -8470,7 +8500,7 @@ def _orchestrate_signin_2fa(args: dict) -> dict: # Find the code field WITHOUT touching the foreground. Autodesk renders either one input or a # segmented set, so try the obvious accessible names, then fall back to the first edit control.- found = _unwrap(_ad_call("desktop_find_control",+ found = _unwrap(_ab_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40)) ctrls = found.get("controls") or found.get("matches") or [] steps = []@@ -8478,14 +8508,14 @@ def _orchestrate_signin_2fa(args: dict) -> dict: if len(ctrls) >= len(digits) and len(ctrls) >= 4: # segmented: one box per digit for i, ch in enumerate(digits[:len(ctrls)]):- _ad_call("desktop_ui_set", {"hwnd": int(hwnd),+ _ab_call("desktop_ui_set", {"hwnd": int(hwnd), "automationId": ctrls[i].get("automationId"), "name": ctrls[i].get("name"), "value": ch}, timeout=20) filled = True steps.append("typed %d digits into %d segmented boxes (background UIA)" % (len(digits), len(ctrls))) elif ctrls: c = ctrls[0]- r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),+ r = _unwrap(_ab_call("desktop_ui_set", {"hwnd": int(hwnd), "automationId": c.get("automationId"), "name": c.get("name"), "value": digits}, timeout=20)) filled = bool(r.get("ok") or r.get("set") or r is not None)@@ -8569,7 +8599,7 @@ def _orchestrate_signin(args: dict) -> dict: "statusVerb": "fusion_signin"} # profiles known to ABE (for target selection + naming)- profs = _ad_call("nbrowser_profiles", {}, timeout=40)+ profs = _ab_call("nbrowser_profiles", {}, timeout=40) pdata = profs.get("data", profs) if isinstance(profs, dict) else {} choices = [p for p in (pdata.get("profiles") or []) if isinstance(p, dict) and p.get("profile")] @@ -8628,7 +8658,7 @@ def _orchestrate_signin(args: dict) -> dict: for c in choices: if not c.get("live"): continue- ls = _ad_call("nbrowser_login_state",+ ls = _ab_call("nbrowser_login_state", {"profile": c["profile"], "url": "https://accounts.autodesk.com/"}, timeout=40) lsd = ls.get("data", ls) if isinstance(ls, dict) else {} c["_ad"] = "signed-in" if lsd.get("loggedIn") else ("signed-out" if lsd.get("ok") is not None else "unprobed")@@ -8702,7 +8732,7 @@ def _orchestrate_signin(args: dict) -> dict: _open_args = {"sessionId": sess, "url": auth["url"], "background": True, "profile": target, "thread": "fusion-signin", "purpose": "Fusion Autodesk sign-in (correct profile)"}- r = _ad_call("nbrowser_open_window", _open_args, timeout=60)+ r = _ab_call("nbrowser_open_window", _open_args, timeout=60) rd_open = r.get("data", r) if isinstance(r, dict) else {} opened = bool(rd_open.get("sessionId") or r.get("ok") or r.get("success") or rd_open.get("opened") or ("opened in the BACKGROUND" in str(r.get("_hint", ""))))@@ -8712,7 +8742,7 @@ def _orchestrate_signin(args: dict) -> dict: # that profile. nbrowser_open_os_window does not - it opens the URL in the real profile # at the OS level, which is all an OAuth consent needs. This keeps the multi-profile fix # working on machines with no extension (same gap as APS issue #12).- r2 = _ad_call("nbrowser_open_os_window", _open_args, timeout=60)+ r2 = _ab_call("nbrowser_open_os_window", _open_args, timeout=60) if isinstance(r2, dict) and not r2.get("_adCallError"): # ok:false just means the hwnd was not resolved in ~8s; the launch usually still fired opened = bool(r2.get("ok") or r2.get("hwnd") or "Launch fired" in str(r2.get("_hint", "")))@@ -8731,7 +8761,7 @@ def _orchestrate_signin(args: dict) -> dict: # signed into the identity provider, this completes the whole login silently. sso = None if opened and open_via == "extension" and args.get("driveSso", True):- tabs = _unwrap(_ad_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or []+ tabs = _unwrap(_ab_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or [] my_tab = None for t in tabs: if "autodesk" in str(t.get("url", "")).lower() and t.get("owner") == "agent-opened":@@ -9187,7 +9217,7 @@ def _show_library_view(name, suffix, lbr_path=None): _body = _d.get("title") or "" if str(name).lower() in _body.lower(): _about_this = True- _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))+ _unwrap(_ab_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12)) if not _about_this: print("[Fusion Bridge] declined a STALE Create-new prompt that does not mention " "%r - clearing it and trusting the EDIT's own result" % name)@@ -9646,7 +9676,7 @@ _TOUR_PANEL_ID = "fusion-tour" def _tour_panel_show(state, stops, stage, playing=True, reason=""): """Create/update the remote (idempotent by id). Best-effort, never raises.""" try:- _ad_call("desktop_demo_panel", {+ _ab_call("desktop_demo_panel", { "action": "show", "id": _TOUR_PANEL_ID, "title": "Fusion bridge tour", "stops": stops, "stage": int(stage), "state": "playing" if playing else "paused", "position": "bottom-right",@@ -9662,7 +9692,7 @@ def _tour_panel_pump(state): button names seen (for the progress stream). Never raises.""" seen = [] try:- r = _ad_call("desktop_demo_panel", {"action": "poll", "id": _TOUR_PANEL_ID}, timeout=8) or {}+ r = _ab_call("desktop_demo_panel", {"action": "poll", "id": _TOUR_PANEL_ID}, timeout=8) or {} events = _find_key(r, "events") or [] for ev in events: b = str((ev or {}).get("button") or "").lower()@@ -9686,7 +9716,7 @@ def _tour_panel_pump(state): def _tour_panel_hide(): try:- _ad_call("desktop_demo_panel", {"action": "hide", "id": _TOUR_PANEL_ID}, timeout=8)+ _ab_call("desktop_demo_panel", {"action": "hide", "id": _TOUR_PANEL_ID}, timeout=8) except Exception: pass @@ -9706,7 +9736,7 @@ def _tour_caption(text: str, seconds: int = 45): try: # dash-demo fleet ruling: ALWAYS the bridge's DEFAULT caption size and position - # seven apps, one caption look. If the default is wrong, fix it in ab for everyone.- _ad_call("desktop_caption", {"text": text, "id": "fusion-tour",+ _ab_call("desktop_caption", {"text": text, "id": "fusion-tour", "duration": max(15000, min(int(seconds) * 2000, 240000))}, timeout=10) except Exception:@@ -10173,7 +10203,7 @@ def _orchestrate_demo_tour(args: dict) -> dict: state["tourDocsSweepError"] = str(_swe)[:200] if args.get("captions", True): try:- _ad_call("desktop_caption", {"action": "hide", "id": "fusion-tour"}, timeout=8)+ _ab_call("desktop_caption", {"action": "hide", "id": "fusion-tour"}, timeout=8) except Exception: pass if remote:@@ -11208,14 +11238,14 @@ def _orchestrate_demo(args: dict) -> dict: # DO IT, don't just describe it: open the Autodesk sign-in in the user's OWN browser, # in the BACKGROUND so we never yank them out of what they're doing. Then tell the AI # exactly WHERE it is waiting and offer the three ways forward.- nb = _ad_call("nbrowser_readiness", {}, timeout=40)+ nb = _ab_call("nbrowser_readiness", {}, timeout=40) nb_data = nb.get("data", nb) if isinstance(nb, dict) else {} nb_state = nb_data.get("state") opened, where, profile_used = False, "", "" pick_reason, ambiguous = "", False choices = [] if nb_state == "ready":- profs = _ad_call("nbrowser_profiles", {}, timeout=40)+ profs = _ab_call("nbrowser_profiles", {}, timeout=40) pdata = profs.get("data", profs) if isinstance(profs, dict) else {} # profiles are DICTS: {profile,label,email,browser,displayName,profileDir,active,live,...} for p in (pdata.get("profiles") or []):@@ -11240,7 +11270,7 @@ def _orchestrate_demo(args: dict) -> dict: if not c["live"]: c["autodesk"] = "unprobed(asleep)" continue- ls = _ad_call("nbrowser_login_state",+ ls = _ab_call("nbrowser_login_state", {"profile": c["profile"], "url": "https://accounts.autodesk.com/"}, timeout=45) lsd = ls.get("data", ls) if isinstance(ls, dict) else {}@@ -11279,7 +11309,7 @@ def _orchestrate_demo(args: dict) -> dict: "background": True, "thread": "fusion-demo", "purpose": "Autodesk sign-in for Fusion"} if profile_used: oa["profile"] = profile_used- r = _ad_call("nbrowser_open_window", oa, timeout=60)+ r = _ab_call("nbrowser_open_window", oa, timeout=60) opened = bool((r.get("data", r) if isinstance(r, dict) else {}).get("sessionId") or r.get("ok") or r.get("success")) if not where: where = "your browser"@@ -11725,7 +11755,7 @@ def _time_and_indicate(command: str, args: dict, run): stop = None if slow and mode in ("flash", "both"): try:- _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ _ab_call("desktop_taskbar", {"titleContains": "Autodesk Fusion", "flash": "until_focused"}, timeout=10) except Exception: pass@@ -11749,7 +11779,7 @@ def _time_and_indicate(command: str, args: dict, run): from handlers.taskbar_overlay import end_activity_progress end_activity_progress(command, failed=failed) if mode in ("flash", "both"):- _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ _ab_call("desktop_taskbar", {"titleContains": "Autodesk Fusion", "flash": "stop"}, timeout=8) except Exception: pass@@ -11773,7 +11803,7 @@ def _handle_set_activity_indicator(fusion_info: dict, args: dict) -> dict: pass if mode in ("none", "progress"): try:- _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ _ab_call("desktop_taskbar", {"titleContains": "Autodesk Fusion", "flash": "stop"}, timeout=8) except Exception: pass@@ -14732,14 +14762,14 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> for _d in (classify_blocking_dialogs() or []): _cat = _d.get("category") if _cat == "upload_in_progress":- _unwrap(_ad_call("desktop_ui_click",+ _unwrap(_ab_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12)) if isinstance(res, dict): res["uploadCloseDeclined"] = True print("[Fusion Bridge] answered the Hub upload-close confirm with " "No (work-preserving; close retried after jobs drain)") elif _cat == "create_confirm":- _unwrap(_ad_call("desktop_ui_click",+ _unwrap(_ab_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12)) if isinstance(res, dict): res["createPromptDeclined"] = True@@ -14747,7 +14777,7 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> "chokepoint (raised by a raw EDIT; Yes would fabricate an " "empty artifact)") elif _cat in ("launch_error", "lbr_open_error"):- _unwrap(_ad_call("desktop_ui_click",+ _unwrap(_ab_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "OK"}, timeout=12)) print("[Fusion Bridge] dismissed a standing %s dialog at the " "chokepoint" % _cat)@@ -14837,7 +14867,7 @@ def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = No "window comes forward, so they can yield.")} _note_focus_event(command, "foreground", fg_reason) try:- ad_client.notify("Fusion is coming to the foreground",+ ab_client.notify("Fusion is coming to the foreground", fg_reason + " (Requested by your AI - Fusion verbs otherwise always " "run in the background.)", level="info", reach_user=True) _time.sleep(3.0) # the user's 3-second heads-up BEFORE any focus change@@ -15528,6 +15558,16 @@ def main(): print(f" Add-in sync failed: {e}") else: print(f" WARNING: Fusion 360 not found. Some commands will fail.")+ # Detection can miss a Fusion that is nonetheless installed (another profile, an odd+ # webdeploy root). If ANY AddIns dir exists the add-in is in use there, so sync it+ # anyway: idempotent, and the only way a stale copy ever gets repaired (issue #75).+ try:+ from install_addin import TARGET_CANDIDATES as _tc+ if any(c.exists() for c in _tc):+ print(f"[Fusion Bridge] An AddIns dir exists though Fusion was not detected: syncing the add-in anyway")+ install_addin()+ except Exception as e:+ print(f" Add-in sync (undetected Fusion) failed: {e}") addin_health = _probe_addin() if addin_health:@@ -15842,24 +15882,24 @@ def _handle_live_feed(fusion_info: dict, args: dict) -> dict: if not hwnd: _LIVE_FEED["noHwnd"] += 1 if hwnd:- # The in-process ad_client returns a bare image ({data:{image,format,...}}) and+ # The in-process ab_client returns a bare image ({data:{image,format,...}}) and # NO owned popups; the loopback direct API returns the full capture with # screenshots[] (banner, panels, dialogs) and box paths. Prefer it here. # PROBED 2026-08-29 (fusion_live_feed {action:"probe"}): the in-process- # ad_client returns the FULL capture (fullPath, fullWidth, ownedPopupCount,+ # ab_client returns the FULL capture (fullPath, fullWidth, ownedPopupCount, # screenshots[] with every owned popup); the loopback direct API returns a- # bare inline image with no popups. _ad_call would unwrap ad_client's `output`+ # bare inline image with no popups. _ab_call would unwrap ab_client's `output` # string and throw the outer shape away, so call the client directly here. shot = None try:- if ad_client.available():- _r = ad_client.call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20)+ if ab_client.available():+ _r = ab_client.call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20) if isinstance(_r, dict) and (_r.get("fullPath") or _r.get("screenshots") is not None): shot = _r except Exception as _ce:- _LIVE_FEED["lastError"] = ("ad_client: %s" % _ce)[:200]+ _LIVE_FEED["lastError"] = ("ab_client: %s" % _ce)[:200] if not isinstance(shot, dict):- shot = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20) or {}+ shot = _ab_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20) or {} # The ab reply nests the capture under `data` ({data:{fullPath,...}, message}). # Reading the top level gave "screenshot returned no path: ['data','message']" # on every tick all session (found by the honesty fields, 2026-08-29).@@ -16037,7 +16077,7 @@ def _dpi_scale_of(win_row: dict) -> float: moves around, hence the recursive lookup); falls back to the screen/……ratio, then 1.0. A wrong scale is why the first managed-library run clicked at (283,51) instead of (592,129).""" try:- sc = _ad_call("desktop_screenshot_screen", {}, timeout=40) or {}+ sc = _ab_call("desktop_screenshot_screen", {}, timeout=40) or {} v = _find_key(sc, "dpiScale") if v: return float(v)@@ -16063,7 +16103,7 @@ def _frac_to_screen(frac_xy, info): def _fusion_main_window(): """{hwnd, rect, dpiScale} for Fusion's main window, or None.""" try:- r = _ad_call("desktop_list_windows", {}, timeout=20) or {}+ r = _ab_call("desktop_list_windows", {}, timeout=20) or {} out = r.get("output") data = json.loads(out) if isinstance(out, str) else (r.get("data") or {}) for w in ((data.get("data") or data).get("windows") or []):@@ -16141,7 +16181,7 @@ def _popup_menu_rect(timeout_s=5.0): deadline = _t.time() + timeout_s while _t.time() < deadline: try:- resp = _ad_call("desktop_list_windows", {}, timeout=25)+ resp = _ab_call("desktop_list_windows", {}, timeout=25) wins = _find_key(resp, "windows") or [] cands = [] for w in wins:@@ -16175,7 +16215,7 @@ def _wait_for_top_window(title_substr, timeout_s=25.0): deadline = _t.time() + timeout_s while _t.time() < deadline: try:- resp = _ad_call("desktop_list_windows", {}, timeout=25)+ resp = _ab_call("desktop_list_windows", {}, timeout=25) for w in (_find_key(resp, "windows") or []): if want in str(w.get("title") or "").lower(): return w@@ -16196,7 +16236,7 @@ def _wait_for_top_window_gone(title_substr, timeout_s=15.0): deadline = _t.time() + timeout_s while _t.time() < deadline: try:- resp = _ad_call("desktop_list_windows", {}, timeout=25)+ resp = _ab_call("desktop_list_windows", {}, timeout=25) wins = _find_key(resp, "windows") if wins is not None and not any(want in str(w.get("title") or "").lower() for w in wins):@@ -16217,7 +16257,7 @@ def _picker_rows(hwnd): per row. Only REALIZED rows are exposed (Qt virtualizes the list), so this returns what is on screen, not necessarily the whole catalogue.""" try:- resp = _ad_call("desktop_ui_tree", {"hwnd": hwnd, "maxDepth": 14}, timeout=60)+ resp = _ab_call("desktop_ui_tree", {"hwnd": hwnd, "maxDepth": 14}, timeout=60) except Exception as e: return [], str(e) cells = []@@ -16280,9 +16320,9 @@ def _ribbon_click(win, key, why, popup=False): x = int(ox + _LIBIO_DIP[key][0] * scale) y = int(oy + _LIBIO_DIP[key][1] * scale) if popup:- _ad_call("desktop_mouse_click", {"x": x, "y": y, "reason": why}, timeout=25)+ _ab_call("desktop_mouse_click", {"x": x, "y": y, "reason": why}, timeout=25) else:- _ad_call("desktop_click", {"hwnd": win["hwnd"], "x": x, "y": y, "cursor": False,+ _ab_call("desktop_click", {"hwnd": win["hwnd"], "x": x, "y": y, "cursor": False, "reason": why}, timeout=25) return {"step": key, "at": [x, y], "scale": scale} @@ -16323,7 +16363,7 @@ def _libio_menu(win, item, why, steps): x = int(rect["left"] + rect["width"] / 2) y = int(rect["top"] + (idx + 0.5) * rect["height"] / n) # A popup item goes through the raw mouse verb: re-focusing Fusion here would dismiss it.- _ad_call("desktop_mouse_click", {"x": x, "y": y, "reason": why}, timeout=25)+ _ab_call("desktop_mouse_click", {"x": x, "y": y, "reason": why}, timeout=25) steps.append({"step": item, "at": [x, y], "menuRect": rect}) _t.sleep(2.0) return None@@ -16357,7 +16397,7 @@ def _handle_web_signin(fusion_info: dict, args: dict) -> dict: steps = [] def _pup(verb, a, timeout=90):- return _ad_call("pup_" + verb, a, timeout=timeout) or {}+ return _ab_call("pup_" + verb, a, timeout=timeout) or {} def _state(): r = _pup("eval", {"sessionId": sess, "expression":@@ -16479,7 +16519,7 @@ def _click_cef_button(win, x_frac, y_frac, why): w, h = int(r.get("width") or 0), int(r.get("height") or 0) if left is None or top is None or not (w and h): return False- _ad_call("desktop_click", {"x": int(left + w * x_frac), "y": int(top + h * y_frac),+ _ab_call("desktop_click", {"x": int(left + w * x_frac), "y": int(top + h * y_frac), "hwnd": win.get("hwnd"), "cursor": False, "reason": why}, timeout=25) return True@@ -16677,7 +16717,7 @@ def _handle_managed_library_link(fusion_info: dict, args: dict) -> dict: want = (args.get("name") or "").strip() def _close(why):- _ad_call("desktop_ui_click", {"hwnd": hwnd, "name": "Cancel", "reason": why}, timeout=30)+ _ab_call("desktop_ui_click", {"hwnd": hwnd, "name": "Cancel", "reason": why}, timeout=30) if not want: _close("close the Select Managed Library picker after listing it read-only")@@ -16698,11 +16738,11 @@ def _handle_managed_library_link(fusion_info: dict, args: dict) -> dict: "_hint": "Names are exact. If you expect a library shared from another Autodesk " "account, it only appears once its MANAGED FOLDER is shared with this " "account on library.io - being in the same Fusion hub is not enough."}- _ad_call("desktop_click", {"x": int(hit["_at"][0]), "y": int(hit["_at"][1]), "cursor": False,+ _ab_call("desktop_click", {"x": int(hit["_at"][0]), "y": int(hit["_at"][1]), "cursor": False, "reason": "select %s in the Select Managed Library picker" % want}, timeout=25) _t.sleep(1)- ok = _ad_call("desktop_ui_click", {"hwnd": hwnd, "name": "OK",+ ok = _ab_call("desktop_ui_click", {"hwnd": hwnd, "name": "OK", "reason": "confirm the managed-library link"}, timeout=30) or {} steps.append({"step": "confirm", "row": hit["name"], "clicked": bool(_find_key(ok, "success"))}) _t.sleep(2)@@ -16746,12 +16786,12 @@ def _handle_library_manager(fusion_info: dict, args: dict) -> dict: tab = (args.get("tab") or "private").lower() label = {"hub": "Hub Libraries", "public": "Public Libraries", "private": "Private Libraries"}.get(tab, "Private Libraries")- click = _ad_call("desktop_ui_click", {"hwnd": dlg.get("hwnd"), "name": label,+ click = _ab_call("desktop_ui_click", {"hwnd": dlg.get("hwnd"), "name": label, "reason": "switch Library Manager to %s" % label}, timeout=40) or {} steps.append({"step": "tab", "label": label, "ok": bool(click.get("success"))}) _t.sleep(12) # library.io fetches are slow; Library Manager can even show Not Responding- tree = _ad_call("desktop_ui_tree", {"hwnd": dlg.get("hwnd"), "maxDepth": 10,+ tree = _ab_call("desktop_ui_tree", {"hwnd": dlg.get("hwnd"), "maxDepth": 10, "maxNodes": 600}, timeout=120) or {} rows = [] @@ -16861,13 +16901,13 @@ def _handle_managed_library_create(fusion_info: dict, args: dict) -> dict: "_hint": "Push offers Create only for a library that is NOT yet linked. If this " "library is already linked, fusion_managed_library_push pushes a new " "version instead - check fusion_alerts."}- ok = _ad_call("desktop_ui_click", {"hwnd": dlg.get("hwnd"), "name": "Create",+ ok = _ab_call("desktop_ui_click", {"hwnd": dlg.get("hwnd"), "name": "Create", "reason": "create the managed library on library.io"}, timeout=30) or {} steps.append({"step": "create", "ok": bool(ok.get("success"))}) conf = _wait_for_dialog("Create Managed Library", 40) # the confirmation reuses the title if conf:- _ad_call("desktop_ui_click", {"hwnd": conf.get("hwnd"), "name": "OK",+ _ab_call("desktop_ui_click", {"hwnd": conf.get("hwnd"), "name": "OK", "reason": "acknowledge the managed-library confirmation"}, timeout=30) steps.append({"step": "ok"})