app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 124 file(s)
15 files changed
+375−61
BRIDGE_VERSION+1−1bridge.json+5−4dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1install.sh+1−1package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/bridge/src/main.rs+36−5rust/core/src/host.rs+7rust/lbr/src/lib.rs+4−1rust/os-win/Cargo.toml+2−2rust/os-win/src/lib.rs+279−16tools/check-portable.sh+5tools/release.sh+20BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.348+1.9.349
bridge.json+5−4@@ -2,19 +2,20 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.348",+ "version": "1.9.349", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.", "license": "MIT", "spawn": {- "kind": "python",- "entrypoint": "server.py",+ "kind": "exe",+ "entrypoint": "adom-fusion-bridge.exe", "port": 0, "healthEndpoint": "/status", "stopMethod": "kill", "killImageName": "python.exe",- "persistent": true+ "persistent": true,+ "_hint": "Phase 1 of the Rust port: ab launches the Rust exe (<install>/adom-fusion-bridge.exe --port N); the exe spawns server.py beside it as a Python sidecar (ab's provisioned ~/.adom/adom-runtimes python) and proxies every verb it has not ported yet. killImageName stays python.exe so ab's runtime-mode stop reaps the sidecar first; the exe also ties the sidecar to its own lifetime with a job object. To fall back to the Python bridge, set kind: python, entrypoint: server.py." }, "verbPrefixes": [ "fusion_"
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.348"+version = "1.9.349" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.348"+version = "1.9.349" edition = "2021" [dependencies]
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.348"+DASH_VERSION="1.9.349" 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.348",+ "version": "1.9.349", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+11−27@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.348",+ "version": "1.9.349", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -75,7 +79,10 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge"+ "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page" ], "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,28 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.9.347"+version = "1.9.349" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+36−5@@ -78,18 +78,26 @@ impl Sidecar { best } - fn spawn(bind: &str) -> Option<Sidecar> {+ fn spawn(bind: &str, log_dir: &std::path::Path) -> Option<Sidecar> { let py = Self::python_exe()?; let here = std::env::current_exe().ok()?.parent()?.to_path_buf(); // The sidecar's server.py sits beside this exe in the bridge dir during the port. let server_py = here.join("server.py"); if !server_py.exists() { return None; } let port = pick_free_port();+ // The sidecar's own stdout/stderr land in <state>/sidecar.log so a startup failure is+ // readable (a null handle is how the first smoke test died silently).+ let _ = std::fs::create_dir_all(log_dir);+ let log = std::fs::OpenOptions::new().create(true).append(true).open(log_dir.join("sidecar.log")).ok();+ let (out, err): (Stdio, Stdio) = match log {+ Some(f) => (f.try_clone().map(Stdio::from).unwrap_or_else(|_| Stdio::null()), Stdio::from(f)),+ None => (Stdio::null(), Stdio::null()),+ }; let child = Command::new(py) .arg(&server_py).arg("--port").arg(port.to_string()) .env("ADOM_BIND_HOST", bind) .current_dir(&here)- .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())+ .stdin(Stdio::null()).stdout(out).stderr(err) .spawn().ok()?; Some(Sidecar { child: Some(child), port, started: Instant::now() }) }@@ -130,7 +138,14 @@ fn main() { let bind = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".into()); let catalog = fusion_core::contract::load().expect("frozen verb catalog"); let h = host();- let sidecar = Arc::new(Mutex::new(Sidecar::spawn(&bind)));+ let sidecar = Arc::new(Mutex::new(Sidecar::spawn(&bind, &h.bridge_state_dir())));+ // ab stops this bridge with a hard kill (bridge_install, supervisor respawn): the sidecar+ // must go with it, or every respawn leaves one more Python on a random port.+ if let Some(sc) = sidecar.lock().unwrap().as_ref() {+ if let Some(pid) = sc.child.as_ref().map(|c| c.id()) {+ if !h.tie_child_lifetime(pid) { eprintln!("[fusion-bridge] could not tie the sidecar (pid {pid}) to this process's lifetime"); }+ }+ } let server = Server::http(format!("{bind}:{port}")).expect("bind loopback"); eprintln!("[fusion-bridge] v{VERSION} listening on {bind}:{port}; contract frozen {}; sidecar {}", catalog.frozen,@@ -138,6 +153,9 @@ fn main() { for mut req in server.incoming_requests() { let path = req.url().split('?').next().unwrap_or("/").to_string();+ // A panic in a handler must cost one request (tiny_http answers 500 for the dropped+ // request), never the process: ab paints the chip red the moment the port goes dark.+ let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { match (req.method(), path.as_str()) { (Method::Get, "/status") | (Method::Get, "/health") => { // Prefer the sidecar's rich status (Fusion/add-in probe) while it exists;@@ -177,12 +195,16 @@ fn main() { } _ => { let _ = req.respond(json_response(404, &json!({"error": "not found"}))); } }+ }));+ if outcome.is_err() { eprintln!("[fusion-bridge] a handler panicked on {path}; the shell stays up"); } } } /// Forward an unported verb to the Python sidecar, identity headers intact. fn proxy(sidecar: &Arc<Mutex<Option<Sidecar>>>, raw: &str, caller: &Caller) -> Value {- let url = match sidecar.lock().unwrap().as_ref() { Some(s) => s.url("/command"), None => return json!({+ // ab routes the first verb the moment /status is 2xx, which is before the sidecar has+ // bound its port: wait out the sidecar's startup (bounded) instead of failing that verb.+ let (url, young) = match sidecar.lock().unwrap().as_ref() { Some(s) => (s.url("/command"), s.started.elapsed() < Duration::from_secs(45)), None => return json!({ "success": false, "errorCode": "sidecar_absent", "error": "This verb is not ported yet and the Python sidecar is not running.", "_hint": "Set ADOM_SIDECAR_PYTHON to ab's provisioned python.exe, or run the Python bridge directly until this verb is ported."}) };@@ -190,9 +212,18 @@ fn proxy(sidecar: &Arc<Mutex<Option<Sidecar>>>, raw: &str, caller: &Caller) -> V if !caller.thread.is_empty() { r = r.set("X-Adom-Caller-Thread", &caller.thread); } if !caller.container.is_empty() { r = r.set("X-Adom-Caller-Container", &caller.container); } if !caller.reason.is_empty() { r = r.set("X-Adom-Caller-Reason", &caller.reason); }+ if young {+ let t0 = Instant::now();+ while t0.elapsed() < Duration::from_secs(45) {+ let base = url.trim_end_matches("/command").to_string();+ if ureq::get(&format!("{base}/status")).timeout(Duration::from_secs(2)).call().is_ok() { break; }+ std::thread::sleep(Duration::from_millis(400));+ }+ } match r.send_string(raw) { Ok(resp) => resp.into_json::<Value>().unwrap_or(json!({"success": false, "error": "sidecar returned non-JSON"})), Err(ureq::Error::Status(code, resp)) => resp.into_json::<Value>().unwrap_or(json!({"success": false, "error": format!("sidecar HTTP {code}")})),- Err(e) => json!({"success": false, "errorCode": "sidecar_unreachable", "error": e.to_string()}),+ Err(e) => json!({"success": false, "errorCode": "sidecar_unreachable", "error": e.to_string(),+ "_hint": "The Python sidecar beside adom-fusion-bridge.exe is not answering. Read ~/.adom/fusion-bridge/sidecar.log; if ab's portable python is missing (~/.adom/adom-runtimes/python-*), install any python bridge (kicad) once so ab provisions it."}), } }
rust/core/src/host.rs+7@@ -38,6 +38,13 @@ pub trait HostProcess: Send + Sync { /// Whether the bridge runs elevated (drives the installer's --globalinstall decision). fn is_elevated(&self) -> bool;++ /// Tie a child (the Python sidecar during the port) to THIS process's lifetime, so a hard+ /// kill of the bridge by ab (every bridge_install, every supervisor respawn) takes the+ /// child with it instead of leaving an orphan on a random port. A Windows job object+ /// with KILL_ON_JOB_CLOSE (and BREAKAWAY_OK, so the child's own Fusion launch can still+ /// break away); a no-op elsewhere. Returns whether the tie took.+ fn tie_child_lifetime(&self, _pid: u32) -> bool { false } } /// Where things live on this OS. Every path the bridge ever touches comes from here.
rust/lbr/src/lib.rs+4−1@@ -6,6 +6,9 @@ pub enum LbrError { #[error("not an EAGLE library: {0}")] NotALibrary(String),+ /// Phase 2 lands the engine; until then callers get an error, never a panic.+ #[error("not ported to the Rust engine yet: {0}")]+ NotPorted(&'static str), } /// Names that exist in a library (the equivalent of `_library_artifact_names`).@@ -18,5 +21,5 @@ pub struct ArtifactNames { } pub fn artifact_names(_xml: &str) -> Result<ArtifactNames, LbrError> {- todo!("Phase 2: port _library_artifact_names")+ Err(LbrError::NotPorted("_library_artifact_names")) }
rust/os-win/Cargo.toml+2−2@@ -3,10 +3,10 @@ name = "fusion-os-win" version.workspace = true edition.workspace = true license.workspace = true-description = "Windows implementation of fusion-core's HostProcess and HostPaths. The ONLY crate allowed to depend on the windows crate."+description = "Windows implementation of fusion-core's HostProcess and HostPaths. The ONLY crate allowed to depend on windows-sys." [dependencies] fusion-core = { path = "../core" } [target.'cfg(windows)'.dependencies]-windows = { version = "0.58", features = ["Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_Foundation"] }+windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_UI_Shell", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Security"] }
rust/os-win/src/lib.rs+279−16@@ -1,31 +1,294 @@ //! Windows host: the audited Win32 exceptions and nothing more.-//! - Toolhelp32 snapshot for the readiness poll (in-process, microseconds)-//! - CreateProcess with CREATE_BREAKAWAY_FROM_JOB for the Fusion launch-//! - IsUserAnAdmin for the installer decision-//! - the webdeploy / FusionAddins / AppLogFile paths+//! - Toolhelp32 snapshot for the readiness poll (in-process, microseconds, never a child+//! process: a `tasklist` that stalled under a pegged CPU once launched a SECOND Fusion)+//! - `cmd /c start "" /min <launcher>` with CREATE_BREAKAWAY_FROM_JOB for the Fusion launch,+//! so a bridge respawn can never take Fusion down with it (issue #50: seven dead Fusions+//! in one build matrix, every one a child of the bridge)+//! - IsUserAnAdmin for the installer's --globalinstall decision+//! - the webdeploy / FusionAddins / AppLogFile paths, ported 1:1 from fusion_detect.py,+//! install_addin.py and server.py so the two shells agree during the overlap+//!+//! Everything here also compiles on Linux/macOS (the Win32 calls are cfg(windows)-gated and+//! answer "nothing running / not installed" elsewhere) so `tools/check-portable.sh` can+//! build and test the whole workspace on the dev container.+//!+//! AI hint: nothing in this crate may panic on a poll. A `todo!()` here once killed the+//! Rust shell on its very first /status request (tiny_http answered 500 for the request+//! dropped mid-unwind, then the port went dark). `tools/check-portable.sh` refuses `todo!`. -use fusion_core::{HostPaths, HostProcess}; use fusion_core::host::{FusionProcess, HostError};-use std::path::PathBuf;+use fusion_core::{HostPaths, HostProcess};+use std::path::{Path, PathBuf};+use std::time::{Duration, Instant}; pub struct WindowsHost; -impl HostProcess for WindowsHost {- fn fusion_processes(&self) -> Vec<FusionProcess> {- todo!("Phase 1: port _fusion_pids_toolhelp (fusion_detect.py)")+/// The ACTUAL app binary (~880 KB). Its presence at full size is the one reliable "installed"+/// signal: FusionLauncher.exe lands early mid-stream, and the launcher's .ini is absent from+/// the complete app dir, so neither of those may gate "installed".+const FUSION_APP_EXE: &str = "Fusion360.exe";+const MIN_APP_EXE_BYTES: u64 = 200_000;++fn env_dir(var: &str) -> Option<PathBuf> {+ std::env::var_os(var).map(PathBuf::from).filter(|p| !p.as_os_str().is_empty())+}++fn home() -> PathBuf {+ env_dir("USERPROFILE").or_else(|| env_dir("HOME")).unwrap_or_else(|| PathBuf::from("C:\\Users\\Default"))+}++fn local_app_data() -> PathBuf {+ env_dir("LOCALAPPDATA").unwrap_or_else(|| home().join("AppData").join("Local"))+}++fn roaming_app_data() -> PathBuf {+ env_dir("APPDATA").unwrap_or_else(|| home().join("AppData").join("Roaming"))+}++/// Every profile under the users root (the parent of the home dir, normally `C:\Users`), so a+/// bridge running as one user still finds a Fusion installed or logging as another (issue #326).+fn user_profiles() -> Vec<PathBuf> {+ let root = home().parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("C:\\Users"));+ let mut out = Vec::new();+ if let Ok(rd) = std::fs::read_dir(&root) {+ for e in rd.flatten() {+ if e.path().is_dir() { out.push(e.path()); }+ }+ }+ out+}++/// `<base>\Autodesk\webdeploy\production` for every profile, then the env-rooted bases.+fn webdeploy_bases() -> Vec<PathBuf> {+ let mut bases: Vec<PathBuf> = user_profiles().into_iter()+ .map(|u| u.join("AppData").join("Local").join("Autodesk").join("webdeploy").join("production"))+ .collect();+ for var in ["LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {+ if let Some(root) = env_dir(var) {+ let b = root.join("Autodesk").join("webdeploy").join("production");+ if !bases.contains(&b) { bases.push(b); }+ }+ }+ bases+}++fn app_dir_complete(sub: &Path) -> bool {+ std::fs::metadata(sub.join(FUSION_APP_EXE)).map(|m| m.is_file() && m.len() >= MIN_APP_EXE_BYTES).unwrap_or(false)+}++fn mtime_secs(p: &Path) -> u64 {+ std::fs::metadata(p).and_then(|m| m.modified())+ .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+ .map(|d| d.as_secs()).unwrap_or(0)+}++fn subdirs(base: &Path) -> Vec<PathBuf> {+ std::fs::read_dir(base).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect()).unwrap_or_default()+}++/// Is Fusion installed at all: ANY hash dir holds a complete Fusion360.exe.+pub fn any_app_complete() -> bool {+ webdeploy_bases().iter().any(|b| subdirs(b).iter().any(|s| app_dir_complete(s)))+}++/// The FusionLauncher.exe to RUN, only once Fusion is actually installed. Two separate+/// questions (conflating them broke the launch once): "installed" is gated on Fusion360.exe;+/// "which launcher" prefers a FusionLauncher.exe WITH its .ini beside it (a bare launcher+/// throws Fusion's own "FusionLauncher.exe.ini is missing or incomplete"), then a bare+/// launcher, then the app exe itself. Newest mtime wins inside each pool.+fn find_fusion_launcher() -> Option<PathBuf> {+ if !any_app_complete() { return None; }+ let (mut runnable, mut bare, mut app_exes): (Vec<(u64, PathBuf)>, Vec<(u64, PathBuf)>, Vec<(u64, PathBuf)>) = (vec![], vec![], vec![]);+ for base in webdeploy_bases() {+ for sub in subdirs(&base) {+ let mt = mtime_secs(&sub);+ let launcher = sub.join("FusionLauncher.exe");+ if launcher.is_file() {+ if sub.join("FusionLauncher.exe.ini").is_file() { runnable.push((mt, launcher)); } else { bare.push((mt, launcher)); }+ }+ if app_dir_complete(&sub) { app_exes.push((mt, sub.join(FUSION_APP_EXE))); }+ }+ }+ for pool in [&mut runnable, &mut bare, &mut app_exes] {+ if !pool.is_empty() {+ pool.sort_by(|a, b| b.0.cmp(&a.0));+ return Some(pool[0].1.clone());+ } }+ None+}++#[cfg(windows)]+fn snapshot_fusion() -> Vec<FusionProcess> {+ use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};+ use windows_sys::Win32::System::Diagnostics::ToolHelp::{+ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,+ };+ let mut out = Vec::new();+ // SAFETY: plain Toolhelp32 walk over a snapshot handle we own and close; the entry struct+ // is zeroed and sized before the first call, as the API requires.+ unsafe {+ let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);+ if snap == INVALID_HANDLE_VALUE || snap.is_null() { return out; }+ let mut pe: PROCESSENTRY32W = std::mem::zeroed();+ pe.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;+ if Process32FirstW(snap, &mut pe) != 0 {+ loop {+ let len = pe.szExeFile.iter().position(|&c| c == 0).unwrap_or(pe.szExeFile.len());+ let name = String::from_utf16_lossy(&pe.szExeFile[..len]);+ if name.eq_ignore_ascii_case(FUSION_APP_EXE) {+ out.push(FusionProcess { pid: pe.th32ProcessID, image: name, parent_pid: Some(pe.th32ParentProcessID) });+ }+ if Process32NextW(snap, &mut pe) == 0 { break; }+ }+ }+ CloseHandle(snap);+ }+ out+}++#[cfg(not(windows))]+fn snapshot_fusion() -> Vec<FusionProcess> { Vec::new() }++#[cfg(windows)]+fn start_shim(exe: &Path) -> Result<u32, HostError> {+ use std::os::windows::process::CommandExt;+ use std::process::{Command, Stdio};+ const CREATE_NO_WINDOW: u32 = 0x0800_0000;+ const DETACHED_PROCESS: u32 = 0x0000_0008;+ const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;+ const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;+ let base = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;+ // `start "" /min <exe>`: the empty "" is the window-title slot start expects first. cmd exits+ // at once, so the launcher's parent is gone and no tree walk from the bridge reaches Fusion.+ let spawn = |flags: u32| Command::new("cmd").args(["/c", "start", "", "/min"]).arg(exe)+ .creation_flags(flags).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();+ match spawn(base | CREATE_BREAKAWAY_FROM_JOB) {+ Ok(c) => Ok(c.id()),+ // The job forbids breakaway (ERROR_ACCESS_DENIED): still reparented via cmd.+ Err(_) => spawn(base).map(|c| c.id()).map_err(|e| HostError::Launch(e.to_string())),+ }+}++#[cfg(not(windows))]+fn start_shim(_exe: &Path) -> Result<u32, HostError> {+ Err(HostError::Launch("the Windows host only launches Fusion on Windows".into()))+}++impl HostProcess for WindowsHost {+ fn fusion_processes(&self) -> Vec<FusionProcess> { snapshot_fusion() }+ fn launch_fusion(&self) -> Result<FusionProcess, HostError> {- todo!("Phase 1: port the CREATE_BREAKAWAY_FROM_JOB launch (fusion_detect.py)")+ let exe = self.fusion_executable().ok_or_else(|| HostError::NotInstalled(+ "no webdeploy hash dir holds a complete Fusion360.exe".into()))?;+ let shim = start_shim(&exe)?;+ // Fusion360.exe appears a few seconds after the launcher; report the real process+ // when it shows, else the shim pid so the caller can still poll.+ let t0 = Instant::now();+ while t0.elapsed() < Duration::from_secs(8) {+ if let Some(p) = snapshot_fusion().into_iter().next() { return Ok(p); }+ std::thread::sleep(Duration::from_millis(250));+ }+ Ok(FusionProcess { pid: shim, image: "cmd.exe (start shim; Fusion360.exe not visible yet)".into(), parent_pid: None }) }++ #[cfg(windows)]+ fn tie_child_lifetime(&self, pid: u32) -> bool {+ use windows_sys::Win32::Foundation::CloseHandle;+ use windows_sys::Win32::System::JobObjects::{+ AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,+ SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,+ JOB_OBJECT_LIMIT_BREAKAWAY_OK, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,+ };+ use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};+ // SAFETY: a job handle we deliberately never close (its close is what kills the child+ // when THIS process dies), a zeroed limit struct sized for the call, a process handle+ // we close after the assignment.+ unsafe {+ let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());+ if job.is_null() { return false; }+ let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;+ if SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info as *const _ as *const _,+ std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32) == 0 { return false; }+ let ph = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid);+ if ph.is_null() { return false; }+ let ok = AssignProcessToJobObject(job, ph) != 0;+ CloseHandle(ph);+ ok+ }+ }++ #[cfg(windows)] fn is_elevated(&self) -> bool {- todo!("Phase 1: IsUserAnAdmin")+ // SAFETY: no arguments, no memory handed over; a plain BOOL answer.+ unsafe { windows_sys::Win32::UI::Shell::IsUserAnAdmin() != 0 } }+ #[cfg(not(windows))]+ fn is_elevated(&self) -> bool { false } } impl HostPaths for WindowsHost {- fn fusion_executable(&self) -> Option<PathBuf> { todo!("Phase 1: newest webdeploy launcher by mtime") }- fn addin_dirs(&self) -> Vec<PathBuf> { todo!("Phase 1: install_addin.py TARGET_CANDIDATES") }- fn fusion_log_dir(&self) -> Option<PathBuf> { todo!("Phase 1: _fusion_log_path") }- fn bridge_state_dir(&self) -> PathBuf { todo!("Phase 1: ~/.adom/fusion-bridge") }- fn scratch_dir(&self) -> PathBuf { todo!("Phase 1: %TEMP%/adom-fusion") }+ fn fusion_executable(&self) -> Option<PathBuf> { find_fusion_launcher() }++ /// Newest convention first. Fusion MOVED this dir across versions and silently ignores the+ /// others (issue #63), so the installer writes to every one that exists.+ fn addin_dirs(&self) -> Vec<PathBuf> {+ let r = roaming_app_data().join("Autodesk");+ vec![+ r.join("FusionAddins"),+ r.join("Autodesk Fusion").join("API").join("AddIns"),+ r.join("Autodesk Fusion 360").join("API").join("AddIns"),+ ]+ }++ /// The `logs` dir holding the newest AppLogFile*.log across every profile's+ /// `Autodesk Fusion[ 360]` tree; None when Fusion has never logged here.+ fn fusion_log_dir(&self) -> Option<PathBuf> {+ let mut bases = vec![];+ for nm in ["Autodesk Fusion 360", "Autodesk Fusion"] {+ bases.push(local_app_data().join("Autodesk").join(nm));+ }+ for u in user_profiles() {+ for nm in ["Autodesk Fusion 360", "Autodesk Fusion"] {+ bases.push(u.join("AppData").join("Local").join("Autodesk").join(nm));+ }+ }+ let mut best: Option<(u64, PathBuf)> = None;+ for base in bases {+ for entry in subdirs(&base) {+ let logs = entry.join("logs");+ let Ok(rd) = std::fs::read_dir(&logs) else { continue };+ for f in rd.flatten() {+ let name = f.file_name().to_string_lossy().to_string();+ if name.starts_with("AppLogFile") && name.ends_with(".log") {+ let m = mtime_secs(&f.path());+ if best.as_ref().map(|b| m > b.0).unwrap_or(true) { best = Some((m, logs.clone())); }+ }+ }+ }+ }+ best.map(|b| b.1)+ }++ fn bridge_state_dir(&self) -> PathBuf { home().join(".adom").join("fusion-bridge") }++ fn scratch_dir(&self) -> PathBuf {+ env_dir("TEMP").unwrap_or_else(|| local_app_data().join("Temp")).join("adom-fusion")+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn paths_never_panic_off_windows() {+ let h = WindowsHost;+ let _ = h.fusion_executable();+ assert_eq!(h.addin_dirs().len(), 3);+ let _ = h.fusion_log_dir();+ assert!(h.bridge_state_dir().ends_with("fusion-bridge"));+ assert!(h.scratch_dir().ends_with("adom-fusion"));+ assert!(h.fusion_processes().is_empty() || cfg!(windows));+ } }
tools/check-portable.sh+5@@ -5,3 +5,8 @@ cd "$(dirname "$0")/../rust" bad=$(cargo tree -p fusion-core -e normal 2>/dev/null | grep -iE "^\s*[│├└─ ]*(windows|windows-sys|winapi|cocoa|objc|core-foundation)\b" || true) if [ -n "$bad" ]; then echo "PORTABILITY DRIFT: fusion-core depends on an OS crate:"; echo "$bad"; exit 1; fi echo "fusion-core is OS-blind"+# No `todo!()` may ship in a crate the Windows exe links: one killed the shell on its first+# /status request. (os-mac is Kyle's crate and may still carry stubs until his port lands.)+todo=$(grep -rn "todo!(" core/src lbr/src os-win/src bridge/src | grep -vE ':[0-9]+:\s*//' || true)+if [ -n "$todo" ]; then echo "SHIP BLOCKER: todo!() in a linked crate:"; echo "$todo"; exit 1; fi+echo "no todo!() in the linked crates"
tools/release.sh+20@@ -172,6 +172,7 @@ for f in ("bridge.json", "page.json", "package.json"): json.dump(d, open(f, "w"), indent=2); open(f, "a").write("\n") PY sed -i "s/^version = \"[0-9.]*\"/version = \"$VERSION\"/" dashboard/Cargo.toml+sed -i "s/^version = \"[0-9.]*\"/version = \"$VERSION\"/" rust/Cargo.toml # the Rust workspace rides the same line sed -i "s/DASH_VERSION=\"[0-9.]*\"/DASH_VERSION=\"$VERSION\"/" install.sh # prove they all match now BAD=$(cur_versions; sed -n 's/DASH_VERSION="\([0-9.]*\)"/\1/p' install.sh)@@ -184,6 +185,20 @@ step "Building dashboard binary" "$DASH_BIN" --version 2>&1 | grep -q "v$VERSION" || die "built dashboard does not report v$VERSION" echo " dashboard $($DASH_BIN --version 2>&1 | head -1)" +# --- 3b. build the Rust bridge exe (Windows) -----------------------------------------------+# Phase 1 of the Rust port: the exe ships INSIDE the bridge zip beside server.py. Whether ab+# spawns it (spawn.kind exe) or the Python bridge (spawn.kind python) is bridge.json's call;+# the payload carries both so a tier can flip without a rebuild. Cross-compiled with the+# mingw target; the guards run first so a todo!() or an OS crate in core never ships.+step "Building Rust bridge exe"+bash tools/check-portable.sh || die "check-portable failed"+bash tools/check-naming.sh || die "check-naming failed"+( cd rust && cargo build --release --target x86_64-pc-windows-gnu -p adom-fusion-bridge ) || die "rust exe build failed"+RUST_EXE="rust/target/x86_64-pc-windows-gnu/release/adom-fusion-bridge.exe"+[ -s "$RUST_EXE" ] || die "rust exe missing after build"+grep -q "version = \"$VERSION\"" rust/Cargo.toml || die "rust workspace is not at $VERSION"+echo " rust exe $(stat -c%s "$RUST_EXE") bytes @ $VERSION"+ # --- 4. build the bridge zip ------------------------------------------------------------- step "Building bridge zip" # The asset FILENAME becomes the download-URL basename, and the manifest URL (step 5) expects@@ -225,8 +240,13 @@ for f in [x for x in files if x.startswith("addin/AdomBridge/") and x.endswith(" if not (cand + ".py" in payload or os.path.join(cand, "__init__.py") in payload): raise SystemExit("REFUSING to build: %s imports %s%s which is not in the payload" % (f, dots, mod)) print(" add-in payload: %d files, all relative imports resolve" % len([x for x in files if x.startswith("addin/")]))+# The Rust exe lands at the ROOT of the payload, beside server.py: that is where ab's+# spawn.kind exe looks for the entrypoint, and where the exe looks for its sidecar.+rust_exe = "rust/target/x86_64-pc-windows-gnu/release/adom-fusion-bridge.exe"+if not os.path.exists(rust_exe): raise SystemExit("missing the Rust bridge exe: %s" % rust_exe) with zipfile.ZipFile(zp, "w", zipfile.ZIP_DEFLATED) as z: for f in files: z.write(f, f)+ z.write(rust_exe, "adom-fusion-bridge.exe") print(" zip", os.path.getsize(zp), "bytes,", len(files), "files") PY