master
Drew Owens The mechanical layer on 1.12.8 with the restart fixes (1.13.0-dev.1, add-in 1.0.23) 2ef0220 15d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
//! Windows host: the audited Win32 exceptions and nothing more.
//!  - 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::host::{FusionProcess, HostError};
use fusion_core::{HostPaths, HostProcess};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

pub struct WindowsHost;

/// 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_images() -> Vec<(u32, u32, String)> {
    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: same Toolhelp32 walk as snapshot_fusion, over a handle we own and close.
    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());
                out.push((pe.th32ProcessID, pe.th32ParentProcessID, String::from_utf16_lossy(&pe.szExeFile[..len])));
                if Process32NextW(snap, &mut pe) == 0 { break; }
            }
        }
        CloseHandle(snap);
    }
    out
}
#[cfg(not(windows))]
fn snapshot_images() -> Vec<(u32, u32, String)> { Vec::new() }

#[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 window_dpi_scale(&self, hwnd: i64) -> f64 { window_dpi_scale(hwnd) }
    fn client_origin(&self, hwnd: i64) -> (i32, i32) { client_origin(hwnd) }
    fn window_work_area(&self, hwnd: i64) -> Option<(i32, i32, i32, i32)> { window_work_area(hwnd) }
    fn window_is_maximized(&self, hwnd: i64) -> bool { window_is_maximized(hwnd) }
    fn fusion_processes(&self) -> Vec<FusionProcess> { snapshot_fusion() }

    fn image_running(&self, names: &[&str]) -> bool {
        snapshot_images().iter().any(|(_, _, img)| names.iter().any(|n| n.eq_ignore_ascii_case(img)))
    }

    fn launch_fusion(&self) -> Result<FusionProcess, HostError> {
        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 })
    }

    fn background_envelope(&self, seconds: f64, minimize: bool) { envelope::begin(seconds, minimize) }
    fn window_hung(&self, hwnd: i64) -> bool { hang::window_hung(hwnd) }
    fn family_cpu_ticks(&self) -> u64 { hang::family_cpu_ticks() }
    fn user_watching(&self) -> bool { envelope::user_watching() }
    #[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 {
        // 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> { 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 short_path(&self, path: &str) -> String { short_path(path) }
    fn clean_incomplete_webdeploy(&self) -> Vec<String> {
        if any_app_complete() { return vec![]; }
        let mut removed = Vec::new();
        for base in webdeploy_bases() {
            for sub in subdirs(&base) {
                if sub.join("FusionLauncher.exe").is_file() && !app_dir_complete(&sub) && std::fs::remove_dir_all(&sub).is_ok() { removed.push(sub.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()); }
            }
        }
        removed
    }
    fn fusion_install_streaming(&self) -> bool {
        if any_app_complete() { return false; }
        webdeploy_bases().iter().any(|b| subdirs(b).iter().any(|s| s.join("FusionLauncher.exe").is_file()))
    }

    fn fusion_update_in_progress(&self) -> bool {
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
        let mut mtimes = Vec::new();
        for b in webdeploy_bases() { for s in subdirs(&b) { if s.join("FusionLauncher.exe").is_file() { mtimes.push(mtime_secs(&s)); } } }
        mtimes.len() >= 2 && mtimes.iter().any(|m| now.saturating_sub(*m) < 20 * 60)
    }

    fn scratch_dir(&self) -> PathBuf {
        env_dir("TEMP").unwrap_or_else(|| local_app_data().join("Temp")).join("adom-fusion")
    }

    fn mechanical_state_dir(&self) -> PathBuf { local_app_data().join("Adom").join("FusionBridge").join("mechanical") }
}

#[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));
    }
}


/// The background envelope, ported from fusion_detect.py (`begin_background_envelope` and
/// `_envelope_watch`): while a verb may raise a Fusion window, Windows is told to DENY foreground
/// changes (SPI_SETFOREGROUNDLOCKTIMEOUT 300000, LockSetForegroundWindow), the minimize animation
/// is off so a pushed-down window vanishes in one frame, and a watcher thread pushes any
/// Fusion-family window that reaches the foreground to the bottom (or minimizes it, for a launch)
/// unless the USER brought it forward (recent input) - then the foreground is theirs and the
/// watcher stands down until they leave it. Nested envelopes refcount; the last one out restores
/// the user's settings.
pub mod envelope {
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Mutex;
    static USER_WATCHING: AtomicBool = AtomicBool::new(false);
    #[allow(dead_code)]
    static STATE: Mutex<(u32, Option<u32>, Option<i32>, bool)> = Mutex::new((0, None, None, false));
    pub fn user_watching() -> bool { USER_WATCHING.load(Ordering::Relaxed) }

    #[cfg(not(windows))]
    pub fn begin(_seconds: f64, _minimize: bool) {}

    #[cfg(windows)]
    pub fn begin(seconds: f64, minimize: bool) {
        use windows_sys::Win32::UI::WindowsAndMessaging::{LockSetForegroundWindow, LSFW_LOCK};
        {
            let mut st = STATE.lock().unwrap();
            st.0 += 1;
            if st.0 == 1 {
                st.1 = win::get_fg_lock_timeout();
                st.2 = win::get_min_animate();
                win::set_fg_lock_timeout(300_000);
                win::set_min_animate(0);
                // SAFETY: plain user32 call with a documented flag.
                st.3 = unsafe { LockSetForegroundWindow(LSFW_LOCK) } != 0;
            }
        }
        std::thread::spawn(move || { watch(seconds, minimize); release(); });
    }

    #[cfg(windows)]
    fn release() {
        use windows_sys::Win32::UI::WindowsAndMessaging::{LockSetForegroundWindow, LSFW_UNLOCK};
        let mut st = STATE.lock().unwrap();
        st.0 = st.0.saturating_sub(1);
        if st.0 == 0 {
            if let Some(fg) = st.1 { win::set_fg_lock_timeout(fg); }
            if let Some(a) = st.2 { win::set_min_animate(a); }
            // SAFETY: as above.
            if st.3 { unsafe { LockSetForegroundWindow(LSFW_UNLOCK); } }
        }
    }

    #[cfg(windows)]
    fn family_pids() -> std::collections::HashSet<u32> {
        super::snapshot_images().into_iter().filter(|(_, _, img)| fusion_core::windows_facts::FAMILY_IMAGES.iter().any(|f| f.eq_ignore_ascii_case(img))).map(|(pid, _, _)| pid).collect()
    }

    #[cfg(windows)]
    fn watch(seconds: f64, minimize: bool) {
        use std::time::{Duration, Instant};
        use windows_sys::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId, SetWindowPos, ShowWindowAsync, HWND_BOTTOM, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SW_SHOWMINNOACTIVE};
        let flags = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE;
        let fg_pid = |hwnd: windows_sys::Win32::Foundation::HWND| -> u32 { let mut pid = 0u32; unsafe { GetWindowThreadProcessId(hwnd, &mut pid); } pid };
        let end = Instant::now() + Duration::from_secs_f64(seconds.max(0.0));
        let mut pids = family_pids();
        let mut last_refresh = Instant::now();
        let mut prev_was_fusion = false;
        let mut away_since: Option<Instant> = None;
        let mut away_tick: Option<u32> = None;
        // SAFETY: GetForegroundWindow / GetWindowThreadProcessId / SetWindowPos / ShowWindowAsync
        // are plain user32 calls on handles we only read; nothing is dereferenced.
        unsafe {
            let fg0 = GetForegroundWindow();
            if !fg0.is_null() && !pids.is_empty() && pids.contains(&fg_pid(fg0)) { USER_WATCHING.store(true, Ordering::Relaxed); prev_was_fusion = true; }
            while Instant::now() < end {
                if last_refresh.elapsed() > Duration::from_secs(2) { pids = family_pids(); last_refresh = Instant::now(); }
                let fg = GetForegroundWindow();
                let fg_is_fusion = !fg.is_null() && !pids.is_empty() && pids.contains(&fg_pid(fg));
                if fg_is_fusion {
                    away_since = None;
                    if !prev_was_fusion && win::user_active_recently(5000) { USER_WATCHING.store(true, Ordering::Relaxed); }
                    if !USER_WATCHING.load(Ordering::Relaxed) {
                        if minimize { ShowWindowAsync(fg, SW_SHOWMINNOACTIVE); } else { SetWindowPos(fg, HWND_BOTTOM, 0, 0, 0, 0, flags); }
                    }
                } else if !fg.is_null() {
                    match away_since {
                        None => { away_since = Some(Instant::now()); away_tick = win::last_input_tick(); }
                        Some(t) if USER_WATCHING.load(Ordering::Relaxed) && t.elapsed() >= Duration::from_secs(3) => {
                            let now_tick = win::last_input_tick();
                            if let (Some(n), Some(a)) = (now_tick, away_tick) { if n != a { USER_WATCHING.store(false, Ordering::Relaxed); } }
                        }
                        _ => {}
                    }
                }
                prev_was_fusion = fg_is_fusion;
                std::thread::sleep(Duration::from_millis(50));
            }
            // a 20s grace: keep pushing a Fusion window down until the user acts or it stays away
            let grace_end = Instant::now() + Duration::from_secs(20);
            while Instant::now() < grace_end {
                if win::user_active_recently(5000) || USER_WATCHING.load(Ordering::Relaxed) { break; }
                let fg = GetForegroundWindow();
                if fg.is_null() || pids.is_empty() || !pids.contains(&fg_pid(fg)) { break; }
                SetWindowPos(fg, HWND_BOTTOM, 0, 0, 0, 0, flags);
                std::thread::sleep(Duration::from_millis(200));
            }
        }
    }

    #[cfg(windows)]
    mod win {
        use windows_sys::Win32::UI::WindowsAndMessaging::{SystemParametersInfoW, ANIMATIONINFO, SPI_GETANIMATION, SPI_GETFOREGROUNDLOCKTIMEOUT, SPI_SETANIMATION, SPI_SETFOREGROUNDLOCKTIMEOUT};
        use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
        use windows_sys::Win32::System::SystemInformation::GetTickCount;
        // SAFETY (all four): SystemParametersInfoW with the documented parameter shapes; the
        // out-params are stack locals of the right size.
        pub fn get_fg_lock_timeout() -> Option<u32> { let mut v: u32 = 0; unsafe { if SystemParametersInfoW(SPI_GETFOREGROUNDLOCKTIMEOUT, 0, &mut v as *mut u32 as *mut _, 0) != 0 { Some(v) } else { None } } }
        pub fn set_fg_lock_timeout(ms: u32) { unsafe { SystemParametersInfoW(SPI_SETFOREGROUNDLOCKTIMEOUT, 0, ms as usize as *mut _, 0); } }
        pub fn get_min_animate() -> Option<i32> { let mut ai: ANIMATIONINFO = unsafe { std::mem::zeroed() }; ai.cbSize = std::mem::size_of::<ANIMATIONINFO>() as u32; unsafe { if SystemParametersInfoW(SPI_GETANIMATION, ai.cbSize, &mut ai as *mut _ as *mut _, 0) != 0 { Some(ai.iMinAnimate) } else { None } } }
        pub fn set_min_animate(v: i32) { let mut ai: ANIMATIONINFO = unsafe { std::mem::zeroed() }; ai.cbSize = std::mem::size_of::<ANIMATIONINFO>() as u32; ai.iMinAnimate = v; unsafe { SystemParametersInfoW(SPI_SETANIMATION, ai.cbSize, &mut ai as *mut _ as *mut _, 0); } }
        pub fn last_input_tick() -> Option<u32> { let mut lii: LASTINPUTINFO = unsafe { std::mem::zeroed() }; lii.cbSize = std::mem::size_of::<LASTINPUTINFO>() as u32; unsafe { if GetLastInputInfo(&mut lii) != 0 { Some(lii.dwTime) } else { None } } }
        pub fn user_active_recently(window_ms: u32) -> bool { match last_input_tick() { Some(t) => unsafe { GetTickCount() }.wrapping_sub(t) < window_ms, None => false } }
    }
}


/// Two audited Win32 reads for the hang gate (fusion_ui.is_fusion_hung, fusion_detect.fusion_using_cpu):
/// IsHungAppWindow, the shell's own verdict, and GetProcessTimes over the launch family so a
/// GIL-starved-but-working Fusion (CPU advancing) is never mistaken for a wedged one.
pub mod hang {
    #[cfg(not(windows))]
    pub fn window_hung(_hwnd: i64) -> bool { false }
    #[cfg(not(windows))]
    pub fn family_cpu_ticks() -> u64 { 0 }
    #[cfg(windows)]
    pub fn window_hung(hwnd: i64) -> bool {
        use windows_sys::Win32::UI::WindowsAndMessaging::IsHungAppWindow;
        // SAFETY: a plain user32 read on a handle we only pass through.
        unsafe { IsHungAppWindow(hwnd as *mut _) != 0 }
    }
    #[cfg(windows)]
    pub fn family_cpu_ticks() -> u64 {
        use windows_sys::Win32::Foundation::{CloseHandle, FILETIME};
        use windows_sys::Win32::System::Threading::{GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
        let pids: Vec<u32> = super::snapshot_images().into_iter().filter(|(_, _, img)| fusion_core::windows_facts::FAMILY_IMAGES.iter().any(|f| f.eq_ignore_ascii_case(img))).map(|(p, _, _)| p).collect();
        let mut total = 0u64;
        for pid in pids {
            // SAFETY: a limited-information handle we close; four zeroed FILETIMEs of the right size.
            unsafe {
                let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
                if h.is_null() { continue; }
                let (mut c, mut e, mut k, mut u): (FILETIME, FILETIME, FILETIME, FILETIME) = (std::mem::zeroed(), std::mem::zeroed(), std::mem::zeroed(), std::mem::zeroed());
                if GetProcessTimes(h, &mut c, &mut e, &mut k, &mut u) != 0 {
                    total += (k.dwLowDateTime as u64) | ((k.dwHighDateTime as u64) << 32);
                    total += (u.dwLowDateTime as u64) | ((u.dwHighDateTime as u64) << 32);
                }
                CloseHandle(h);
            }
        }
        total
    }
}


/// `_fusion_text_path`: Fusion's executeTextCommand is space-delimited, and the quoted form
/// fails with error 3; the 8.3 short path with forward slashes opens first try.
#[cfg(windows)]
pub fn short_path(path: &str) -> String {
    use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW;
    let p = path.replace('\\', "/");
    if !p.contains(' ') { return p; }
    let wide: Vec<u16> = p.encode_utf16().chain(std::iter::once(0)).collect();
    let mut buf = vec![0u16; 1024];
    // SAFETY: a NUL-terminated UTF-16 input and an output buffer of the stated length.
    let n = unsafe { GetShortPathNameW(wide.as_ptr(), buf.as_mut_ptr(), buf.len() as u32) } as usize;
    if n == 0 || n >= buf.len() { return p; }
    let s = String::from_utf16_lossy(&buf[..n]).replace('\\', "/");
    if s.contains(' ') { p } else { s }
}
#[cfg(not(windows))]
pub fn short_path(path: &str) -> String { path.replace('\\', "/") }

/// `_window_dpi_scale`: GetDpiForWindow / 96, else the nearest monitor's effective DPI, else 1.0.
#[cfg(windows)]
pub fn window_dpi_scale(hwnd: i64) -> f64 {
    use windows_sys::Win32::Graphics::Gdi::{MonitorFromWindow, MONITOR_DEFAULTTONEAREST};
    use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, GetDpiForWindow, MDT_EFFECTIVE_DPI};
    let h = hwnd as isize as windows_sys::Win32::Foundation::HWND;
    unsafe {
        let dpi = GetDpiForWindow(h);
        if dpi > 0 { return dpi as f64 / 96.0; }
        let mon = MonitorFromWindow(h, MONITOR_DEFAULTTONEAREST);
        let (mut x, mut y) = (0u32, 0u32);
        if !mon.is_null() && GetDpiForMonitor(mon, MDT_EFFECTIVE_DPI, &mut x, &mut y) == 0 && x > 0 { return x as f64 / 96.0; }
    }
    1.0
}
#[cfg(not(windows))]
pub fn window_dpi_scale(_hwnd: i64) -> f64 { 1.0 }

/// `_client_origin`: ClientToScreen of (0,0), the honest origin for ribbon DIP offsets.
#[cfg(windows)]
pub fn client_origin(hwnd: i64) -> (i32, i32) {
    use windows_sys::Win32::Foundation::POINT;
    use windows_sys::Win32::Graphics::Gdi::ClientToScreen;
    let mut pt = POINT { x: 0, y: 0 };
    if unsafe { ClientToScreen(hwnd as isize as windows_sys::Win32::Foundation::HWND, &mut pt) } != 0 { (pt.x, pt.y) } else { (0, 0) }
}
#[cfg(not(windows))]
pub fn client_origin(_hwnd: i64) -> (i32, i32) { (0, 0) }

#[cfg(windows)]
pub fn window_work_area(hwnd: i64) -> Option<(i32, i32, i32, i32)> {
    use windows_sys::Win32::Graphics::Gdi::{GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST};
    unsafe {
        let mon = MonitorFromWindow(hwnd as isize as windows_sys::Win32::Foundation::HWND, MONITOR_DEFAULTTONEAREST);
        if mon.is_null() { return None; }
        let mut mi: MONITORINFO = std::mem::zeroed(); mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
        if GetMonitorInfoW(mon, &mut mi) == 0 { return None; }
        let r = mi.rcWork; Some((r.left, r.top, r.right - r.left, r.bottom - r.top))
    }
}
#[cfg(not(windows))]
pub fn window_work_area(_hwnd: i64) -> Option<(i32, i32, i32, i32)> { None }
#[cfg(windows)]
pub fn window_is_maximized(hwnd: i64) -> bool { unsafe { windows_sys::Win32::UI::WindowsAndMessaging::IsZoomed(hwnd as isize as windows_sys::Win32::Foundation::HWND) != 0 } }
#[cfg(not(windows))]
pub fn window_is_maximized(_hwnd: i64) -> bool { false }