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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
//! The readiness executors, native: the seat resolver, the recovery dismissal and the hang
//! verdict. Ported from server.py (`_resolve_seat_dialog`, `_confirmed_hung`), handlers/
//! dismiss_recovery.py and handlers/fusion_ui.py (`is_fusion_hung`). UIA and window closes go
//! through ab; the two Win32 reads live in os-win behind HostProcess.
use serde_json::{json, Value};
use std::time::{Duration, Instant};
use crate::addin::Ctx;
use fusion_core::ab::{AbClient, Loopback};
fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }
/// `_owned_popup_count`: ab's screenshot signal, -1 when unavailable.
fn owned_popup_count(ab: &dyn AbClient, ctx: &Ctx, main_hwnd: i64) -> i64 {
ab.call("desktop_screenshot_window", json!({"hwnd": main_hwnd}), &ctx.caller, 8).ok().and_then(|r| r["ownedPopupCount"].as_i64().or(r["data"]["ownedPopupCount"].as_i64())).unwrap_or(-1)
}
/// `_resolve_seat_dialog`: Continue (or Check again) through UIA in the background, verified by
/// the parent's owned-popup count. {sawDialog, clicks, verified}.
pub fn resolve_seat_dialog(ctx: &Ctx, max_clicks: u32) -> Value {
let (mut clicks, mut saw, mut verified, mut needs_approval) = (0u32, false, false, false);
let Some(ab) = Loopback::from_env() else { return json!({"sawDialog": false, "clicks": 0, "verified": false}) };
for _ in 0..max_clicks {
let facts = fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller);
let Some(dlg) = facts.licensing_dialog else { verified = true; break };
saw = true;
let hwnd = dlg["hwnd"].as_i64().unwrap_or(0);
let parent = dlg["owner"].as_i64().unwrap_or(0);
let mut landed = false;
if hwnd != 0 {
for btn in ["Continue", "Check again"] {
match ab.call("desktop_ui_click", json!({"hwnd": hwnd, "name": btn}), &ctx.caller, 4) {
Ok(r) => {
if r["success"].as_bool() == Some(true) || r["status"].as_str() == Some("ok") {
if btn == "Check again" { eprintln!("[fusion-bridge] seat: clicked 'Check again' to REGRAB the suspended session (Session Suspended variant)"); }
landed = true; break;
}
if r["errorCode"].as_str() == Some("needs_approval") || r["data"]["errorCode"].as_str() == Some("needs_approval") { needs_approval = true; }
}
Err(e) => { if format!("{e}").contains("needs_approval") { needs_approval = true; } }
}
}
}
if landed { clicks += 1; }
std::thread::sleep(Duration::from_millis(1500));
if parent != 0 && owned_popup_count(&ab, ctx, parent) == 0 { verified = true; break; }
}
// ab gates UI clicks behind its shell approval, even for a bridge (#133, #136: with approval off the
// click was refused and the run sat at the seat dialog with nothing said). Raise the prompt, at most
// every five minutes; once it is approved the next pass clicks through.
if needs_approval {
static ASKED: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
let due = { let mut a = ASKED.lock().unwrap(); let due = a.map(|t| t.elapsed() > Duration::from_secs(300)).unwrap_or(true); if due { *a = Some(std::time::Instant::now()); } due };
if due { let _ = ab.call("request_shell_approval", json!({"reason": "Answer Autodesk's Active Sessions Exceeded dialog so this PC takes the Fusion seat"}), &ctx.caller, 10); }
}
json!({"sawDialog": saw, "clicks": clicks, "verified": verified, "needsApproval": needs_approval})
}
/// The verb-shaped answer (`fusion_resolve_seat_dialog`).
pub fn resolve_seat_dialog_verb(ctx: &Ctx, args: &Value) -> Value {
let r = resolve_seat_dialog(ctx, args["maxClicks"].as_u64().unwrap_or(3).max(1) as u32);
let still = Loopback::from_env().map(|ab| fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller).licensing_dialog.is_some()).unwrap_or(false);
if still && r["needsApproval"].as_bool() == Some(true) {
return json!({"success": false, "errorCode": "needs_approval", "clicks": r["clicks"], "resolved": false, "detail": r,
"error": "Autodesk's seat dialog is up, and Adom Bridge needs approval before the bridge can answer it. The prompt has been raised.",
"tellUser": "Adom Bridge needs your OK to answer Autodesk's Active Sessions Exceeded dialog: click Approve (1 hr, 24 hr or Auto) in the Adom Bridge window, on its notification, or in Hydrogen.",
"_hint": "Once approved, call this again (or fusion_claim_seat). Continue makes this PC the active seat; Fusion on the other machine suspends and keeps its documents."});
}
json!({"success": true, "clicks": r["clicks"], "resolved": !still, "detail": r, "_hint": "Readiness executor: the seat dialog is clicked Continue (Suspend pre-selected) and verified gone by screenshot; poll fusion_readiness."})
}
/// `relocate_recovery_files`: move CrashRecovery contents to ~/.adom/recovery/fusion/<stamp>/.
pub fn relocate_recovery_files() -> Value {
let base = std::path::Path::new(&std::env::var("LOCALAPPDATA").unwrap_or_default()).join("Autodesk").join("Autodesk Fusion 360");
let Ok(entries) = std::fs::read_dir(&base) else { return json!({"moved": 0, "dest": Value::Null, "sources": []}) };
let cr_dirs: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path().join("CrashRecovery")).filter(|d| d.is_dir() && std::fs::read_dir(d).map(|mut r| r.next().is_some()).unwrap_or(false)).collect();
if cr_dirs.is_empty() { return json!({"moved": 0, "dest": Value::Null, "sources": []}); }
let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();
let dest = std::path::Path::new(&home).join(".adom").join("recovery").join("fusion").join(chrono::Local::now().format("%Y%m%d_%H%M%S").to_string());
let _ = std::fs::create_dir_all(&dest);
let mut moved = 0i64;
let mut sources = Vec::new();
for cr in &cr_dirs {
sources.push(cr.to_string_lossy().replace('\\', "/"));
if let Ok(items) = std::fs::read_dir(cr) {
for it in items.flatten() {
let src = it.path();
let mut dst = dest.join(it.file_name());
if dst.exists() { let stem = src.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); dst = dest.join(format!("{stem}_{moved}{ext}")); }
if std::fs::rename(&src, &dst).is_ok() { moved += 1; continue; }
let copied = if src.is_dir() { copy_dir(&src, &dst).is_ok() } else { std::fs::copy(&src, &dst).is_ok() };
if copied { let _ = if src.is_dir() { std::fs::remove_dir_all(&src) } else { std::fs::remove_file(&src) }; moved += 1; }
}
}
let _ = std::fs::remove_dir(cr); // only when empty
}
json!({"moved": moved, "dest": if moved > 0 { json!(dest.to_string_lossy().replace('\\', "/")) } else { Value::Null }, "sources": sources})
}
fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for e in std::fs::read_dir(src)? { let e = e?; let p = e.path(); let d = dst.join(e.file_name()); if p.is_dir() { copy_dir(&p, &d)?; } else { std::fs::copy(&p, &d)?; } }
Ok(())
}
fn windows(ab: &dyn AbClient, ctx: &Ctx) -> Vec<Value> {
ab.call("desktop_enum_windows", json!({}), &ctx.caller, 10).ok().map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default()).unwrap_or_default()
}
fn find_by_title(rows: &[Value], sub: &str) -> Option<i64> { rows.iter().find(|w| w["visible"].as_bool() != Some(false) && w["title"].as_str().map(|t| t.contains(sub)).unwrap_or(false)).and_then(|w| w["hwnd"].as_i64()) }
/// `_find_fusion_dialog_windows`: small Qt windows titled exactly "Fusion360" (the recovery list).
fn recovery_list_windows(rows: &[Value]) -> Vec<i64> {
rows.iter().filter(|w| {
let cls = w["className"].as_str().unwrap_or("");
if w["visible"].as_bool() == Some(false) || !cls.contains("QWindowIcon") || cls.contains("ToolSaveBits") || w["title"].as_str() != Some("Fusion360") { return false; }
let r = fusion_core::dialogs::rect_of(&w["rect"]);
r["width"].as_i64().unwrap_or(0) < 700 && r["height"].as_i64().unwrap_or(0) < 500
}).filter_map(|w| w["hwnd"].as_i64()).collect()
}
/// `dismiss_recovery_dialog`: relocate the files, then answer every recovery prompt in the
/// background (No through UIA where Python used Tab+Enter after a foreground grab; WM_CLOSE
/// for the list). True when anything was dismissed.
pub fn dismiss_recovery_dialog(ctx: &Ctx, max_wait: f64, relocate: bool) -> (bool, Value) {
let reloc = if relocate { relocate_recovery_files() } else { json!({"moved": 0, "dest": Value::Null}) };
let Some(ab) = Loopback::from_env() else { return (false, reloc) };
let deadline = Instant::now() + Duration::from_secs_f64(max_wait.max(0.5));
let mut dismissed_any = false;
while Instant::now() < deadline {
let rows = windows(&ab, ctx);
if let Some(h) = find_by_title(&rows, "Open recovery document instead?") {
if ab.call("desktop_ui_click", json!({"hwnd": h, "name": "No"}), &ctx.caller, 8).is_err() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 8); }
dismissed_any = true; std::thread::sleep(Duration::from_millis(500)); continue;
}
let lst = recovery_list_windows(&rows);
if !lst.is_empty() {
for h in &lst { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 8); }
crate::fusion_prefs::action_ledger(&ctx.state_dir, "recovery_wm_close", json!({"windows": lst}));
dismissed_any = true; std::thread::sleep(Duration::from_millis(500)); continue;
}
if let Some(h) = find_by_title(&rows, "Recovered Documents") {
if ab.call("desktop_ui_click", json!({"hwnd": h, "name": "Close"}), &ctx.caller, 8).is_err() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 8); }
dismissed_any = true; std::thread::sleep(Duration::from_millis(500)); continue;
}
if dismissed_any { std::thread::sleep(Duration::from_millis(500)); let rows = windows(&ab, ctx); if find_by_title(&rows, "Open recovery document instead?").is_none() && recovery_list_windows(&rows).is_empty() && find_by_title(&rows, "Recovered Documents").is_none() { break; } continue; }
break;
}
(dismissed_any, reloc)
}
static HUNG_SINCE: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);
/// `is_fusion_hung`: IsHungAppWindow on the main window plus a visible "Ghost" window naming Fusion.
pub fn is_fusion_hung(ctx: &Ctx) -> Value {
let mut out = json!({"hung": false, "signals": [], "hwnd": Value::Null});
if let Some(ab) = Loopback::from_env() {
let facts = fusion_core::windows_facts::facts_from_ab(&ab, &ctx.caller);
if let Some(h) = facts.main_hwnd { out["hwnd"] = json!(h); if ctx.host.window_hung(h) { out["hung"] = json!(true); out["signals"].as_array_mut().unwrap().push(json!("IsHungAppWindow")); } }
let ghosts: Vec<i64> = windows(&ab, ctx).iter().filter(|w| w["className"].as_str() == Some("Ghost") && w["visible"].as_bool() != Some(false) && w["title"].as_str().map(|t| t.contains("Fusion")).unwrap_or(false)).filter_map(|w| w["hwnd"].as_i64()).collect();
if !ghosts.is_empty() { out["hung"] = json!(true); out["signals"].as_array_mut().unwrap().push(json!("GhostWindow")); out["ghostHwnds"] = json!(ghosts); }
}
out
}
/// `fusion_using_cpu`: did the family's CPU time advance over 0.7s?
pub fn fusion_using_cpu(ctx: &Ctx) -> bool {
let t0 = ctx.host.family_cpu_ticks(); std::thread::sleep(Duration::from_millis(700)); let t1 = ctx.host.family_cpu_ticks();
t1.saturating_sub(t0) > 50_000
}
/// CPU over a sample window that says Fusion is working: at least 15% of one core across the family
/// (GetProcessTimes counts 100 ns units). A hung host's background threads stay well under it.
pub fn working_from_ticks(delta: u64, window: Duration) -> bool {
delta as f64 >= 0.15 * window.as_secs_f64() * 10_000_000.0
}
/// A Fusion still using CPU is working, whatever its window says: a script or native call that
/// outlived the request that started it burns a core on the main thread, where a real hang (a
/// deadlock, a wait on a lost modal) sits near idle.
pub fn fusion_working(ctx: &Ctx) -> bool {
let window = Duration::from_millis(1500);
let t0 = ctx.host.family_cpu_ticks(); std::thread::sleep(window); let t1 = ctx.host.family_cpu_ticks();
working_from_ticks(t1.saturating_sub(t0), window)
}
/// `_confirmed_hung`: kill-grade only when the OS says hung AND a 20s probe gets nothing AND the host
/// is idle AND all of that has persisted 180s; a long op in flight (under 30 minutes, or with CPU
/// still moving) protects.
pub fn confirmed_hung(ctx: &Ctx) -> Value {
if let Some(lo) = crate::long_op_marker() {
let el = now_f() - lo.1;
let protected = el < 1800.0 || fusion_using_cpu(ctx);
if protected { return json!({"hung": false, "phase": "long-op-in-flight", "longOp": lo.0, "longOpElapsedSec": (el * 10.0).round() / 10.0, "keptBy": if el < 1800.0 { "under-ceiling" } else { "fusion-cpu-active" }}); }
}
let h = is_fusion_hung(ctx);
if h["hung"].as_bool() != Some(true) { *HUNG_SINCE.lock().unwrap() = 0.0; return json!({"hung": false, "phase": "pumping"}); }
if crate::addin::addin_status_probe(Duration::from_secs(20)).is_some() { *HUNG_SINCE.lock().unwrap() = 0.0; return json!({"hung": false, "phase": "busy-not-hung", "signals": h["signals"]}); }
// 2026-10-30: the gate killed a Fusion 37 minutes into a long build script, after the request
// that started the script had timed out (so no long op was on record), and closed the user's
// 24 documents. A kill now also needs the host to have gone idle.
if fusion_working(ctx) { *HUNG_SINCE.lock().unwrap() = 0.0; return json!({"hung": false, "phase": "busy-not-hung", "keptBy": "fusion-cpu-active", "signals": h["signals"]}); }
let now = now_f();
let mut since = HUNG_SINCE.lock().unwrap();
if *since == 0.0 { *since = now; }
let held = now - *since;
if held < 180.0 { return json!({"hung": false, "phase": "suspect", "heldSeconds": (held * 10.0).round() / 10.0, "signals": h["signals"]}); }
json!({"hung": true, "phase": "confirmed", "heldSeconds": (held * 10.0).round() / 10.0, "signals": h["signals"]})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fusion_burning_a_core_is_working_not_hung() {
let window = Duration::from_millis(1500);
assert!(working_from_ticks(15_000_000, window)); // a whole core: a script on the main thread
assert!(working_from_ticks(2_250_000, window)); // 15%
assert!(!working_from_ticks(300_000, window)); // 2%: a hung host's background threads
assert!(!working_from_ticks(0, window));
}
}