← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.348\ No newline at end of file+1.9.349\ No newline at end of file
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]"-  }-}\ No newline at end of file+  ]+}
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