master
Colby Knox Restore 25 files to their state before PR #61: the wiki's pr revert left them truncated to the diff's context 4f3a0f4 8d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
//! Windows implementation. Registry (HKLM/HKCU SOFTWARE\KiCad\<ver>\InstallationPath) plus
//! the standard install folders, %APPDATA%/kicad/<ver> settings, %USERPROFILE%/Documents/KiCad/<ver>.
//!
//! Phase 3 (window control, UIA, dialog primitives) lives in `win/`: one module per family
//! of primitives, each ported from the Python handler that carried the measurements. This
//! file only converts trait-side u64 handles into HWNDs and delegates. Conventions callers
//! rely on:
//! - `child_windows` returns every DESCENDANT (EnumChildWindows semantics) with `owner` set
//!   to the direct parent, so "the `Edit` under the `searchCtrl` parent" is a join on
//!   class_name and the parent's title.
//! - `gl_canvas_rect` is (x, y, w, h) relative to the top-left of the window rect, which is
//!   the box `capture_window` renders, so the probe can index the capture directly.
//! - `bring_to_front` returns the MEASURED result: GetForegroundWindow afterwards.
//! - Every call Windows would make synchronous on KiCad's thread is gated on a WM_NULL probe
//!   and refused (Err) on a hung window; nothing here can block the bridge.

#[path = "win/mod.rs"]
mod win;

use std::path::{Path, PathBuf};
use std::process::Command;

use crate::{install_from_cli, major_minor, Capabilities, Capture, KicadInstall, MenuItem, Placement, Platform, ProcessInfo, WindowInfo};
use win::{hwnd, live};

pub struct Native;

fn env_path(var: &str) -> Option<PathBuf> {
    std::env::var(var).ok().filter(|s| !s.is_empty()).map(PathBuf::from)
}

/// ab provisions bridges with a stripped environment where APPDATA/LOCALAPPDATA can be
/// missing (documented in the Python bridge). Fall back from USERPROFILE.
fn userprofile() -> Option<PathBuf> {
    env_path("USERPROFILE").or_else(|| std::env::var("HOMEDRIVE").ok().zip(std::env::var("HOMEPATH").ok()).map(|(d, p)| PathBuf::from(format!("{d}{p}"))))
}

fn appdata() -> Option<PathBuf> {
    env_path("APPDATA").or_else(|| userprofile().map(|u| u.join("AppData").join("Roaming")))
}

fn localappdata() -> Option<PathBuf> {
    env_path("LOCALAPPDATA").or_else(|| userprofile().map(|u| u.join("AppData").join("Local")))
}

fn registry_installs() -> Vec<KicadInstall> {
    use winreg::enums::*;
    use winreg::RegKey;
    let mut out = Vec::new();
    for (hive, name, per_user) in [(HKEY_LOCAL_MACHINE, "HKLM", false), (HKEY_CURRENT_USER, "HKCU", true)] {
        let root = RegKey::predef(hive);
        let Ok(k) = root.open_subkey("SOFTWARE\\KiCad") else { continue };
        for ver in k.enum_keys().flatten() {
            let Ok(vk) = k.open_subkey(&ver) else { continue };
            let Ok(path): Result<String, _> = vk.get_value("InstallationPath") else { continue };
            let cli = Path::new(&path).join("bin").join("kicad-cli.exe");
            if let Some(i) = install_from_cli(&cli, &ver, &format!("registry {name}"), per_user) {
                out.push(i);
            }
        }
    }
    out
}

/// KiCad's uninstall entries (HKLM, HKLM WOW6432Node, HKCU): InstallLocation names the install root even when the
/// SOFTWARE\KiCad key is missing (wiki #137).
fn uninstall_installs() -> Vec<KicadInstall> {
    use winreg::enums::*;
    use winreg::RegKey;
    let mut out = Vec::new();
    for (hive, key, per_user) in [
        (HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", false),
        (HKEY_LOCAL_MACHINE, "SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", false),
        (HKEY_CURRENT_USER, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", true),
    ] {
        let Ok(k) = RegKey::predef(hive).open_subkey(key) else { continue };
        for sub in k.enum_keys().flatten() {
            let Ok(e) = k.open_subkey(&sub) else { continue };
            let name: String = e.get_value("DisplayName").unwrap_or_default();
            if !name.to_ascii_lowercase().starts_with("kicad") {
                continue;
            }
            let Ok(loc): Result<String, _> = e.get_value("InstallLocation") else { continue };
            let ver: String = e.get_value("DisplayVersion").unwrap_or_default();
            let cli = Path::new(loc.trim_matches('"')).join("bin").join("kicad-cli.exe");
            if let Some(i) = install_from_cli(&cli, &crate::major_minor(&ver), "uninstall key", per_user) {
                out.push(i);
            }
        }
    }
    out
}

/// Every fixed drive's root (C:\, D:\, ...): KiCad may be installed on any of them (wiki #137).
fn fixed_drive_roots() -> Vec<PathBuf> {
    use windows::core::PCWSTR;
    use windows::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives};
    const DRIVE_FIXED: u32 = 3;
    let mask = unsafe { GetLogicalDrives() };
    (0..26u32)
        .filter(|i| mask & (1 << i) != 0)
        .map(|i| format!("{}:\\", (b'A' + i as u8) as char))
        .filter(|root| {
            let w: Vec<u16> = root.encode_utf16().chain(Some(0)).collect();
            unsafe { GetDriveTypeW(PCWSTR(w.as_ptr())) == DRIVE_FIXED }
        })
        .map(PathBuf::from)
        .collect()
}

/// kicad-cli on PATH: a portable or unusual install the other searches miss.
fn path_installs() -> Vec<KicadInstall> {
    let Some(path) = std::env::var_os("PATH") else { return Vec::new() };
    std::env::split_paths(&path)
        .map(|d| d.join("kicad-cli.exe"))
        .filter(|c| c.is_file())
        .filter_map(|c| install_from_cli(&c, "", "PATH", false))
        .collect()
}

fn folder_installs() -> Vec<KicadInstall> {
    let mut bases: Vec<(PathBuf, bool)> = Vec::new();
    for var in ["ProgramFiles", "ProgramFiles(x86)"] {
        if let Some(p) = env_path(var) {
            bases.push((p.join("KiCad"), false));
        }
    }
    if let Some(l) = localappdata() {
        bases.push((l.join("Programs").join("KiCad"), true));
    }
    // and the same folders on every fixed drive, not only the system one (wiki #137: KiCad on D:)
    for b in crate::drive_install_bases(&fixed_drive_roots()) {
        if !bases.iter().any(|(x, _)| x.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())) {
            bases.push((b, false));
        }
    }
    let mut out = Vec::new();
    for (base, per_user) in bases {
        for (name, cli) in crate::version_dirs(&base, "kicad-cli.exe") {
            if let Some(i) = install_from_cli(&cli, &name, "folder", per_user) {
                out.push(i);
            }
        }
    }
    out
}

impl Platform for Native {
    fn capabilities(&self) -> Capabilities {
        Capabilities {
            os: "windows",
            owner: "the kicad-bridge maintainer thread",
            kicad_cli: true,
            window_control: true,
            ui_automation: true,
            dialog_sweep: true,
            focus_etiquette: true,  // phase 4: win/etiquette.rs
            ipc_api: false,        // phase 2
            silent_install: true,  // phase 3b: kicad_upgrade (win/install.rs)
        }
    }
    fn cli_file_name(&self) -> &'static str {
        "kicad-cli.exe"
    }
    fn find_installs(&self) -> Vec<KicadInstall> {
        // the registry first, then the uninstall entries, every fixed drive's install folders, then PATH (wiki #137)
        let mut installs = registry_installs();
        for f in uninstall_installs().into_iter().chain(folder_installs()).chain(path_installs()) {
            if !installs.iter().any(|i| i.bin_dir.eq_ignore_ascii_case(&f.bin_dir)) {
                installs.push(f);
            }
        }
        installs
    }
    fn config_dir(&self, version: &str) -> Option<PathBuf> {
        Some(appdata()?.join("kicad").join(major_minor(version)))
    }
    fn user_dir(&self, version: &str) -> Option<PathBuf> {
        Some(userprofile()?.join("Documents").join("KiCad").join(major_minor(version)))
    }
    fn quiet_command(&self, cmd: &mut Command) {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        cmd.stdin(std::process::Stdio::null());
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    fn config_root(&self) -> Option<PathBuf> {
        Some(appdata()?.join("kicad"))
    }
    fn user_root(&self) -> Option<PathBuf> {
        Some(userprofile()?.join("Documents").join("KiCad"))
    }
    fn documents_dir(&self) -> Option<PathBuf> {
        Some(userprofile()?.join("Documents"))
    }
    fn process_alive(&self, pid: u32) -> Option<bool> {
        // OpenProcess + GetExitCodeProcess (no tasklist child, no console blip).
        win::process::alive(pid)
    }

    // ---- Phase 3: window control, dialogs, UIA. See win/ for the primitives.

    fn list_windows(&self) -> Result<Vec<WindowInfo>, String> {
        Ok(win::enumerate::list_visible())
    }
    fn window_info(&self, h: u64) -> Result<Option<WindowInfo>, String> {
        if h == 0 || !win::is_window(hwnd(h)) {
            return Ok(None);
        }
        let mut cache = win::enumerate::ExeCache::default();
        Ok(Some(win::enumerate::info(hwnd(h), &mut cache, 150)))
    }
    fn child_windows(&self, h: u64) -> Result<Vec<WindowInfo>, String> {
        Ok(win::enumerate::children(live(h)?))
    }
    fn processes(&self, exe_names: &[&str]) -> Result<Vec<ProcessInfo>, String> {
        Ok(win::process::list(exe_names))
    }
    fn spawn_background(&self, exe: &Path, args: &[String], env: &[(String, String)], log: Option<&Path>) -> Result<u32, String> {
        win::process::spawn_background(exe, args, env, log)
    }
    fn kill_process(&self, pid: u32) -> Result<(), String> {
        win::process::kill(pid)
    }
    fn responsive(&self, h: u64, timeout_ms: u32) -> Result<bool, String> {
        Ok(win::responsive(live(h)?, timeout_ms))
    }
    fn post_close(&self, h: u64) -> Result<(), String> {
        win::messages::post_close(live(h)?)
    }
    fn menu_tree(&self, h: u64) -> Result<Vec<MenuItem>, String> {
        win::menu::tree(live(h)?)
    }
    fn menu_invoke(&self, h: u64, id: u32) -> Result<(), String> {
        win::menu::invoke(live(h)?, id)
    }
    fn post_key(&self, h: u64, vk: u16, ch: Option<char>) -> Result<(), String> {
        win::messages::post_key(live(h)?, vk, ch)
    }
    fn post_click(&self, h: u64, x: i32, y: i32, double: bool) -> Result<(), String> {
        win::messages::post_click(live(h)?, x, y, double)
    }
    fn set_text(&self, h: u64, text: &str) -> Result<(), String> {
        win::messages::set_text(live(h)?, text)
    }
    fn window_text(&self, h: u64, timeout_ms: u32) -> Result<String, String> {
        win::messages::window_text(live(h)?, timeout_ms)
    }
    fn click_button(&self, dialog: u64, labels: &[&str]) -> Result<String, String> {
        let d = live(dialog)?;
        if let Some(method) = win::messages::bm_click(d, labels) {
            return Ok(method);
        }
        // No Win32 Button child with that caption (a wx-drawn button): UIA Invoke, exact caption.
        win::uia::click_button(d, labels)
    }
    fn uia_invoke(&self, h: u64, name: &str) -> Result<String, String> {
        win::uia::invoke(live(h)?, name)
    }
    fn uia_set_checked(&self, h: u64, name: &str, on: bool) -> Result<String, String> {
        win::uia::set_checked(live(h)?, name, on)
    }
    fn uia_set_value(&self, h: u64, name: &str, text: &str) -> Result<(), String> {
        win::uia::set_value(live(h)?, name, text)
    }
    fn uia_find(&self, h: u64, name: &str, limit: usize) -> Result<Vec<String>, String> {
        win::uia::find_names(live(h)?, name, limit)
    }
    fn foreground(&self) -> Result<u64, String> {
        Ok(win::focus::foreground())
    }
    fn push_to_background(&self, h: u64) -> Result<(), String> {
        win::focus::push_to_background(live(h)?)
    }
    fn z_top(&self) -> Result<u64, String> {
        Ok(win::enumerate::z_top())
    }
    fn show_no_activate(&self, h: u64) -> Result<(), String> {
        win::focus::show_no_activate(live(h)?)
    }
    fn bring_to_front(&self, h: u64) -> Result<bool, String> {
        win::focus::bring_to_front(live(h)?)
    }
    fn placement(&self, h: u64) -> Result<Placement, String> {
        win::focus::placement(live(h)?)
    }
    fn set_placement(&self, h: u64, p: &Placement) -> Result<(), String> {
        win::focus::set_placement(live(h)?, p)
    }
    fn on_a_monitor(&self, rect: (i32, i32, i32, i32)) -> Result<bool, String> {
        Ok(win::enumerate::on_a_monitor(rect))
    }
    fn repair_taskbar_shortcuts(&self, version: &str) -> Result<serde_json::Value, String> {
        let rows = win::shortcuts::repair_kicad_shortcut_aumids(version)?;
        Ok(serde_json::Value::Array(rows.into_iter().map(|r| serde_json::json!({"path": r.path, "aumid": r.aumid, "outcome": r.outcome, "detail": r.detail})).collect()))
    }
    fn capture_window(&self, h: u64) -> Result<Capture, String> {
        win::capture::capture(live(h)?)
    }
    fn gl_canvas_rect(&self, h: u64) -> Result<Option<(i32, i32, i32, i32)>, String> {
        Ok(win::enumerate::gl_canvas_rect(live(h)?))
    }
    fn seconds_since_input(&self) -> Result<f64, String> {
        win::messages::seconds_since_input()
    }
    // ---- Phase 3b: silent install. See win/install.rs.

    fn elevation_info(&self) -> Result<serde_json::Value, String> {
        win::install::elevation_info()
    }
    fn run_installer(&self, path: &Path, all_users: bool, timeout_secs: u64) -> Result<i32, String> {
        win::install::run_installer(path, all_users, timeout_secs)
    }
    fn installer_kind(&self) -> &'static str {
        "nsis-exe"
    }
    fn installer_download_url(&self, version: &str) -> Option<String> {
        Some(win::install::download_url(version))
    }
    // ---- Phase 4: the etiquette loop. See win/etiquette.rs.

    fn etiquette_start(&self) {
        win::etiquette::start()
    }
    fn etiquette_set_enabled(&self, on: bool) {
        win::etiquette::set_enabled(on)
    }
    fn etiquette_enabled(&self) -> bool {
        win::etiquette::enabled()
    }
    fn is_shell_window(&self, h: u64) -> bool {
        win::etiquette::is_shell_hwnd(h)
    }
    fn request_foreground(&self, h: u64) -> bool {
        win::focus::request_foreground(win::hwnd(h))
    }
    fn etiquette_set_flash_suppressed(&self, on: bool) {
        win::etiquette::set_flash_suppressed(on)
    }
    fn etiquette_begin_verb(&self, verb: &str, spawns: bool) {
        win::etiquette::begin_verb(verb, spawns)
    }
    fn etiquette_end_verb(&self, verb: &str) {
        win::etiquette::end_verb(verb)
    }
    fn etiquette_register_pid(&self, pid: u32, project: Option<&Path>) {
        win::etiquette::register_pid(pid, project)
    }
    fn etiquette_sanction(&self, h: u64, seconds: f64) {
        win::etiquette::sanction(h, seconds)
    }
    fn etiquette_events(&self) -> Vec<serde_json::Value> {
        win::etiquette::events()
    }
    fn etiquette_debug(&self) -> serde_json::Value {
        win::etiquette::debug()
    }
    fn etiquette_owned(&self, h: u64) -> Option<bool> {
        Some(win::etiquette::owned(h))
    }

    // ---- Phase 5: tour input and narration. See win/tour_input.rs. Every input call is
    // refused unless `h` is the measured foreground window.

    fn tour_mouse_move(&self, h: u64, x: i32, y: i32) -> Result<(), String> {
        win::tour_input::mouse_move(h, x, y)
    }
    fn tour_mouse_button(&self, h: u64, button: &str, down: bool) -> Result<(), String> {
        win::tour_input::mouse_button(h, button, down)
    }
    fn tour_wheel(&self, h: u64, clicks: i32) -> Result<(), String> {
        win::tour_input::wheel(h, clicks)
    }
    fn tour_key(&self, h: u64, vk: u16) -> Result<(), String> {
        win::tour_input::key_press(h, vk)
    }
    fn tour_audio_play(&self, path: &Path, volume: f32) -> Result<u64, String> {
        win::tour_input::audio_play(path, volume)
    }
    fn tour_audio_stop(&self, handle: u64) -> Result<(), String> {
        win::tour_input::audio_stop(handle)
    }
    // ---- Freerouting: the windows-x64 msi (bundled Java). `msiexec /a` is an
    // administrative (extract-only) install: no registry, no shortcuts, no elevation, two
    // seconds; it leaves `<dest>\freerouting\freerouting.exe` plus `runtime\`. Size
    // verified 2026-09-12.

    fn freerouting_bundle(&self) -> Option<(String, u64, &'static str)> {
        Some(("https://github.com/freerouting/freerouting/releases/download/v2.4.1/freerouting-2.4.1-windows-x64.msi".into(), 88_235_097, "msi"))
    }
    fn freerouting_extract(&self, archive: &Path, dest: &Path, _unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> {
        if !archive.is_file() {
            return Err(format!("{} does not exist", archive.display()));
        }
        // msiexec wants the .msi extension on the package.
        let msi = if archive.extension().map(|e| e.eq_ignore_ascii_case("msi")).unwrap_or(false) {
            archive.to_path_buf()
        } else {
            let renamed = archive.with_extension("msi");
            std::fs::rename(archive, &renamed).map_err(|e| format!("rename to .msi: {e}"))?;
            renamed
        };
        // The administrative image goes into a FRESH, EMPTY subfolder that does not hold the
        // package: pointing TARGETDIR at the folder the .msi sits in returned 1603 (fatal),
        // while the probe that worked on ConfRoomROG extracted into an empty "extract" dir.
        let image = dest.join("image");
        let _ = std::fs::remove_dir_all(&image);
        std::fs::create_dir_all(&image).map_err(|e| format!("cannot create {}: {e}", image.display()))?;
        let mut cmd = Command::new("msiexec");
        cmd.arg("/a").arg(&msi).arg("/qn");
        {
            // Property syntax is TARGETDIR="C:\path with spaces": the quotes belong INSIDE the
            // token. Letting Command quote the whole token made msiexec show its usage box
            // (hidden) and wait forever (ConfRoomROG, 2026-09-12).
            use std::os::windows::process::CommandExt;
            cmd.raw_arg(format!("TARGETDIR=\"{}\"", image.display()));
        }
        cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
        {
            use std::os::windows::process::CommandExt;
            const CREATE_NO_WINDOW: u32 = 0x0800_0000;
            cmd.creation_flags(CREATE_NO_WINDOW);
        }
        let mut child = cmd.spawn().map_err(|e| format!("msiexec launch failed: {e}"))?;
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
        let code = loop {
            match child.try_wait() {
                Ok(Some(status)) => break status.code().unwrap_or(-1),
                Ok(None) => {}
                Err(e) => return Err(format!("waiting on msiexec failed: {e}")),
            }
            if std::time::Instant::now() >= deadline {
                let _ = child.kill();
                let _ = child.wait();
                return Err("msiexec /a did not finish within 300 s; killed".into());
            }
            std::thread::sleep(std::time::Duration::from_millis(250));
        };
        if code != 0 {
            return Err(format!("msiexec /a exited with {code} (1619 = package unreadable, 1602 = cancelled, 1603 = fatal)"));
        }
        // The admin install also drops a copy of the package next to the tree; it is
        // 88 MB of nothing the router needs.
        if let Ok(rd) = std::fs::read_dir(dest) {
            for e in rd.flatten() {
                let p = e.path();
                if p != msi && p.extension().map(|x| x.eq_ignore_ascii_case("msi")).unwrap_or(false) {
                    let _ = std::fs::remove_file(&p);
                }
            }
        }
        // Lift the app folder out of the image dir so the bundle sits at <dest>\freerouting,
        // where status() and find_exe look; drop the image dir (it also holds a copy of the msi).
        let lifted = dest.join("freerouting");
        let _ = std::fs::remove_dir_all(&lifted);
        if image.join("freerouting").is_dir() {
            std::fs::rename(image.join("freerouting"), &lifted).map_err(|e| format!("move extracted app: {e}"))?;
        }
        let _ = std::fs::remove_dir_all(&image);
        let exe = dest.join("freerouting").join("freerouting.exe");
        if exe.is_file() {
            return Ok(exe);
        }
        // Fall back to a shallow search in case the package layout moved.
        let mut stack = vec![dest.to_path_buf()];
        let mut depth = 0;
        while !stack.is_empty() && depth < 3 {
            let mut next = Vec::new();
            for d in stack {
                if let Ok(rd) = std::fs::read_dir(&d) {
                    for e in rd.flatten() {
                        let p = e.path();
                        if p.is_dir() {
                            next.push(p);
                        } else if p.file_name().map(|n| n.eq_ignore_ascii_case("freerouting.exe")).unwrap_or(false) {
                            return Ok(p);
                        }
                    }
                }
            }
            stack = next;
            depth += 1;
        }
        Err(format!("msiexec /a succeeded but no freerouting.exe under {}", dest.display()))
    }
    fn freerouting_dir(&self) -> PathBuf {
        localappdata().unwrap_or_else(std::env::temp_dir).join("Adom Bridge").join("freerouting")
    }
    fn init_process(&self) {
        // Per-monitor DPI awareness so every rect is in physical pixels (kicad_ui.py did
        // this at import). Fails harmlessly if the manifest or an earlier call already set it.
        use windows::Win32::UI::HiDpi::{SetProcessDpiAwareness, PROCESS_PER_MONITOR_DPI_AWARE};
        use windows::Win32::UI::WindowsAndMessaging::SetProcessDPIAware;
        // SAFETY: process-wide setting, no pointers.
        unsafe {
            if SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE).is_err() {
                let _ = SetProcessDPIAware();
            }
        }
    }
}

/// Live-API smoke tests. They only assert what any Windows desktop session guarantees
/// (a foreground window exists, the idle clock ticks, bad handles are refused) and never
/// touch a real KiCad; John runs the KiCad probes by hand on ConfRoomROG.
#[cfg(all(test, windows))]
mod tests {
    use super::*;

    #[test]
    fn lists_at_least_the_desktop_shell() {
        let ws = Native.list_windows().unwrap();
        assert!(!ws.is_empty(), "EnumWindows returned nothing");
        assert!(ws.iter().all(|w| w.hwnd != 0 && w.visible));
    }

    #[test]
    fn bad_handles_are_refused_not_hung() {
        assert!(matches!(Native.window_info(0), Ok(None)));
        assert!(matches!(Native.window_info(0xDEAD_BEEF_0000), Ok(None)));
        assert!(Native.responsive(0, 100).is_err());
        assert!(Native.post_close(0).is_err());
        assert!(Native.menu_tree(0).is_err());
        assert!(Native.child_windows(0).is_err());
        assert!(Native.capture_window(0).is_err());
    }

    #[test]
    fn foreground_and_idle_clock_answer() {
        assert!(Native.foreground().is_ok());
        let idle = Native.seconds_since_input().unwrap();
        assert!(idle.is_finite() && idle >= 0.0);
    }

    #[test]
    fn foreground_window_round_trips_through_info() {
        let fg = Native.foreground().unwrap();
        if fg == 0 {
            return; // a session with no foreground (service session); nothing to check
        }
        let info = Native.window_info(fg).unwrap().expect("foreground window exists");
        assert_eq!(info.hwnd, fg);
        assert!(info.pid != 0);
        assert!(Native.responsive(fg, 500).unwrap_or(false) || info.hung);
        let p = Native.placement(fg).unwrap();
        assert!(p.width > 0 || p.maximized);
        assert!(Native.on_a_monitor(info.rect).unwrap() || info.minimized);
    }

    #[test]
    fn our_own_process_is_alive_and_listed() {
        let me = std::process::id();
        assert_eq!(Native.process_alive(me), Some(true));
        assert_eq!(Native.process_alive(0), Some(false));
        let exe = std::env::current_exe().unwrap().file_name().unwrap().to_string_lossy().to_lowercase();
        let ps = Native.processes(&[&exe]).unwrap();
        assert!(ps.iter().any(|p| p.pid == me), "{exe} not found in {ps:?}");
    }

    #[test]
    fn spawn_background_runs_and_logs() {
        let dir = std::env::temp_dir().join(format!("kicad-bridge-spawn-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let log = dir.join("spawn.log");
        let cmd = Path::new(r"C:\Windows\System32\cmd.exe");
        let pid = Native.spawn_background(cmd, &["/c".into(), "echo hello-from-spawn".into()], &[("KICAD_BRIDGE_TEST".into(), "1".into())], Some(&log)).unwrap();
        assert!(pid != 0);
        for _ in 0..50 {
            if Native.process_alive(pid) == Some(false) {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(100));
        }
        let text = std::fs::read_to_string(&log).unwrap_or_default();
        assert!(text.contains("hello-from-spawn"), "log was: {text:?}");
        let _ = std::fs::remove_dir_all(&dir);
    }
}