app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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);
}
}