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.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824
//! State-only verbs the exe answers natively: the sticky preference toggles, cancel_run,
//! addin_status and plan_library_import. Each mirrors its `_handle_*` in server.py to the
//! word (hints included), so a caller cannot tell which shell answered except by `shell`.
use serde_json::{json, Value};
use std::path::Path;
fn prefs_path(state_dir: &Path) -> std::path::PathBuf { state_dir.join("prefs.json") }
pub fn get_pref(state_dir: &Path, key: &str) -> Option<Value> {
let t = std::fs::read_to_string(prefs_path(state_dir)).ok()?;
serde_json::from_str::<Value>(t.trim_start_matches('\u{feff}')).ok()?.get(key).cloned()
}
fn pref_bool(state_dir: &Path, key: &str, default: bool) -> bool {
match get_pref(state_dir, key) { Some(Value::Bool(b)) => b, Some(Value::Null) | None => default, Some(v) => !(v == json!(0) || v == json!("")) }
}
/// `_set_bridge_pref`: read-modify-write of prefs.json (indent 2, BOM-free, atomic).
pub fn set_pref(state_dir: &Path, key: &str, value: Value) -> bool {
let p = prefs_path(state_dir);
let mut cur = std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str::<Value>(t.trim_start_matches('\u{feff}')).ok()).unwrap_or(json!({}));
if !cur.is_object() { cur = json!({}); }
cur[key] = value;
let _ = std::fs::create_dir_all(state_dir);
let tmp = p.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(&cur).unwrap_or_default()).is_ok() && std::fs::rename(&tmp, &p).is_ok()
}
fn on_off(b: bool) -> &'static str { if b { "ON" } else { "OFF" } }
pub fn set_auto_update(state_dir: &Path, args: &Value) -> Value {
if args.get("enabled").is_none() {
let cur = pref_bool(state_dir, "autoUpdateFusion", true);
return json!({"success": true, "autoUpdateFusion": cur,
"_hint": format!("Fusion auto-update is currently {}. It is ON BY DEFAULT: the bridge clicks Fusion's 'Update Now' for the user in the BACKGROUND so they always run the latest build and never see the nag. To opt out: fusion_set_auto_update {{\"enabled\": false}} - that choice is STICKY.", on_off(cur)),
"statusVerb": "fusion_readiness"});
}
let enabled = truthy(&args["enabled"]);
set_pref(state_dir, "autoUpdateFusion", json!(enabled));
json!({"success": true, "autoUpdateFusion": enabled,
"narrate": if enabled { "I'll keep Fusion updated automatically in the background." } else { "I'll stop auto-updating Fusion. You'll see Autodesk's own update prompts." },
"_hint": format!("Saved and sticky (~/.adom/fusion-bridge/prefs.json). {}", if enabled {
"Auto-update ON: the bridge silently clicks 'Update Now' whenever Fusion offers one, so the user always runs the newest build. Tell them it is handled and they never need to click it."
} else { "Auto-update OFF: the bridge will NOT touch update prompts; the user handles Autodesk's nag themselves. Do not override this." }),
"statusVerb": "fusion_readiness"})
}
pub fn set_seat_takeover(state_dir: &Path, args: &Value) -> Value {
if args.get("enabled").is_none() {
let cur = pref_bool(state_dir, "autoTakeOverSeat", true);
return json!({"success": true, "autoTakeOverSeat": cur,
"_hint": format!("Seat takeover is currently {}. ON BY DEFAULT: when Autodesk pops the 'Active Sessions Exceeded' dialogs mid-session, the bridge clicks Continue through BOTH in the background - this machine takes the seat, the other machine's Fusion suspends RESUMABLY, one toast tells the user. Opt out: fusion_set_seat_takeover {{\"enabled\": false}} - STICKY.", on_off(cur)),
"statusVerb": "fusion_readiness"});
}
let enabled = truthy(&args["enabled"]);
set_pref(state_dir, "autoTakeOverSeat", json!(enabled));
json!({"success": true, "autoTakeOverSeat": enabled,
"narrate": if enabled { "I'll automatically take the Fusion seat on this machine whenever Autodesk flags the session limit." } else { "I'll leave Autodesk seat-conflict dialogs for you to resolve yourself." },
"_hint": format!("Sticky (~/.adom/fusion-bridge/prefs.json). {}", if enabled { "Takeover ON: mid-session seat dialogs are clicked through silently." } else { "Takeover OFF: the sweep will NOT touch seat dialogs; fusion_start still grabs the seat when the user asks for Fusion." }),
"statusVerb": "fusion_readiness"})
}
pub fn set_window_sizing(state_dir: &Path, args: &Value) -> Value {
if args.get("enabled").is_none() {
let cur = pref_bool(state_dir, "sizeFusionWindow", true);
return json!({"success": true, "sizeFusionWindow": cur,
"_hint": format!("Fusion window auto-sizing is {}. ON BY DEFAULT: after a launch the bridge grows a small Fusion window to ~92% of the monitor work area, centered, WITHOUT activating it (SetWindowPos NOACTIVATE, never ShowWindow/maximize, so your focus is never stolen). A window you maximized or already sized large is left alone. Opt out: fusion_set_window_sizing {{\"enabled\": false}} - STICKY.", on_off(cur)),
"statusVerb": "fusion_state"});
}
let enabled = truthy(&args["enabled"]);
set_pref(state_dir, "sizeFusionWindow", json!(enabled));
json!({"success": true, "sizeFusionWindow": enabled,
"narrate": if enabled { "I'll size Fusion's window to fill most of your screen when it launches." } else { "I'll leave Fusion's window size exactly as Fusion opens it." },
"statusVerb": "fusion_state"})
}
/// The read path and the pref write are native; turning the badge OFF also clears the
/// live overlay, which still lives in the sidecar's taskbar handler (`clear` is the proxy).
pub fn set_driving_badge(state_dir: &Path, args: &Value, clear: &dyn Fn()) -> Value {
if args.get("enabled").is_none() {
let cur = pref_bool(state_dir, "showDrivingBadge", true);
return json!({"success": true, "showDrivingBadge": cur,
"_hint": format!("The driving badge is currently {}. ON BY DEFAULT: while Adom drives Fusion, its taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds after 5 minutes idle. Opt out: fusion_set_driving_badge {{\"enabled\": false}} - STICKY.", on_off(cur)),
"statusVerb": "fusion_state"});
}
let enabled = truthy(&args["enabled"]);
set_pref(state_dir, "showDrivingBadge", json!(enabled));
if !enabled { clear(); }
json!({"success": true, "showDrivingBadge": enabled,
"narrate": if enabled { "I'll badge Fusion's taskbar button while I'm driving it." } else { "I'll stop badging Fusion's taskbar button." },
"_hint": "Sticky (~/.adom/fusion-bridge/prefs.json). Cleared immediately when turned off.",
"statusVerb": "fusion_state"})
}
fn truthy(v: &Value) -> bool {
match v { Value::Bool(b) => *b, Value::Null => false, Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0, Value::String(s) => !s.is_empty(), Value::Array(a) => !a.is_empty(), Value::Object(o) => !o.is_empty() }
}
/// `fusion_cancel_run`: cooperative stop at the next part boundary; shared run store.
pub fn cancel_run(args: &Value, banner: &dyn Fn(Value)) -> Value {
let terminal = ["done", "failed", "cancelled"];
let mut runs = crate::chains::read_runs();
let mut rid = args["runId"].as_str().unwrap_or("").trim().to_string();
if rid.is_empty() {
let mut live: Vec<&Value> = runs.values().filter(|r| !terminal.contains(&r["stage"].as_str().unwrap_or(""))).collect();
live.sort_by(|a, b| a["startedAt"].as_f64().unwrap_or(0.0).partial_cmp(&b["startedAt"].as_f64().unwrap_or(0.0)).unwrap());
if let Some(r) = live.last() { rid = r["runId"].as_str().unwrap_or("").to_string(); }
}
if rid.is_empty() {
return json!({"success": true, "cancelRequested": false, "reason": "no_run_in_flight", "note": "No import is running on this box, so there is nothing to cancel.", "_hint": "fusion_prove_result {} shows the latest run and its stage."});
}
let Some(mut st) = runs.get(&rid).cloned() else {
return json!({"success": false, "errorCode": "unknown_run_id", "error": format!("no run with runId {} on this box", fusion_lbr::py_repr_str(&rid))});
};
let stage = st["stage"].clone();
if terminal.contains(&stage.as_str().unwrap_or("")) {
return json!({"success": true, "runId": rid, "alreadyFinished": true, "stage": stage, "_hint": format!("That run had already ended ({}); nothing to cancel.", stage.as_str().unwrap_or(""))});
}
let by: String = args["caller"]["aiThread"].as_str().or(args["by"].as_str()).unwrap_or("user").chars().take(60).collect();
st["cancelRequested"] = json!(true);
st["cancelledBy"] = json!(by);
st["stageLabel"] = json!(format!("stopping at the next part boundary ({})", st["stageLabel"].as_str().or(st["stage"].as_str()).unwrap_or("")));
runs.insert(rid.clone(), st.clone());
let p = std::env::temp_dir().join("fusion-prove-runs.json");
let _ = std::fs::write(&p, serde_json::to_string(&runs).unwrap_or_default());
let lp = std::env::temp_dir().join("fusion-prove-last.json");
if let Some(mut last) = std::fs::read_to_string(&lp).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
if last["runId"].as_str() == Some(&rid) { last["cancelRequested"] = json!(true); let _ = std::fs::write(&lp, last.to_string()); }
}
banner(json!({"action": "update", "title": "Stopping the import", "detail": "Finishing the current part, then stopping", "percent": Value::Null, "etaSec": Value::Null, "tone": "working"}));
json!({"success": true, "runId": rid, "cancelRequested": true, "stage": stage, "statusVerb": "fusion_prove_result",
"_hint": "The run stops at its next part boundary (a STEP import in progress finishes first, up to ~60s). Poll fusion_prove_result {runId}: stage becomes 'cancelled' with cancelledAt and what was completed. Parts already bound keep their 3D; the library stays open in Fusion, unsaved to the cloud."})
}
/// The bundled add-in's manifest version (beside the exe, `addin/AdomBridge/AdomBridge.manifest`).
pub fn expected_addin_version() -> String {
let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).unwrap_or_default();
std::fs::read_to_string(dir.join("addin").join("AdomBridge").join("AdomBridge.manifest")).ok()
.and_then(|t| serde_json::from_str::<Value>(t.trim_start_matches('\u{feff}')).ok())
.and_then(|v| v["version"].as_str().map(str::to_string)).unwrap_or_else(|| "unknown".into())
}
/// `_addin_staleness`.
pub fn addin_staleness(reported: Option<&str>) -> Value {
let expected = expected_addin_version();
let reported_s = reported.unwrap_or("unknown");
let stale = reported.map(|r| r != "unknown").unwrap_or(false) && expected != "unknown" && reported_s != expected;
let mut v = json!({"addinVersion": reported_s, "expectedAddinVersion": expected, "addinStale": stale});
if stale {
v["_staleHint"] = json!(format!("STALE add-in: Fusion is running add-in v{reported_s} but this bridge bundles v{expected}. Newer verbs (e.g. fusion_aps_open/open_by_urn) can fail silently. Fix: fusion_stop then fusion_start - the bridge re-syncs the add-in from its cache on start (only possible with Fusion CLOSED)."));
}
v
}
/// `_addin_start_failure`: the add-in's own start-failure marker.
pub fn addin_start_failure() -> Option<Value> {
let base = std::env::var("TEMP").ok().filter(|s| !s.is_empty()).map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir);
let fp = base.join("adom-fusion").join("addin-start-failed.json");
let t = std::fs::read_to_string(&fp).ok()?;
let mut m: Value = serde_json::from_str(&t).ok()?;
if !m.is_object() { return None; }
m["markerPath"] = json!(fp.to_string_lossy().replace('\\', "/"));
m["_hint"] = json!(format!("Fusion loaded a BROKEN copy of the add-in from {}. The bridge re-syncs every AddIns dir on start; restart Fusion with fusion_restart_for_addin {{save:true}} so it loads the repaired copy. If it keeps failing, delete that folder and restart Fusion: the bridge reinstalls it.", m["addinDir"].as_str().unwrap_or("None")));
Some(m)
}
/// `fusion_addin_status`: GET /status on the add-in, staleness, the start-failure marker.
pub fn addin_status(fusion_running: bool) -> Value {
let status: Option<Value> = ureq::get("http://127.0.0.1:8774/status").timeout(std::time::Duration::from_secs(3)).call().ok().and_then(|r| r.into_json().ok());
let Some(mut status) = status else {
return json!({"success": false, "status": "unreachable", "errorCode": "addin_not_answering",
"error": format!("The Adom add-in did not answer on port 8774. Fusion is {}running.", if fusion_running { "" } else { "NOT " }),
"_hint": "If Fusion is running, the add-in was installed or updated after launch (or its server stopped): fusion_restart_for_addin {save:true} asks and restarts. If Fusion is not running, fusion_start."});
};
let stale = addin_staleness(status["version"].as_str());
if let (Some(so), Some(st)) = (status.as_object_mut(), stale.as_object()) { for (k, v) in st { if k != "_staleHint" { so.insert(k.clone(), v.clone()); } } }
let mut result = json!({"success": true, "output": serde_json::to_string(&status).unwrap_or_default()});
if let (Some(ro), Some(so)) = (result.as_object_mut(), status.as_object()) { for (k, v) in so { ro.insert(k.clone(), v.clone()); } }
if stale["addinStale"].as_bool() == Some(true) { result["_hint"] = stale["_staleHint"].clone(); }
if let Some(asf) = addin_start_failure() { result["addinStartFailed"] = asf; }
result
}
fn human_secs(v: f64) -> String {
let v = v.round() as i64;
if v < 60 { return format!("{v}s"); }
let (m, s) = (v / 60, v % 60);
if s != 0 { format!("{m}m {s:02}s") } else { format!("{m}m") }
}
/// `fusion_plan_library_import`: the pre-flight summary, no work done.
pub fn plan_library_import(state_dir: &Path, installed: bool, running: bool, addin_ok: bool, args: &Value) -> Value {
let parts = args["parts"].as_array().cloned().unwrap_or_default();
let n_parts = parts.len().max(1);
let has_steps = parts.iter().any(|p| p["modelPath"].as_str().map(|s| !s.is_empty()).unwrap_or(false)) || args["stepPath"].as_str().map(|s| !s.is_empty()).unwrap_or(false);
let inst = installed || running;
let (status, note) = if !inst { ("not_installed", "Fusion does not look installed on this machine. I can install it for you - it is a silent install plus an Autodesk sign-in.") }
else if !running { ("installed_not_running", "Fusion is installed but not running. I can launch it, which usually takes about a minute, and then carry on.") }
else if !addin_ok { ("addin_restart_required", "Fusion is running but the Adom add-in is not loaded - it needs a quick restart to finish setup. I can save your documents and do it.") }
else { ("ready", "Fusion is running and ready - I can start straight away.") };
let est = |k: &str, f: f64| { let (v, c) = crate::chains::estimate(state_dir, k, f); ((v * 10.0).round() / 10.0, c) };
let mut steps: Vec<Value> = Vec::new();
if !inst { let (v, c) = est("stage:install_fusion", 1200.0); steps.push(json!({"stage": "install", "label": "install Fusion (streams the app)", "estimatedSec": v, "confidence": c, "needsUserConsent": true, "note": "10-30 minutes depending on connection, plus an Autodesk sign-in"})); }
if inst && !running { let (v, c) = est("stage:launch", 60.0); steps.push(json!({"stage": "launch", "label": "open Fusion", "estimatedSec": v, "confidence": c})); }
if status == "addin_restart_required" { let (v, c) = est("stage:launch", 60.0); steps.push(json!({"stage": "restart", "label": "restart Fusion to load the add-in", "estimatedSec": v, "confidence": c, "needsUserConsent": true})); }
if has_steps { let (per, c) = est("stage:bind_per_part", 60.0); steps.push(json!({"stage": "bind", "label": format!("attach 3D to {} part{}", n_parts, if n_parts == 1 { "" } else { "s" }), "estimatedSec": ((per * n_parts as f64) * 10.0).round() / 10.0, "confidence": c, "parts": n_parts, "perPartSec": per, "note": "one STEP import per part - the slowest stage"})); }
{ let (v, c) = est("stage:prove", 300.0); steps.push(json!({"stage": "prove", "label": "capture 4 evidence views", "estimatedSec": v, "confidence": c})); }
let total = (steps.iter().map(|s| s["estimatedSec"].as_f64().unwrap_or(0.0)).sum::<f64>() * 10.0).round() / 10.0;
let ready_total = (steps.iter().filter(|s| !matches!(s["stage"].as_str(), Some("launch") | Some("restart"))).map(|s| s["estimatedSec"].as_f64().unwrap_or(0.0)).sum::<f64>() * 10.0).round() / 10.0;
let conf = if steps.iter().all(|s| s["confidence"].as_str() == Some("measured")) { "measured" } else { "typical" };
let tail = if ready_total != total { format!("Had Fusion already been open, about {}.", human_secs(ready_total)) } else { String::new() };
json!({"success": true,
"fusion": {"installed": inst, "running": running, "addinReady": addin_ok, "status": status, "message": note},
"plan": steps, "partsTotal": n_parts, "estimatedSec": total, "estimatedSecIfAlreadyOpen": ready_total, "confidence": conf,
"summary": format!("{} This will take about {}. {}", note, human_secs(total), tail).trim().to_string(),
"_hint": "Pre-flight only - NOTHING was launched, imported or changed. Render `summary` plus `plan[]` as the progress bar's steps, then call fusion_create_library (or open_lbr + build_library_3d) and poll fusion_prove_result, which reports the SAME stage names so one renderer draws both. Estimates marked confidence:'measured' come from this machine's own recorded runs; 'typical' means seeded and not yet observed here."})
}
/// The host app's CLI (`adom-bridge-cli.exe` beside the app, else `adom-bridge` on PATH). The
/// Rust exe knows only the current name: the naming guard keeps retired names out of rust/.
fn find_ab_cli() -> Option<std::path::PathBuf> {
let la = std::env::var("LOCALAPPDATA").unwrap_or_default();
if !la.is_empty() {
let p = std::path::Path::new(&la).join("Adom Bridge").join("adom-bridge-cli.exe");
if p.is_file() { return Some(p); }
}
for name in ["adom-bridge-cli", "adom-bridge"] {
if let Ok(path) = std::env::var("PATH") { for d in std::env::split_paths(&path) { for ext in ["", ".exe"] { let p = d.join(format!("{name}{ext}")); if p.is_file() { return Some(p); } } } }
}
None
}
/// `_cli_notify_all`: a sticky toast through the CLI when the loopback API is down.
fn cli_notify_all(state_dir: &Path, title: &str, body: &str, level: &str) -> Value {
let Some(exe) = find_ab_cli() else { return json!({"delivered": false, "error": "adom-bridge CLI not found"}) };
let payload = json!({"title": title, "body": body, "level": level, "scenario": "reminder", "durationLong": true, "buttons": [{"label": "Got it"}]}).to_string();
let mut cmd = std::process::Command::new(&exe);
if pref_bool(state_dir, "notifyBroadcast", false) { cmd.args(["--target", "all"]); }
cmd.arg("notify_user").arg(&payload);
#[cfg(windows)]
{ use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); }
match cmd.output() {
Ok(o) => {
let out = format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
let delivered = o.status.success() || out.contains("displayed") || out.contains("\"status\": \"ok\"") || out.contains("status':'ok");
let nid = regex::Regex::new(r#""id"\s*:\s*"([0-9a-f]{8,})""#).ok().and_then(|re| re.captures(&out).map(|c| c[1].to_string()));
json!({"delivered": delivered, "targets": "all", "id": nid, "error": if delivered { Value::Null } else { json!(out.chars().take(200).collect::<String>()) }})
}
Err(e) => json!({"delivered": false, "error": e.to_string()}),
}
}
/// `fusion_notify_owner`: the last-resort toast to the human, wherever they are.
pub fn notify_owner(state_dir: &Path, args: &Value, caller: &fusion_core::ab::Caller) -> Value {
let title = args["title"].as_str().filter(|s| !s.is_empty()).unwrap_or("Fusion bridge needs you").to_string();
let body = args["body"].as_str().or(args["message"].as_str()).filter(|s| !s.is_empty()).unwrap_or("A human step is required to continue.").to_string();
let level = args["level"].as_str().filter(|s| !s.is_empty()).unwrap_or("warning").to_string();
let via_api = fusion_core::ab::Loopback::from_env().map(|ab| { use fusion_core::ab::AbClient; ab.call("notify_user", json!({"title": title, "body": body, "level": level, "target": "all"}), caller, 20) });
match via_api {
Some(Ok(res)) => {
let summary_ok = res["summary"]["ok"].as_f64().unwrap_or(0.0) > 0.0;
let ok = res["success"].as_bool() == Some(true) || res["status"].as_str() == Some("ok") || summary_ok;
let reached = if res["targets"].is_null() { if ok { json!(["self"]) } else { json!([]) } } else { res["targets"].clone() };
json!({"success": ok, "via": "ab_client:direct(all)", "targets": reached,
"_hint": "Toast fanned out to ALL connected desktops (the user's machine included) via the in-process ab direct API. This is the LAST RESORT - only use after exhausting programmatic options (UIA background clicks, seat auto-resolve, warm-SSO sign-in), and only when the box is ACTUALLY ready for the user to act. Now WAIT and poll fusion_readiness for the state to clear; do not re-toast within a few minutes."})
}
_ => {
let cli = cli_notify_all(state_dir, &title, &body, &level);
if cli["delivered"].as_bool() == Some(true) {
return json!({"success": true, "via": "cli:--target all", "targets": cli["targets"],
"_hint": "Toast fanned out to ALL connected desktops (the user's main machine included) via the adom-bridge CLI, because the bridge's in-process ab API was unavailable (this VM). WAIT and poll fusion_readiness; do not re-toast within a few minutes. Only notify when the box is ACTUALLY ready for the user to act - do not toast for a step you can do yourself."});
}
json!({"success": false,
"error": format!("ab in-process API unavailable AND the adom-bridge CLI fallback failed: {}", cli["error"].as_str().unwrap_or("None").chars().take(200).collect::<String>()),
"notifyUser": {"title": title, "body": body, "level": level},
"_hint": "Relay the notifyUser payload yourself: `adom-bridge --target all notify_user {title, body, level}` (or `--target <the user's host>`)."})
}
}
}
/// `fusion_close_window`: WM_CLOSE to a dialog, through ab's graceful close (never a kill).
pub fn close_window(args: &Value, caller: &fusion_core::ab::Caller) -> Value {
let Some(hwnd) = args["hwnd"].as_i64().or_else(|| args["hwnd"].as_str().and_then(|s| s.trim().parse().ok())).filter(|h| *h != 0) else {
return json!({"success": false, "error": "Missing required arg: hwnd"});
};
let Some(ab) = fusion_core::ab::Loopback::from_env() else {
return json!({"success": false, "error": format!("HWND {hwnd} could not be closed: the ab loopback API is not available to this bridge."), "_hint": "The exe was not spawned by ab (ADOM_DIRECT_API_URL unset). Let ab spawn it, or use desktop_close_window {hwnd} directly."});
};
use fusion_core::ab::AbClient;
match ab.call("desktop_close_window", json!({"hwnd": hwnd}), caller, 15) {
Ok(r) => {
if r["resolvedBy"].is_null() && r["ok"].as_bool() != Some(true) && r["success"].as_bool() != Some(true) {
return json!({"success": false, "error": format!("HWND {hwnd} is not a valid window.")});
}
json!({"success": true, "hwnd": hwnd, "action": "WM_CLOSE sent", "output": format!("Sent WM_CLOSE to hwnd {hwnd}."), "closed": r["closed"]})
}
Err(fusion_core::ab::AbError::Refused { message, .. }) if message.to_lowercase().contains("not found") || message.to_lowercase().contains("no window") || message.to_lowercase().contains("invalid") => json!({"success": false, "error": format!("HWND {hwnd} is not a valid window.")}),
Err(e) => json!({"success": false, "error": format!("HWND {hwnd} could not be closed: {e:?}")}),
}
}
/// `fusion_screenshot_fusion`: the main window (or a dialog by hwnd), background PrintWindow via ab.
pub fn screenshot_fusion(args: &Value, caller: &fusion_core::ab::Caller) -> Value {
let Some(ab) = fusion_core::ab::Loopback::from_env() else {
return json!({"success": false, "error": "Screenshot unavailable: the ab loopback API is not available to this bridge.", "_hint": "The exe was not spawned by ab (ADOM_DIRECT_API_URL unset). Let ab spawn it, or use desktop_screenshot_window directly."});
};
use fusion_core::ab::AbClient;
let asked = args["hwnd"].as_i64().or(args["dialogHwnd"].as_i64()).or_else(|| args["hwnd"].as_str().and_then(|s| s.trim().parse().ok())).filter(|h| *h != 0);
let hwnd = match asked {
Some(h) => h,
None => {
let facts = fusion_core::windows_facts::facts_from_ab(&ab, caller);
match facts.main_hwnd {
Some(h) => h,
None => return json!({"success": false, "error": "Fusion 360 window not found (no window with 'Autodesk Fusion' in title).", "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry."}),
}
}
};
match ab.call("desktop_screenshot_window", json!({"hwnd": hwnd}), caller, 40) {
Ok(r) => {
let saved = r["fullPath"].as_str().or(r["safePath"].as_str()).map(|s| s.replace('\\', "/"));
let Some(saved) = saved else { return json!({"success": false, "error": "ab captured nothing for that window (no fullPath in the answer)", "hwnd": hwnd}) };
let bytes = r["fullBytesHost"].as_f64().or(r["fullBytes"].as_f64()).unwrap_or(0.0);
let size_kb = (bytes / 1024.0 * 10.0).round() / 10.0;
let (w, h) = (r["fullWidth"].clone(), r["fullHeight"].clone());
json!({"success": true, "savedTo": saved, "sizeKB": size_kb, "hwnd": hwnd,
"fullPath": saved, "safePath": r["safePath"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| saved.clone()), "fullPathHost": saved, "safePathHost": saved,
"fullWidth": w, "fullHeight": h, "dimensions": {"width": w, "height": h}, "ownedPopupCount": r["ownedPopupCount"], "screenshots": r["screenshots"],
"output": json!({"savedTo": saved, "sizeKB": size_kb, "fullPath": saved, "safePath": saved, "fullPathHost": saved, "safePathHost": saved, "fullWidth": w, "fullHeight": h}).to_string()})
}
Err(e) => json!({"success": false, "error": format!("Screenshot failed: {e:?}"), "hwnd": hwnd}),
}
}
/// UI Automation reads of a window's controls cost ~1-5 s on a busy Fusion, and the classifier,
/// the closable check and the inventory each wanted one for the same window on the same verb.
/// One read per window per 20 s is plenty: a dialog's buttons do not change that fast.
pub fn controls_cached(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> Vec<Value> {
use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant};
static CACHE: Mutex<Option<HashMap<i64, (Instant, Vec<Value>)>>> = Mutex::new(None);
if let Some((t, v)) = CACHE.lock().unwrap().get_or_insert_with(HashMap::new).get(&hwnd) { if t.elapsed() < Duration::from_secs(20) { return v.clone(); } }
let v: Vec<Value> = ab.call("desktop_find_controls", json!({"hwnd": hwnd}), caller, 10).ok().map(|r| { let d = if r["data"].is_object() { r["data"].clone() } else { r }; d["controls"].as_array().or(d["matches"].as_array()).cloned().unwrap_or_default() }).unwrap_or_default();
let mut g = CACHE.lock().unwrap(); let m = g.get_or_insert_with(HashMap::new);
m.retain(|_, (t, _)| t.elapsed() < Duration::from_secs(60));
m.insert(hwnd, (Instant::now(), v.clone()));
v
}
/// ab's image-delivery contract: a reply that carries `fullPath` (and `safePath`), or
/// `screenshots[]` entries with `fullPath`, is auto-pulled to the CALLER's machine, so an AI in a
/// container can read the picture with no second call. Older replies named the Windows path
/// `path`, `savedTo`, `screenshot` or `postOpenScreenshot`, which ab does not pull (page issue
/// #98). This lifts every Windows image path in a reply into that shape and keeps the old keys.
pub fn add_image_delivery(r: &mut Value) {
if !r.is_object() { return; }
fn is_img(s: &str) -> bool {
let l = s.to_ascii_lowercase();
let b = l.as_bytes();
b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\') && (l.ends_with(".png") || l.ends_with(".jpg") || l.ends_with(".jpeg") || l.ends_with(".webp"))
}
fn walk(v: &Value, label: &str, depth: u8, out: &mut Vec<(String, String)>) {
if depth > 7 { return; }
match v {
Value::String(s) if is_img(s) => { if !out.iter().any(|(p, _)| p == s) { out.push((s.replace('\\', "/"), label.to_string())); } }
Value::Object(m) => for (k, x) in m { if k == "output" || k == "_hint" || k.starts_with("_") { continue; } walk(x, &if label.is_empty() { k.clone() } else { format!("{label}.{k}") }, depth + 1, out); },
Value::Array(a) => for (i, x) in a.iter().enumerate() { walk(x, &format!("{label}[{i}]"), depth + 1, out); },
_ => {}
}
}
let mut found = Vec::new();
walk(r, "", 0, &mut found);
if found.is_empty() { return; }
if r["fullPath"].is_null() {
r["fullPath"] = json!(found[0].0);
if r["safePath"].is_null() { r["safePath"] = json!(found[0].0); }
}
let has_shots = r["screenshots"].as_array().map(|a| a.iter().any(|e| e["fullPath"].is_string())).unwrap_or(false);
if !has_shots {
r["screenshots"] = json!(found.iter().map(|(p, l)| json!({"fullPath": p, "safePath": p, "label": l})).collect::<Vec<_>>());
}
}
/// May the bridge send WM_CLOSE to a Fusion window that no rule recognises? Only when it is
/// plainly a DIALOG. Fusion keeps hidden and visible top-level Qt windows that are not dialogs
/// (EAGLE's own schematic and board editor windows, detached document frames), and WM_CLOSE on
/// one of those closes documents and can exit Fusion: that is what happened on ConfRoomROG on
/// 2026-09-22 (a window titled "Fusion360" got WM_CLOSE, Fusion closed every document and ran
/// ExitApplicationCommand, the daily run went red, page issue #97) and on 2026-09-03. A window
/// that fails a test is left open and reported with its text, so the caller decides.
pub fn unknown_window_is_closable(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, dlg: &Value, rows: &[Value]) -> (bool, String) {
let title = dlg["title"].as_str().unwrap_or("");
if title.contains("EAGLE") { return (false, "an EAGLE editor window (closing it closes a document)".into()); }
let r = fusion_core::dialogs::rect_of(&dlg["rect"]);
let (w, h) = (r["width"].as_i64().unwrap_or(0), r["height"].as_i64().unwrap_or(0));
let main = rows.iter().filter(|x| x["visible"].as_bool() != Some(false) && x["title"].as_str().map(|t| t.contains("Autodesk Fusion")).unwrap_or(false))
.map(|x| fusion_core::dialogs::rect_of(&x["rect"])).max_by_key(|m| m["width"].as_i64().unwrap_or(0) * m["height"].as_i64().unwrap_or(0));
if let Some(m) = main {
let (mw, mh) = (m["width"].as_i64().unwrap_or(0), m["height"].as_i64().unwrap_or(0));
if mw > 0 && mh > 0 && w * 10 >= mw * 8 && h * 10 >= mh * 8 { return (false, format!("window-sized ({w}x{h} against the main window's {mw}x{mh}): a main or document window, not a dialog")); }
}
let Some(hwnd) = dlg["hwnd"].as_i64() else { return (false, "no window handle".into()) };
let buttons: Vec<String> = controls_cached(ab, caller, hwnd).iter()
.filter(|c| c["controlType"].as_str().or(c["type"].as_str()).or(c["role"].as_str()).unwrap_or("").to_lowercase().contains("button"))
.filter_map(|c| c["name"].as_str().map(|n| n.trim().to_lowercase())).collect();
const DISMISS: &[&str] = &["ok", "cancel", "close", "no", "dismiss", "not now", "later", "skip", "got it", "continue"];
if buttons.iter().any(|b| DISMISS.iter().any(|d| b == d || b.starts_with(&format!("{d} ")))) { (true, String::new()) }
else { (false, format!("no Cancel/Close/OK-style button (buttons: {})", if buttons.is_empty() { "none readable".to_string() } else { buttons.join(", ") })) }
}
/// `classify_blocking_dialogs`: Fusion's Qt dialogs (from ab's window rows), classified by
/// title and, for a generic "Fusion" title, by the body text UIA exposes.
pub fn classify_blocking_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Vec<Value> {
let mut args = json!({});
if !fusion_pids.is_empty() { args["pids"] = json!(fusion_pids); }
let rows = match ab.call("desktop_enum_windows", args, caller, 10) { Ok(v) => v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default(), Err(_) => return vec![] };
let mut out = Vec::new();
for dlg in fusion_core::dialogs::qt_dialog_rows(&rows) {
let title = dlg["title"].as_str().unwrap_or("").to_string();
let mut classified = fusion_core::dialogs::category_for_title(&title);
let mut matched_on: Option<&str> = None;
if classified.as_ref().map(|(c, _)| c == "generic_modal_read_screenshot" || c == "unknown").unwrap_or(true) {
if let Some(h) = dlg["hwnd"].as_i64() {
let body: String = controls_cached(ab, caller, h).iter().filter_map(|c| c["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Vec<_>>().join(" ").chars().take(400).collect();
if body.contains("ToolbarFramework") || body.contains("QTView") { continue; } // a Fusion panel (a floating BROWSER titled "Fusion360"), not a dialog
if !body.is_empty() { if let Some(bt) = fusion_core::dialogs::category_for_title(&body) { classified = Some(bt); matched_on = Some("bodyText"); } }
}
}
let Some((category, resolution)) = classified else { continue };
let mut e = json!({"hwnd": dlg["hwnd"], "title": title, "category": category, "resolution": resolution});
if let Some(m) = matched_on { e["matchedOn"] = json!(m); }
if category == "unknown" || category == "generic_modal_read_screenshot" {
let (ok, why) = unknown_window_is_closable(ab, caller, &dlg, &rows);
e["closable"] = json!(ok);
if !ok { e["notClosableWhy"] = json!(why); }
}
out.push(e);
}
out
}
/// While a verb that ADDs from a library runs: Fusion's Select Library picker, which it opens when two
/// libraries in use share a name. Its in-use list survives a restart, so the bridge can't always see this
/// coming, and answering it doesn't help: picking a row and OK closes it, but that ADD lands nothing and the
/// next one asks again (live 2026-11-03). So it is closed, and the verb says why, naming both files.
pub struct PickerWatch { stop: std::sync::Arc<std::sync::atomic::AtomicBool>, found: std::sync::Arc<std::sync::Mutex<Option<Value>>> }
pub fn watch_for_library_picker(caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Option<PickerWatch> {
let ab = fusion_core::ab::Loopback::from_env()?;
let (stop, found) = (std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), std::sync::Arc::new(std::sync::Mutex::new(None)));
let (stop2, found2, caller, pids) = (stop.clone(), found.clone(), caller.clone(), fusion_pids.to_vec());
std::thread::spawn(move || {
use fusion_core::ab::AbClient;
while !stop2.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_secs(2));
let mut args = json!({});
if !pids.is_empty() { args["pids"] = json!(pids); }
let Ok(rows) = ab.call("desktop_enum_windows", args, &caller, 10) else { continue };
let rows = rows["windows"].as_array().or_else(|| rows["data"]["windows"].as_array()).cloned().unwrap_or_default();
let Some(dlg) = fusion_core::dialogs::qt_dialog_rows(&rows).into_iter().find(|d| d["title"].as_str().map(|t| t.contains("Select Library")).unwrap_or(false)) else { continue };
let Some(h) = dlg["hwnd"].as_i64() else { continue };
let body = controls_cached(&ab, &caller, h).iter().filter_map(|c| c["name"].as_str().map(str::to_string)).collect::<Vec<_>>().join(" ");
let (gone, how) = close_native_dialog(&ab, &caller, h);
*found2.lock().unwrap() = Some(json!({"paths": library_picker_paths(&body), "closed": gone, "how": how}));
break;
}
});
Some(PickerWatch { stop, found })
}
/// The files the picker lists: each row reads "name (path)".
pub fn library_picker_paths(body: &str) -> Vec<String> {
// a file row names its .lbr, a cloud copy's row its place in the hub: either has a path separator or a colon
let mut out: Vec<String> = regex::Regex::new(r"\(([^()]*[/\\:][^()]*)\)").unwrap().captures_iter(body).map(|c| c[1].to_string()).collect();
out.dedup();
out
}
/// A verb's reply when the picker came up during it: what ran stays reported, the reason replaces Fusion's assertion.
pub fn finish_picker_watch(watch: Option<PickerWatch>, mut r: Value) -> Value {
let Some(w) = watch else { return r };
w.stop.store(true, std::sync::atomic::Ordering::SeqCst);
let Some(found) = w.found.lock().unwrap().clone() else { return r };
let paths: Vec<String> = found["paths"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
r["success"] = json!(false);
r["errorCode"] = json!("library_name_in_use");
r["librariesInUse"] = json!(paths);
r["pickerClosed"] = found["closed"].clone();
r["error"] = json!(format!("Fusion has two libraries with one name in use{} and opened its Select Library picker. The bridge closed it, and that part didn't land.{}", if paths.is_empty() { String::new() } else { format!(" ({})", paths.join(" and ")) }, if found["closed"] == true { "" } else { " The picker did not close, so Fusion is still blocked." }));
r["_hint"] = json!("Give this file another name, or take the other file away and restart Fusion (fusion_restart_for_addin). A restart alone doesn't clear it: Fusion keeps its in-use list until the file is gone.");
r
}
/// While a script runs: Fusion's "board file has errors and will be opened with warnings" notice. A script
/// that opens such a design doesn't return until it's answered, and a call between machines gives up at 120 s
/// with nothing to say why (AI Flow, 2026-11-03). OK is its only answer, so the bridge gives it. A script
/// shorter than the first look costs nothing.
pub struct NoticeWatch { stop: std::sync::Arc<std::sync::atomic::AtomicBool>, answered: std::sync::Arc<std::sync::Mutex<Vec<Value>>> }
pub fn watch_for_open_notice(caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Option<NoticeWatch> {
let ab = fusion_core::ab::Loopback::from_env()?;
let (stop, answered) = (std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), std::sync::Arc::new(std::sync::Mutex::new(Vec::new())));
let (stop2, answered2, caller, pids) = (stop.clone(), answered.clone(), caller.clone(), fusion_pids.to_vec());
std::thread::spawn(move || {
use fusion_core::ab::AbClient;
use std::sync::atomic::Ordering;
let pause = |secs: u64| -> bool { for _ in 0..(secs * 2) { if stop2.load(Ordering::SeqCst) { return false; } std::thread::sleep(std::time::Duration::from_millis(500)); } true };
if !pause(6) { return; }
loop {
for d in classify_blocking_dialogs(&ab, &caller, &pids) {
if d["category"] != "board_opened_with_warnings" { continue; }
if let Some(h) = d["hwnd"].as_i64() {
let clicked = ab.call("desktop_ui_click", json!({"hwnd": h, "name": "OK", "role": "Button"}), &caller, 15).map(|v| v["success"].as_bool() != Some(false)).unwrap_or(false);
answered2.lock().unwrap().push(json!({"title": d["title"], "answeredWith": "OK", "clicked": clicked}));
}
}
if !pause(3) { return; }
}
});
Some(NoticeWatch { stop, answered })
}
pub fn finish_notice_watch(watch: Option<NoticeWatch>, mut r: Value) -> Value {
let Some(w) = watch else { return r };
w.stop.store(true, std::sync::atomic::Ordering::SeqCst);
let answered = w.answered.lock().unwrap().clone();
if !answered.is_empty() && r.is_object() {
r["noticesAnswered"] = json!(answered);
r["_hintNotice"] = json!("Fusion opened a board file with errors and said so; the bridge answered its notice with OK. Read the board before trusting it: fusion_routing_state lists any invalidViaNets, and fusion_set_layer_count at the same count repairs them.");
}
r
}
/// Fusion's "<file> has errors and cannot be opened" modal, by its text: (hwnd, what it says).
pub fn file_refused_dialog(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Option<(i64, String)> {
let mut args = json!({});
if !fusion_pids.is_empty() { args["pids"] = json!(fusion_pids); }
let rows = ab.call("desktop_enum_windows", args, caller, 10).ok()?;
let rows = rows["windows"].as_array().or_else(|| rows["data"]["windows"].as_array()).cloned().unwrap_or_default();
for dlg in fusion_core::dialogs::qt_dialog_rows(&rows) {
let Some(h) = dlg["hwnd"].as_i64() else { continue };
let body: String = controls_cached(ab, caller, h).iter().filter_map(|c| c["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Vec<_>>().join(" ");
if body.contains("has errors and cannot be opened") { return Some((h, body.chars().take(300).collect())); }
}
None
}
/// Is this window still on screen (a visible top-level window with that hwnd)?
fn window_present(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> bool {
ab.call("desktop_list_windows", json!({}), caller, 10).ok()
.and_then(|v| v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned())
.map(|ws| ws.iter().any(|w| w["hwnd"].as_i64() == Some(hwnd) && w["visible"].as_bool() != Some(false)))
.unwrap_or(false)
}
/// Close a native Windows file dialog (class #32770: Open, Save As) and PROVE it is gone (issue #133):
/// its Cancel button first (a common dialog always honours IDCANCEL, in the background by UI
/// Automation), then WM_CLOSE. Returns (closed, how).
pub fn close_native_dialog(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> (bool, &'static str) {
let gone_within = |secs: u64| -> bool { for _ in 0..(secs * 2) { std::thread::sleep(std::time::Duration::from_millis(500)); if !window_present(ab, caller, hwnd) { return true; } } false };
if !window_present(ab, caller, hwnd) { return (true, "already gone"); }
let _ = ab.call("desktop_ui_click", json!({"hwnd": hwnd, "name": "Cancel", "role": "Button"}), caller, 15);
if gone_within(4) { return (true, "Cancel button"); }
let _ = ab.call("desktop_close_window", json!({"hwnd": hwnd}), caller, 15);
if gone_within(4) { return (true, "WM_CLOSE"); }
(false, "still open after Cancel and WM_CLOSE")
}
/// Native file dialogs (class #32770) open on Fusion's processes: an import that failed can leave its
/// Open dialog, which blocks Fusion's main thread like any modal (issue #133).
pub fn native_file_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Vec<Value> {
let mut args = json!({});
if !fusion_pids.is_empty() { args["pids"] = json!(fusion_pids); }
ab.call("desktop_enum_windows", args, caller, 10).ok()
.map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default()).unwrap_or_default()
.into_iter().filter(|w| w["className"].as_str().or(w["class"].as_str()).map(|c| c.contains("#32770")).unwrap_or(false) && w["visible"].as_bool() != Some(false))
.collect()
}
/// `fusion_dismiss_blocking_dialogs`: answer every blocking dialog SAFELY in the background
/// (WM_CLOSE = No/Cancel, never Yes), reporting what each one was.
pub fn dismiss_blocking_dialogs(state_dir: &Path, args: &Value, caller: &fusion_core::ab::Caller, fusion_pids: &[u32], taskbar: &dyn Fn(Option<f64>)) -> Value {
let Some(ab) = fusion_core::ab::Loopback::from_env() else {
return json!({"success": false, "errorCode": "ab_loopback_unavailable", "error": "Cannot see Fusion's dialogs: the ab loopback API is not available to this bridge.", "_hint": "The exe was not spawned by ab (ADOM_DIRECT_API_URL unset). Let ab spawn it."});
};
use fusion_core::ab::AbClient;
let mut classified = classify_blocking_dialogs(&ab, caller, fusion_pids);
let want: Vec<String> = args["categories"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())).collect()).unwrap_or_default();
if !want.is_empty() { classified.retain(|d| d["category"].as_str().map(|c| want.iter().any(|w| w == c)).unwrap_or(false)); }
// native Open/Save dialogs are not Qt dialogs, but they block Fusion all the same: fusion_check_dialogs
// lists them, so this answers them too, with Cancel, and proves each is gone (issue #133)
let mut native_answered = Vec::new();
if want.is_empty() || want.iter().any(|w| w == "file_dialog") {
// a cancelled import opens its next Open dialog (the .brd after the .sch) at once: scan again
// until none is left, a few rounds at most
for round in 0..3 {
let found = native_file_dialogs(&ab, caller, fusion_pids);
if found.is_empty() { break; }
if round > 0 { std::thread::sleep(std::time::Duration::from_millis(500)); }
for w in found {
let Some(h) = w["hwnd"].as_i64() else { continue };
let (closed, how) = close_native_dialog(&ab, caller, h);
crate::fusion_prefs::action_ledger(state_dir, "dialog_dismissed", json!({"title": w["title"], "category": "file_dialog", "hwnd": h, "closed": closed, "how": how}));
native_answered.push(json!({"title": w["title"], "category": "file_dialog", "answeredWith": format!("Cancel ({how})"), "closed": closed, "hwnd": h}));
}
std::thread::sleep(std::time::Duration::from_millis(1500));
}
}
if classified.is_empty() && !native_answered.is_empty() {
let all_closed = native_answered.iter().all(|d| d["closed"] == true);
return json!({"success": all_closed, "count": native_answered.len(), "dismissed": native_answered,
"_hint": if all_closed { "Closed the leftover native Open/Save dialog(s) with Cancel; nothing was opened or saved." } else { "A native Open/Save dialog did not close with Cancel or WM_CLOSE: it is still on screen (dismissed[].hwnd)." }});
}
if classified.is_empty() {
return json!({"success": true, "dismissed": [], "count": 0, "_hint": if want.is_empty() { "No blocking Fusion dialog is open." } else { "No blocking dialog in the requested categories." }});
}
let mut answered = Vec::new();
let mut upload_active = false;
let mut left_open = Vec::new();
for d in &classified {
let cat = d["category"].as_str().unwrap_or("");
if d["closable"].as_bool() == Some(false) {
eprintln!("[fusion-bridge] dismiss_blocking_dialogs: LEFT OPEN {} {:?}: {}", d["hwnd"], d["title"].as_str().unwrap_or(""), d["notClosableWhy"].as_str().unwrap_or(""));
crate::fusion_prefs::action_ledger(state_dir, "dialog_left_open", json!({"title": d["title"], "category": cat, "hwnd": d["hwnd"], "why": d["notClosableWhy"]}));
left_open.push(json!({"title": d["title"], "hwnd": d["hwnd"], "why": d["notClosableWhy"]}));
continue;
}
let mut safe = "No/Cancel (WM_CLOSE, background)";
if cat == "upload_in_progress" { upload_active = true; safe = "No - declined close, Hub upload left to drain (NEVER Yes)"; }
// closed means it is gone from the screen, not that the close was accepted: this notice ignores
// WM_CLOSE, and the verb used to report it closed while it was still up
let mut click_error = Value::Null;
if cat == "board_opened_with_warnings" {
safe = "OK (the only answer: it's a notice)";
if let Some(h) = d["hwnd"].as_i64() {
match ab.call("desktop_ui_click", json!({"hwnd": h, "name": "OK", "role": "Button"}), caller, 15) {
Ok(v) if v["success"].as_bool() == Some(false) => { click_error = json!(v["errorCode"].as_str().or(v["error"].as_str()).unwrap_or("refused")); }
Err(e) => { click_error = json!(format!("{e}")); }
_ => {}
}
}
}
let gone_within = |h: i64, secs: u64| -> bool { for _ in 0..(secs * 2) { std::thread::sleep(std::time::Duration::from_millis(500)); if !window_present(&ab, caller, h) { return true; } } false };
let ok = d["hwnd"].as_i64().map(|h| {
if cat == "board_opened_with_warnings" && gone_within(h, 3) { return true; }
let _ = ab.call("desktop_close_window", json!({"hwnd": h}), caller, 15);
gone_within(h, 3)
}).unwrap_or(false);
// what was closed is evidence (2026-09-03: a post-drill dismiss closed an 'unknown' window
// and the add-in went silent; nothing had recorded which window it was)
eprintln!("[fusion-bridge] dismiss_blocking_dialogs: {} {:?} category={cat} closed={ok}", d["hwnd"], d["title"].as_str().unwrap_or(""));
crate::fusion_prefs::action_ledger(state_dir, "dialog_dismissed", json!({"title": d["title"], "category": cat, "hwnd": d["hwnd"], "closed": ok, "how": "WM_CLOSE"}));
answered.push(json!({"title": d["title"], "category": cat, "answeredWith": safe, "closed": ok, "clickError": click_error, "resolution": d["resolution"]}));
}
let mut hint = format!("Answered {} blocking dialog(s) in the background (No/Cancel - never Yes, never focus-stolen).", answered.len());
if upload_active { hint.push_str(" One was a Hub-UPLOAD 'lose these changes?' prompt: it was declined (No), but the upload is STILL in flight - poll app.hasActiveJobs (fusion_state) until it drains before any close/saveAs, or you WILL lose the 3D package."); }
taskbar(if upload_active { Some(1.0) } else { None });
if !left_open.is_empty() { hint.push_str(&format!(" LEFT OPEN {} window(s) no rule knows that do not look like a dialog (see leftOpen[].why): WM_CLOSE on a main, document or EAGLE editor window closes documents and can exit Fusion. fusion_check_dialogs shows their text; decide from that.", left_open.len())); }
answered.extend(native_answered);
let still_up: Vec<Value> = answered.iter().filter(|d| d["closed"] != true).map(|d| d["title"].clone()).collect();
if !still_up.is_empty() { hint.push_str(&format!(" STILL UP: {} did not close (closed:false above), so Fusion is still blocked.", still_up.len())); }
json!({"success": still_up.is_empty(), "count": answered.len(), "dismissed": answered, "stillUp": still_up, "leftOpen": left_open, "uploadStillActive": upload_active, "_hint": hint})
}
fn screenshot_hwnd(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> Option<String> {
use serde_json::json as j;
ab.call("desktop_screenshot_window", j!({"hwnd": hwnd}), caller, 40).ok().and_then(|r| r["fullPath"].as_str().or(r["safePath"].as_str()).map(|s| s.replace('\\', "/")))
}
/// `_capture_dialog_array`: after a mutating op, every Fusion dialog with a screenshot, or None.
pub fn capture_dialog_array(caller: &fusion_core::ab::Caller, fusion_pids: &[u32], settle_ms: u64) -> Option<Value> {
let ab = fusion_core::ab::Loopback::from_env()?;
use fusion_core::ab::AbClient;
if settle_ms > 0 { std::thread::sleep(std::time::Duration::from_millis(settle_ms)); }
let mut args = json!({});
if !fusion_pids.is_empty() { args["pids"] = json!(fusion_pids); }
let rows = ab.call("desktop_enum_windows", args, caller, 10).ok().map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default())?;
let raw = fusion_core::dialogs::qt_dialog_rows(&rows);
if raw.is_empty() { return None; }
// ab's rows do not carry IsWindowEnabled for the main window; Python treats an unreadable
// state as enabled, which is the conservative branch (generic docked panels are suppressed)
let main_enabled = true;
let classified: std::collections::HashMap<i64, Value> = classify_blocking_dialogs(&ab, caller, fusion_pids).into_iter().filter_map(|c| c["hwnd"].as_i64().map(|h| (h, c))).collect();
let mut out = Vec::new();
for d in raw {
let (w, h) = (d["rect"]["width"].as_i64().unwrap_or(0), d["rect"]["height"].as_i64().unwrap_or(0));
if w < 80 || h < 50 { continue; }
let hwnd = d["hwnd"].as_i64().unwrap_or(0);
let title = d["title"].as_str().unwrap_or("").to_string();
let c = classified.get(&hwnd);
let cat = c.and_then(|c| c["category"].as_str());
if main_enabled && matches!(cat, None | Some("generic_modal_read_screenshot") | Some("unknown")) { continue; }
let mut entry = json!({"hwnd": hwnd, "title": title});
if let Some(cat) = cat { entry["category"] = json!(cat); entry["resolution"] = c.map(|c| c["resolution"].clone()).unwrap_or(Value::Null); }
if let Some(p) = screenshot_hwnd(&ab, caller, hwnd) { entry["screenshot"] = json!(p); }
out.push(entry);
}
if out.is_empty() { return None; }
let n = out.len();
Some(json!({"dialogsDetected": n, "dialogs": out, "mainWindowEnabled": main_enabled,
"_hint": format!("⚠️ {n} Fusion dialog(s)/owned popup(s) are up after this operation. STOP and ANALYZE before the next call: READ each dialogs[].screenshot and use its category/resolution. Do NOT blind-dismiss - some confirms LOSE work (clicking Yes on a Hub-upload 'are you sure you want to close?' destroys the uploaded packages). Take the work-preserving action (often: click No, then wait for the upload to drain). See the fusion-driving skill.")}))
}
/// `_apply_failure_dialogs`: an Error dialog after an op means the op FAILED, whatever the API said.
pub fn apply_failure_dialogs(mut result: Value, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Value {
let Some(ab) = fusion_core::ab::Loopback::from_env() else { return result };
use fusion_core::ab::AbClient;
let failure_cats = fusion_core::dialogs::category_set("FAILURE");
let failures: Vec<Value> = classify_blocking_dialogs(&ab, caller, fusion_pids).into_iter().filter(|d| d["category"].as_str().map(|c| failure_cats.iter().any(|f| f == c)).unwrap_or(false)).collect();
if failures.is_empty() || !result.is_object() { return result; }
let f = &failures[0];
let shots: Vec<String> = failures.iter().filter_map(|d| d["hwnd"].as_i64().and_then(|h| screenshot_hwnd(&ab, caller, h))).collect();
let prev = result["error"].as_str().unwrap_or("").to_string();
result["success"] = json!(false);
result["errorCode"] = json!("fusion_operation_error");
result["error"] = json!(format!("Operation FAILED - Fusion '{}' dialog is up{}", f["title"].as_str().filter(|t| !t.is_empty()).unwrap_or("Error"), if prev.is_empty() { String::new() } else { format!(" (prior: {prev})") }));
result["_hint"] = json!(f["resolution"].as_str().filter(|r| !r.is_empty()).unwrap_or("Fusion shows an Error dialog; the operation failed. Read the screenshot."));
if !result["data"].is_object() { result["data"] = json!({}); }
result["data"]["errorDialog"] = json!({"title": f["title"], "category": f["category"], "resolution": f["resolution"], "screenshots": shots});
for d in &failures { if let Some(h) = d["hwnd"].as_i64() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), caller, 15); } }
result
}
/// `_merge_dialog_array`: the dialog array onto a mutating verb's result, then the failure guard.
pub fn merge_dialog_array(mut result: Value, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Value {
if !result.is_object() { return result; }
let Some(arr) = capture_dialog_array(caller, fusion_pids, 600) else { return result };
if !result["data"].is_object() { result["data"] = json!({}); }
result["data"]["dialogArray"] = arr.clone();
let existing = result["_hint"].as_str().map(str::to_string);
result["_hint"] = json!(format!("{}{}", arr["_hint"].as_str().unwrap_or(""), existing.map(|e| format!(" | {e}")).unwrap_or_default()));
apply_failure_dialogs(result, caller, fusion_pids)
}
#[cfg(test)]
mod closable_tests {
use super::*;
struct FakeAb(Value);
impl fusion_core::ab::AbClient for FakeAb {
fn call(&self, _verb: &str, _args: Value, _caller: &fusion_core::ab::Caller, _t: u64) -> Result<Value, fusion_core::ab::AbError> { Ok(self.0.clone()) }
}
fn main_row() -> Value { json!({"hwnd": 1, "title": "BQ25792 - Autodesk Fusion ", "className": "Qt683QWindowIcon", "visible": true, "rect": [0, 0, 2400, 1270]}) }
fn buttons(names: &[&str]) -> FakeAb { FakeAb(json!({"controls": names.iter().map(|n| json!({"name": n, "controlType": "Button"})).collect::<Vec<_>>()})) }
#[test]
fn a_small_dialog_with_cancel_may_be_closed() {
let dlg = json!({"hwnd": 2, "title": "Fusion360", "rect": {"left": 800, "top": 400, "width": 520, "height": 240}});
let (ok, why) = unknown_window_is_closable(&buttons(&["OK", "Cancel"]), &fusion_core::ab::Caller::default(), &dlg, &[main_row()]);
assert!(ok, "{why}");
}
#[test]
fn a_window_sized_frame_is_never_closed() {
// 2026-09-22 (#97): WM_CLOSE on a window like this made Fusion close every document and exit
let dlg = json!({"hwnd": 3, "title": "Fusion360", "rect": {"left": 0, "top": 0, "width": 2300, "height": 1200}});
let (ok, why) = unknown_window_is_closable(&buttons(&["Close"]), &fusion_core::ab::Caller::default(), &dlg, &[main_row()]);
assert!(!ok); assert!(why.contains("window-sized"), "{why}");
}
#[test]
fn an_eagle_editor_window_is_never_closed() {
let dlg = json!({"hwnd": 4, "title": "4 Board - C:/x/BQ25792.brd - EAGLE 9.7.0 premium", "rect": {"left": 700, "top": 180, "width": 900, "height": 600}});
let (ok, _) = unknown_window_is_closable(&buttons(&["OK"]), &fusion_core::ab::Caller::default(), &dlg, &[main_row()]);
assert!(!ok);
}
#[test]
fn a_window_with_no_dismiss_button_is_left_open() {
let dlg = json!({"hwnd": 5, "title": "Fusion360", "rect": {"left": 800, "top": 400, "width": 600, "height": 400}});
let (ok, why) = unknown_window_is_closable(&buttons(&["Save", "Export"]), &fusion_core::ab::Caller::default(), &dlg, &[main_row()]);
assert!(!ok); assert!(why.contains("no Cancel/Close/OK"), "{why}");
}
}
#[cfg(test)]
mod image_delivery_tests {
use super::*;
#[test]
fn old_path_keys_gain_full_path_and_screenshots() {
let mut r = json!({"success": true, "path": "C:\\tmp\\conduit-screenshots\\viewport.png", "data": {"postOpenScreenshot": {"screenshots": [{"path": "C:/tmp/b.png"}]}}});
add_image_delivery(&mut r);
assert_eq!(r["fullPath"], "C:/tmp/conduit-screenshots/viewport.png");
assert_eq!(r["screenshots"].as_array().unwrap().len(), 2);
assert_eq!(r["path"], "C:\\tmp\\conduit-screenshots\\viewport.png", "old key kept");
}
#[test]
fn a_reply_that_already_has_full_path_is_left_alone() {
let mut r = json!({"fullPath": "C:/x/a.png", "screenshots": [{"fullPath": "C:/x/a.png"}]});
let before = r.clone(); add_image_delivery(&mut r); assert_eq!(r, before);
}
#[test]
fn replies_without_images_are_untouched() {
let mut r = json!({"success": true, "outputPath": "C:/tmp/board.sch"});
let before = r.clone(); add_image_delivery(&mut r); assert_eq!(r, before);
}
}
#[cfg(test)] mod picker_tests {
use super::*;
fn watch(found: Option<Value>) -> Option<PickerWatch> { Some(PickerWatch { stop: Default::default(), found: std::sync::Arc::new(std::sync::Mutex::new(found)) }) }
#[test] fn the_picker_rows_give_both_paths() {
let body = "System There are multiple libraries named lane!\nPick one. lane (C:/a/lane.lbr) lane (C:/b/lane.lbr)";
assert_eq!(library_picker_paths(body), vec!["C:/a/lane.lbr".to_string(), "C:/b/lane.lbr".to_string()]);
}
#[test] fn a_cloud_copys_row_counts_and_plain_words_dont() {
assert_eq!(library_picker_paths("Pick one. TI772 (C:/x/TI772.lbr) TI772 (urn:adsk.wipprod:dm.lineage:AB) (not this)"), vec!["C:/x/TI772.lbr".to_string(), "urn:adsk.wipprod:dm.lineage:AB".to_string()]);
let r = finish_picker_watch(watch(Some(json!({"paths": [], "closed": true}))), json!({"success": false}));
assert!(!r["error"].as_str().unwrap().contains("()"));
}
#[test] fn a_picker_turns_the_reply_into_the_reason() {
let r = finish_picker_watch(watch(Some(json!({"paths": ["C:/a/l.lbr", "C:/b/l.lbr"], "closed": true}))), json!({"success": false, "errorCode": "fusion_assert", "commands": 3}));
assert_eq!(r["errorCode"], "library_name_in_use");
assert_eq!(r["commands"], 3);
assert_eq!(r["pickerClosed"], true);
assert!(r["error"].as_str().unwrap().contains("C:/a/l.lbr and C:/b/l.lbr"));
}
#[test] fn an_answered_notice_is_said_in_the_reply() {
let w = Some(NoticeWatch { stop: Default::default(), answered: std::sync::Arc::new(std::sync::Mutex::new(vec![json!({"answeredWith": "OK"})])) });
let r = finish_notice_watch(w, json!({"success": true}));
assert_eq!(r["noticesAnswered"][0]["answeredWith"], "OK");
assert!(finish_notice_watch(None, json!({"success": true}))["noticesAnswered"].is_null());
}
#[test] fn no_picker_leaves_the_reply_alone() {
assert_eq!(finish_picker_watch(watch(None), json!({"success": true}))["success"], true);
assert_eq!(finish_picker_watch(None, json!({"success": true}))["success"], true);
}
}