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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
//! Phase 3: `fusion_readiness`, the launch observer, ported as a PURE computation over probed
//! inputs. The probes (Toolhelp, the add-in HTTP status, ab's window rows, APS, prefs, the
//! timings store) are gathered by the exe; this module turns them into the exact result the
//! Python handler produces, hint text included (`readiness_hints.json` is derived from the
//! Python goldens, never hand-typed). Side effects (seat auto-resolve, crash janitor, launch
//! self-heal, auto-update) are NOT here: they are returned as `Actions` for the caller to
//! run when it owns readiness, and the pref writes come back as `prefs_after`.
//!
//! AI hint: `tests/golden/readiness.json` was generated by running the Python handler with
//! every helper stubbed per scenario. A difference from it is a bug here until proven
//! otherwise; regenerate the goldens only when server.py's handler changes on purpose.
use serde_json::{json, Map, Value};
/// Python's `%r` of a str (single-quoted unless it holds a single quote and no double).
fn py_repr_str(s: &str) -> String { if s.contains('\'') && !s.contains('"') { format!("\"{s}\"") } else { format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) } }
const HINTS: &str = include_str!("readiness_hints.json");
fn hint(key: &str) -> String {
let h: Value = serde_json::from_str(HINTS).expect("readiness_hints.json");
h[key].as_str().unwrap_or("").to_string()
}
/// What the exe probed, the same facts the Python handler gathers itself.
#[derive(Debug, Clone, Default)]
pub struct Inputs {
pub installed_raw: bool,
/// installer process seen OR a launcher dir with no complete app anywhere
pub streaming: bool,
pub running: bool,
/// the add-in's /status version when it answered, None when silent
pub addin_version: Option<String>,
pub expected_addin_version: String,
pub needs_signin: bool,
pub licensing_dialog: bool,
pub updating: bool,
pub aps_configured: bool,
pub aps_signed_in: bool,
pub mcp_port_open: bool,
pub auto_update_enabled: bool,
/// `get_app_state` round-trip through the add-in succeeded (only asked when ready)
pub roundtrip_ok: bool,
pub kicad_ingest: bool,
pub fusion_user: Option<Value>,
pub modeling_orientation: Option<String>,
pub bridge_version: String,
pub prefs: Map<String, Value>,
pub now: f64,
pub now_iso: String,
/// `timings.summary()["launch"]` and `timings.stats("launch","cold")`
pub timings_launch: Value,
pub launch_stats: Value,
/// A caller-supplied progress block for tests; production builds one from timings.
pub progress_override: Option<Value>,
pub addin_start_failed: Option<Value>,
pub crash_dialogs_cleared: usize,
/// The owner ran the silent update click before computing: the button it clicked.
pub update_applied_button: Option<String>,
/// Fusion is offering an update right now (the sidecar's `_LAST_UPDATE_OFFER`).
pub update_offered: bool,
/// Why a not-ready add-in is loaded and working rather than missing (its /status says busy, or
/// it is starved silent with one of our requests in flight or after answering in this Fusion).
/// A busy add-in is not a launch: the launch observer neither starts nor heals over it.
pub addin_busy: Option<Value>,
}
/// The side effects the Python handler would have taken, for the owner to run.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
pub struct Actions {
#[serde(rename = "resolveSeatDialog")] pub resolve_seat_dialog: bool,
#[serde(rename = "applyUpdateSilently")] pub apply_update_silently: bool,
#[serde(rename = "healLaunch")] pub heal_launch: Option<u64>,
#[serde(rename = "recordLaunchSeconds")] pub record_launch_seconds: Option<f64>,
}
pub struct Outcome { pub result: Value, pub prefs_after: Map<String, Value>, pub actions: Actions }
fn pct(sorted: &[f64], q: f64) -> Option<f64> {
if sorted.is_empty() { return None; }
let i = (q * (sorted.len() as f64 - 1.0)).round_ties_even() as i64;
Some(sorted[i.clamp(0, sorted.len() as i64 - 1) as usize])
}
/// `timings.summary()` over the store's JSON ({"phase|kind": {samples, lastAt}}).
pub fn timings_summary(store: &Value) -> Value {
let mut out = Map::new();
if let Some(m) = store.as_object() {
for (k, row) in m {
let (phase, kind) = match k.find('|') { Some(i) => (&k[..i], &k[i + 1..]), None => (k.as_str(), "") };
let mut s: Vec<f64> = row["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
if s.is_empty() { continue; }
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));
entry[kind] = json!({"p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "count": s.len(), "lastAt": row["lastAt"]});
}
}
Value::Object(out)
}
/// `timings.stats(phase, kind)`.
pub fn timings_stats(store: &Value, phase: &str, kind: &str) -> Value {
let mut s: Vec<f64> = store[format!("{phase}|{kind}")]["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
json!({"p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "count": s.len()})
}
/// `timings.progress_block` for the launch phase (percent derived from elapsed / estimate).
pub fn progress_block(stats: &Value, started: f64, now: f64, label: &str, fallback: f64) -> Value {
let elapsed = (now - started).max(0.0);
let (count, p50) = (stats["count"].as_u64().unwrap_or(0), stats["p50"].as_f64());
let (est0, mut conf) = match (count, p50) { (c, Some(p)) if c >= 2 && p > 0.0 => (p, "measured"), (1, Some(p)) if p > 0.0 => (p, "typical"), _ => (fallback, "typical") };
let mut est = est0;
let overdue = est0 > 0.0 && elapsed > est0;
if overdue {
est = elapsed * 1.25;
if conf == "measured" { conf = "typical"; }
if elapsed > 2.0 * est0 { conf = "unknown"; }
}
let mut percent: i64 = if est0 <= 0.0 { 0 } else if !overdue { (80.0 * elapsed / est0) as i64 } else { let over = elapsed - est0; (80.0 + 15.0 * (over / (over + est0))) as i64 };
percent = percent.clamp(0, if est > 0.0 { 99 } else { 0 });
let eta = if est > 0.0 { Some(((est - elapsed).round() as i64).max(0)) } else { None };
json!({"phase": "launch", "stepLabel": label, "percent": percent, "elapsedSec": elapsed.round() as i64,
"estimatedSec": if est > 0.0 { json!(est.round() as i64) } else { Value::Null }, "etaSec": eta,
"confidence": if est > 0.0 { conf } else { "unknown" }, "startKind": "cold", "overdue": overdue})
}
pub fn compute(i: &Inputs) -> Outcome {
let mut prefs = i.prefs.clone();
let mut actions = Actions::default();
let (installed, installing) = if i.streaming && !i.running { (false, true) } else { (i.installed_raw, !i.installed_raw && i.streaming) };
let running = if i.installed_raw { i.running } else { false };
let addin_ok = i.addin_version.is_some();
let needs_signin = running && i.needs_signin;
let mut ready = installed && running && addin_ok && !needs_signin;
let licensing = running && i.licensing_dialog;
if licensing { actions.resolve_seat_dialog = true; ready = false; }
let updating = if installed && !ready { i.updating } else { false };
let mut stale = Map::new();
if addin_ok {
let reported = i.addin_version.clone().unwrap_or_default();
let is_stale = reported != "unknown" && i.expected_addin_version != "unknown" && reported != i.expected_addin_version;
stale.insert("addinVersion".into(), json!(reported));
stale.insert("expectedAddinVersion".into(), json!(i.expected_addin_version));
stale.insert("addinStale".into(), json!(is_stale));
if is_stale { stale.insert("_staleHint".into(), json!(hint("stale_template").replace("{reported}", &reported).replace("{expected}", &i.expected_addin_version))); }
}
let mut hint_text = if updating && !ready { hint("updating") }
else if needs_signin { hint("needs_signin") }
else if ready && stale["addinStale"] == json!(true) { stale["_staleHint"].as_str().unwrap_or("").to_string() }
else if ready {
actions.apply_update_silently = true;
let mut msg = if i.mcp_port_open { hint("mcp_on") } else { hint("mcp_off") };
if let Some(b) = &i.update_applied_button { msg.push_str(&hint("upd_applied_template").replace("{button}", &py_repr_str(b))); }
else if !i.auto_update_enabled { msg.push_str(&hint("upd_off")); }
let base = if i.aps_signed_in { hint("ready_signed") } else if i.aps_configured { hint("ready_configured") } else { hint("ready_unconfigured") };
base + &msg
}
else if installed && running && licensing { hint("licensing") }
else if installed && running { hint("addin_silent") }
else if installed { hint("installed_not_running") }
else if installing { hint("installing") } else { hint("not_installed") };
let mut note: Option<String> = None;
if ready && !i.roundtrip_ok { ready = false; note = Some(hint("roundtrip_note")); }
if let Some(n) = ¬e { hint_text = format!("{hint_text}{}{n}", hint("roundtrip_note_prefix")); }
// a loaded add-in that is working is not a launch in progress, and never a reason to restart
let busy = running && !ready && !needs_signin && !licensing && i.addin_busy.is_some();
if busy { hint_text = hint("addin_busy"); }
let orient_note = if i.modeling_orientation.as_deref() == Some("yup") {
" ⚠ Default Modeling Orientation is Y-UP on this account, but Adom is a Z-UP environment (a fresh Autodesk account defaults to Y-up). Set it with fusion_set_preference {\"modelingOrientation\":\"zup\"} - no restart needed." } else { "" };
let mut result = Map::new();
result.insert("success".into(), json!(true));
result.insert("hostApp".into(), json!("Fusion 360"));
result.insert("installed".into(), json!(installed));
result.insert("installing".into(), json!(installing));
result.insert("running".into(), json!(running));
result.insert("ready".into(), json!(ready));
result.insert("kicadIngest".into(), json!(i.kicad_ingest));
result.insert("fusionUser".into(), i.fusion_user.clone().unwrap_or(Value::Null));
result.insert("modelingOrientation".into(), i.modeling_orientation.clone().map(Value::String).unwrap_or(Value::Null));
result.insert("bridgeVersion".into(), json!(i.bridge_version));
for (k, v) in &stale { result.insert(k.clone(), v.clone()); }
result.insert("_hint".into(), json!(format!("{hint_text}{orient_note}")));
// the launch observer
result.insert("timings".into(), i.timings_launch.clone());
if busy { result.insert("addinBusy".into(), i.addin_busy.clone().unwrap_or(Value::Null)); }
let now = i.now;
let mut lsa = prefs.get("launchStartedAt").and_then(|v| v.as_f64());
if busy && lsa.is_some() { prefs.insert("launchStartedAt".into(), Value::Null); lsa = None; }
if let Some(s) = lsa { if !ready && now - s > 900.0 { prefs.insert("launchStartedAt".into(), json!(now)); prefs.insert("launchHealedAt".into(), Value::Null); lsa = Some(now); } }
if lsa.is_none() && running && !ready && !busy { prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now); }
if !ready && running && i.crash_dialogs_cleared > 0 {
result.insert("crashReportCleared".into(), json!(i.crash_dialogs_cleared));
result.insert("fusionCrashed".into(), json!(true));
prefs.insert("launchHealedAt".into(), Value::Null); prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now);
}
let healed = prefs.get("launchHealedAt").map(|v| !v.is_null()).unwrap_or(false);
if !ready && running && !busy && !needs_signin && !licensing && !updating && lsa.is_some() && !healed {
let p90 = i.launch_stats["p90"].as_f64().unwrap_or(0.0);
let overdue_by = (2.0 * p90).max(180.0);
if now - lsa.unwrap() > overdue_by {
prefs.insert("launchHealedAt".into(), json!(now));
result.insert("addinRestartTriggered".into(), json!(true));
let attempt = prefs.get("launchHealAttempts").and_then(|v| v.as_u64()).unwrap_or(0) + 1;
if attempt > 4 {
result.insert("addinRestartExhausted".into(), json!(true));
result.insert("_hint".into(), json!(format!("Fusion has been restarted {} times and the add-in still does not serve. This is no longer a slow launch: screenshot the Fusion hwnd and read screenshots[] for a blocking dialog, or check whether Fusion is crashing at startup (fusionCrashed / crash_report).", attempt - 1)));
} else {
prefs.insert("launchHealAttempts".into(), json!(attempt));
result.insert("addinRestartAttempt".into(), json!(attempt));
actions.heal_launch = Some(attempt);
}
}
}
if ready {
prefs.insert("launchHealedAt".into(), Value::Null);
prefs.insert("launchHealAttempts".into(), json!(0));
if let Some(s) = lsa {
let el = now - s;
if (3.0..=3600.0).contains(&el) { actions.record_launch_seconds = Some(el); }
prefs.insert("launchStartedAt".into(), Value::Null);
}
} else if let Some(s) = lsa {
if now - s < 900.0 {
let label = if needs_signin { "Signing in to Autodesk" } else if licensing { "Clearing the Autodesk seat dialog" } else if updating { "Applying a Fusion auto-update" } else if running { "Fusion is up - waiting for the Adom add-in to serve" } else { "Fusion is starting" };
let mut pb = match &i.progress_override { Some(v) => { let mut v = v.clone(); v["stepLabel"] = json!(label); v }, None => progress_block(&i.launch_stats, s, now, label, 150.0) };
if pb["overdue"] == json!(true) && pb["confidence"] == json!("unknown") {
pb["stepLabel"] = json!(format!("{label} - this is longer than this machine has ever taken; restarting Fusion, since the add-in only loads at launch"));
}
result.insert("progress".into(), pb);
result.insert("statusVerb".into(), json!("fusion_readiness"));
}
}
if updating { result.insert("updating".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }
if needs_signin { result.insert("needsSignin".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }
if ready && running { prefs.insert("lastReadyAt".into(), json!(i.now_iso)); }
if running && !needs_signin && ready { prefs.insert("lastSignedInAt".into(), json!(i.now_iso)); }
result.insert("lastReadyAt".into(), prefs.get("lastReadyAt").cloned().unwrap_or(Value::Null));
result.insert("lastSignedInAt".into(), prefs.get("lastSignedInAt").cloned().unwrap_or(Value::Null));
if licensing { result.insert("licensingDialog".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }
if i.update_offered {
result.insert("fusionUpdateOffered".into(), json!(true));
let handled = i.update_applied_button.is_some();
result.insert("fusionUpdateHandled".into(), json!(handled));
result.insert("fusionUpdateHint".into(), json!(if handled { hint("fusion_update_hint_handled") } else { hint("fusion_update_hint_pending_template").replace("{state}", if !i.auto_update_enabled { "opted out" } else { "pending" }) }));
}
if let Some(asf) = &i.addin_start_failed {
result.insert("addinStartFailed".into(), asf.clone());
let h = format!("{} {}", asf["_hint"].as_str().unwrap_or(""), result["_hint"].as_str().unwrap_or(""));
result.insert("_hint".into(), json!(h));
}
Outcome { result: Value::Object(result), prefs_after: prefs, actions }
}