← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.134\ No newline at end of file+1.9.135\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.9.134",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.151/adom-bridge-fusion-v1.9.134.zip",-  "sha256": "b99a60377f5ad946b62e617501bea1e5a66eaf7cb8b216c0bd6f3b32e02429ca",-  "size": 468960,+  "version": "1.9.135",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.153/adom-bridge-fusion-v1.9.135.zip",+  "sha256": "22f164cc4593c72331096e45edf1975ca6d97c1a0143508f424d03f0725deb10",+  "size": 470036,   "verbPrefixes": [     "fusion_"   ],
aps.py+36
@@ -751,6 +751,41 @@ def start_signin(args: dict | None = None) -> dict:   # ── Verb handlers ────────────────────────────────────────────────────────────+_USERINFO_CACHE = {}   # access_token[:24] -> {"email","name","at"}+++def _aps_user_info() -> dict:+    """WHO the APS token belongs to - Autodesk's OIDC userinfo, cached per token.++    John, 2026-08-22: the dashboard cards must show WHO is signed in, because the+    release-readiness auth tests run multiple accounts ([email protected],+    [email protected], ...) and an anonymous green LED cannot distinguish them.+    Free endpoint (userinfo, no data charges); one call per token, then cached.+    """+    try:+        at = _valid_access_token()+    except Exception:+        at = None+    if not at:+        return {}+    key = at[:24]+    hit = _USERINFO_CACHE.get(key)+    if hit:+        return hit+    try:+        import urllib.request as _u+        req = _u.Request("https://api.userprofile.autodesk.com/userinfo",+                         headers={"Authorization": "Bearer " + at})+        info = json.loads(_u.urlopen(req, timeout=10).read())+        out = {"email": info.get("email"), "name": info.get("name"),+               "userId": info.get("sub")}+        _USERINFO_CACHE.clear()+        _USERINFO_CACHE[key] = out+        return out+    except Exception:+        return {}++ def handle_status(args: dict) -> dict:     # NOTE: AD's relay only forwards output/data/success/error/_hint to the     # cloud caller — every structured field MUST live under `data`.@@ -777,6 +812,7 @@ def handle_status(args: dict) -> dict:     data = {         "configured": bool(client_id),         "signedIn": bool(usable),          # usable NOW (refreshed if needed), not merely present+        "signedInAs": (_aps_user_info() if usable else None),         "hasStoredToken": bool(has_at),    # a token file exists (may be dead)         "refreshFailed": refresh_failed,   # stored token is dead and could not be refreshed         "tokenLive": bool(live),
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.134",+  "version": "1.9.135",   "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.",
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.150"+version = "1.9.153" edition = "2021"  [dependencies]
dashboard/src/main.rs+8
@@ -115,6 +115,11 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso     let last_signed = rd["lastSignedInAt"].as_str().unwrap_or("");     let last_ready = rd["lastReadyAt"].as_str().unwrap_or("");     let mcp_last = mcp["lastEnabledAt"].as_str().unwrap_or("");+    // WHO is signed in, per facet (John, 2026-08-22): the auth test matrix runs+    // several accounts, and an anonymous green LED cannot distinguish them.+    let aps_who = aps["signedInAs"]["email"].as_str()+        .or(aps["signedInAs"]["name"].as_str()).unwrap_or("").to_string();+    let fusion_who = rd["fusionUser"]["user"].as_str().unwrap_or("").to_string();     let aps_ok = aps["signedIn"].as_bool().or(aps["configured"].as_bool()).unwrap_or(false)         || aps["ready"].as_bool().unwrap_or(false);     let mcp_on = mcp["enabled"].as_bool().unwrap_or(false);@@ -164,11 +169,13 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso                          else if !running && !last_ready.is_empty() {format!("Dormant - resumes when Fusion starts (last verified {}).", last_ready)}                          else {"Not serving. If needsSignin, run fusion_signin - it self-drives to the password step.".to_string()}},             "signedIn": {"on": running && !needs_signin, "label": "Autodesk sign-in",+                "who": if fusion_who.is_empty() { serde_json::Value::Null } else { serde_json::json!(fusion_who) },                 "state": if running && !needs_signin {"on"} else if !running && !last_signed.is_empty() {"dormant"} else {"off"},                 "_hint": if needs_signin {"fusion_signin {email} self-drives the whole wall: launches, clicks, types the email, toasts the user ONLY at their password.".to_string()}                          else if !running && !last_signed.is_empty() {format!("Signed in (verified {}). Unknowable while Fusion is closed - dormant, not broken.", last_signed)}                          else if running {"Signed in.".to_string()} else {"Unknown until Fusion runs.".to_string()}},             "aps": {"on": aps_ok, "label": "APS cloud search",+                "who": if aps_who.is_empty() { serde_json::Value::Null } else { serde_json::json!(aps_who) },                 "_hint": if aps_ok {"fusion_aps_search gives ~2s cloud lookups."} else {"Not configured: fusion_aps_set_client_id then fusion_aps_signin (silent when the browser session is warm)."}},             "mcp": {"on": mcp_on, "label": "Fusion MCP server",                 "state": if mcp_on {"on"} else if !running && !mcp_last.is_empty() {"dormant"} else {"off"},@@ -176,6 +183,7 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso                          else if !running && !mcp_last.is_empty() {format!("Enabled in Fusion's preferences (verified {}) - resumes when Fusion starts.", mcp_last)}                          else {"fusion_mcp_enable turns it on (~10s, drives the Preferences dialog with a caption).".to_string()}},             "datahub": {"on": aps_ok, "label": "Data Hub API",+                "who": if aps_who.is_empty() { serde_json::Value::Null } else { serde_json::json!(aps_who) },                 "_hint": "Data Hub rides the APS auth: file listings, versions, urn resolution via fusion_aps_get."},             "update": {"on": !upd_offered || upd_handled, "label": "Fusion up to date",                 "pending": upd_offered && !upd_handled,
dashboard/ui/index.html+3−1
@@ -61,6 +61,7 @@ h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);ma #launchbar .lbnote{margin-top:6px;font-size:11px;color:var(--text-2)} @keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(210,153,34,.55)}50%{opacity:.55;box-shadow:0 0 10px 3px rgba(210,153,34,.35)}} .led-label{font-weight:500;color:var(--text)}+.led-who{margin-left:auto;font:12px/1.2 'JetBrains Mono',monospace;color:var(--text-2);background:var(--elevated,#1c2128);border:1px solid var(--border);border-radius:9px;padding:2px 8px;max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px} .btn{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 14px 'Satoshi',sans-serif;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease} .btn:hover{background:var(--overlay);border-color:#4b5563}@@ -336,7 +337,8 @@ function renderLeds(st){     const l=leds[key];     const cls=l.state==='starting'?'starting':(l.pending?'warn':((l.state==='dormant'||l.state==='busy')?'warn':(l.on?'on':'off')));     const el=document.createElement('div');el.className='card';-    el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label}</span></div><div class="led-sub">${l._hint||''}</div>`;+    const who=l.who?`<span class="led-who" title="Signed in as ${l.who}">${l.who}</span>`:'';+    el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label}</span>${who}</div><div class="led-sub">${l._hint||''}</div>`;     g.appendChild(el);   }   $('#subject').textContent=st.target?('target: '+st.target):'no bridge target';
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.150"+DASH_VERSION="1.9.153" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.152",+  "version": "1.9.153",   "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+10−7
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.152",+  "version": "1.9.153",   "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",@@ -70,7 +74,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.",@@ -99,9 +106,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }+  ] }\ No newline at end of file
server.py+42
@@ -550,6 +550,37 @@ def _handle_new_electronics_from_eagle(args: dict) -> dict:   +_FUSION_USER = {}       # {"user","email","fetchedAt"} - cleared when Fusion stops+++def _ensure_fusion_user_async():+    """Cache WHO is signed into Fusion itself (app.userName / userId) without ever+    slowing a readiness poll: first ready poll with an empty cache kicks a daemon+    thread; the next poll reads the answer. Cleared on fusion stop/kill so an+    account switch shows the new identity. (John, 2026-08-22: the dashboard cards+    must show who is logged in - the auth test matrix runs several accounts.)"""+    if _FUSION_USER.get("user") or _FUSION_USER.get("fetching"):+        return+    _FUSION_USER["fetching"] = True+    import threading as _thu++    def _fetch():+        try:+            r = _proxy_to_addin("run_modeling_script",+                                {"script": "import adsk.core\n"+                                           "a=adsk.core.Application.get()\n"+                                           "result={'user': a.userName, 'id': a.userId}"},+                                timeout=20)+            d = (r.get("data") or {}).get("result") or {}+            if d.get("user"):+                _FUSION_USER.update(user=d.get("user"), userId=d.get("id"))+        except Exception:+            pass+        finally:+            _FUSION_USER.pop("fetching", None)+    _thu.Thread(target=_fetch, daemon=True).start()++ def _addin_round_trip_ready(tries: int = 2, gap: float = 1.5) -> bool:     """Can the add-in actually EXECUTE a command, twice in a row? @@ -1742,6 +1773,13 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:     else:         result_note = None +    if ready:+        try:+            _ensure_fusion_user_async()+        except Exception:+            pass+    elif not running and _FUSION_USER:+        _FUSION_USER.clear()      # account switch shows the NEW identity after restart     result = {         "success": True,         "hostApp": "Fusion 360",@@ -1754,6 +1792,10 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:         # rule - so on Windows/Mac this is False and the caller should convert at         # authoring time and send shape A (.lbr + .step).         "kicadIngest": bool(_find_adom_lbr()),+        # WHO is signed into Fusion itself (cached; fetched in the background on the+        # first ready poll, so readiness never waits on a modeling script)+        "fusionUser": ({"user": _FUSION_USER.get("user"), "userId": _FUSION_USER.get("userId")}+                       if _FUSION_USER.get("user") else None),         "bridgeVersion": BRIDGE_VERSION,         **stale_info,         "_hint": (hint + " NOTE: " + result_note) if result_note else hint,