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
//! The OS layer. Everything above this crate is OS-neutral: the HTTP server, the verb
//! catalog, kicad-cli parsing, KiCad file editing and (phase 2) the IPC client.
//!
//! Exactly one `Native` implementation is compiled in, picked by `cfg(target_os)`.
//! Each OS file implements the `Platform` trait. A method an OS does not support yet
//! returns `unsupported(...)`, which carries an honest `not_supported_on_platform`
//! error and names the owner, so a verb never pretends.
//!
//! Ownership: windows.rs (this thread), macos.rs (Kyle), linux.rs (Barrett).

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

use serde::Serialize;
use serde_json::{json, Value};

#[derive(Clone, Debug, Default, Serialize)]
pub struct KicadInstall {
    pub version: String,
    #[serde(rename = "baseDir")]
    pub base_dir: String,
    #[serde(rename = "binDir")]
    pub bin_dir: String,
    #[serde(rename = "kicadCli")]
    pub kicad_cli: String,
    #[serde(rename = "cliVersion")]
    pub cli_version: String,
    pub source: String,
    #[serde(rename = "perUser")]
    pub per_user: bool,
}

/// What this build of the bridge can do on this OS. Reported by readiness and
/// diagnostics so an AI never assumes a Windows-only verb works on a Mac.
#[derive(Clone, Debug, Serialize)]
pub struct Capabilities {
    pub os: &'static str,
    pub owner: &'static str,
    #[serde(rename = "kicadCli")]
    pub kicad_cli: bool,
    #[serde(rename = "windowControl")]
    pub window_control: bool,
    #[serde(rename = "uiAutomation")]
    pub ui_automation: bool,
    #[serde(rename = "dialogSweep")]
    pub dialog_sweep: bool,
    #[serde(rename = "focusEtiquette")]
    pub focus_etiquette: bool,
    #[serde(rename = "ipcApi")]
    pub ipc_api: bool,
    #[serde(rename = "silentInstall")]
    pub silent_install: bool,
}

/// One top-level (or child) window as the OS reports it.
#[derive(Clone, Debug, Default, Serialize)]
pub struct WindowInfo {
    pub hwnd: u64,
    pub title: String,
    #[serde(rename = "className")]
    pub class_name: String,
    pub pid: u32,
    /// Executable base name, lower case (kicad.exe, pcbnew.exe, ...).
    pub exe: String,
    pub visible: bool,
    /// Owner window handle (0 for an unowned top level). Dialogs are owned.
    pub owner: u64,
    /// x, y, width, height in physical pixels.
    pub rect: (i32, i32, i32, i32),
    pub minimized: bool,
    pub maximized: bool,
    /// The window's thread did not answer a WM_NULL within the probe deadline.
    pub hung: bool,
}

#[derive(Clone, Debug, Default, Serialize)]
pub struct MenuItem {
    /// Menu path, top level first ("View", "3D Viewer").
    pub path: Vec<String>,
    pub id: u32,
    /// Accelerator text after the tab ("Alt+3", "Ctrl+Shift+E"), if any.
    pub accel: Option<String>,
}

#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct Placement {
    pub x: i32,
    pub y: i32,
    pub width: i32,
    pub height: i32,
    pub maximized: bool,
}

#[derive(Clone, Debug, Default, Serialize)]
pub struct ProcessInfo {
    pub pid: u32,
    pub exe: String,
    pub path: String,
    #[serde(rename = "parentPid")]
    pub parent_pid: u32,
    #[serde(rename = "startedAt")]
    pub started_at: Option<u64>,
}

/// A captured window bitmap, top-down RGB8, for the canvas-painted probe.
pub struct Capture {
    pub width: u32,
    pub height: u32,
    pub rgb: Vec<u8>,
}

fn nope<T>(what: &str) -> Result<T, String> {
    Err(format!("{what}: not implemented on {}", native().capabilities().os))
}

pub trait Platform: Sync + Send {
    fn capabilities(&self) -> Capabilities;
    /// `kicad-cli.exe` on Windows, `kicad-cli` elsewhere.
    fn cli_file_name(&self) -> &'static str;
    /// Every KiCad install this OS can see. Order does not matter; the core sorts by version.
    fn find_installs(&self) -> Vec<KicadInstall>;
    /// Per-user settings dir for a KiCad major.minor (where kicad_common.json lives).
    fn config_dir(&self, version: &str) -> Option<PathBuf>;
    /// Per-user documents dir KiCad uses for libraries and 3rdparty (Documents/KiCad/<ver>).
    fn user_dir(&self, version: &str) -> Option<PathBuf>;
    /// Make a child process invisible and detached from any console.
    fn quiet_command(&self, cmd: &mut Command);
    /// Parent of every per-version settings dir (all KiCad versions the user ever ran).
    fn config_root(&self) -> Option<PathBuf>;
    /// Parent of every per-version documents dir (Documents/KiCad).
    fn user_root(&self) -> Option<PathBuf>;
    /// The user's Documents folder (where the bridge keeps demo and staging projects).
    fn documents_dir(&self) -> Option<PathBuf>;
    /// Is a process with this id alive? None when the OS layer cannot tell.
    fn process_alive(&self, pid: u32) -> Option<bool>;

    // ---- Phase 3: window control. Every default says "not implemented here" so an
    // OS file only implements what it supports; `capabilities()` tells callers which.

    /// Every visible top-level window.
    fn list_windows(&self) -> Result<Vec<WindowInfo>, String> { nope("list_windows") }
    /// One window by handle (visible or not).
    fn window_info(&self, _hwnd: u64) -> Result<Option<WindowInfo>, String> { nope("window_info") }
    /// Every descendant of a window (class name and text) with `owner` set to its direct parent,
    /// for dialog buttons and the library-tree filter box (join on class and parent title).
    fn child_windows(&self, _hwnd: u64) -> Result<Vec<WindowInfo>, String> { nope("child_windows") }
    /// Running processes whose executable lives under `bin_dir` (or any KiCad exe when None).
    fn processes(&self, _exe_names: &[&str]) -> Result<Vec<ProcessInfo>, String> { nope("processes") }
    /// Spawn a GUI program without activating its window (SW_SHOWNOACTIVATE), stderr to a log.
    fn spawn_background(&self, _exe: &Path, _args: &[String], _env: &[(String, String)], _log: Option<&Path>) -> Result<u32, String> { nope("spawn_background") }
    fn kill_process(&self, _pid: u32) -> Result<(), String> { nope("kill_process") }
    /// Does the window's thread answer a WM_NULL within `timeout_ms`?
    fn responsive(&self, _hwnd: u64, _timeout_ms: u32) -> Result<bool, String> { nope("responsive") }
    /// Post WM_CLOSE (no wait).
    fn post_close(&self, _hwnd: u64) -> Result<(), String> { nope("post_close") }
    /// Walk the native menu bar.
    fn menu_tree(&self, _hwnd: u64) -> Result<Vec<MenuItem>, String> { nope("menu_tree") }
    /// Post WM_COMMAND with a menu id (background, no focus).
    fn menu_invoke(&self, _hwnd: u64, _id: u32) -> Result<(), String> { nope("menu_invoke") }
    /// Post a key (WM_KEYDOWN, optional WM_CHAR, WM_KEYUP) to one window's queue.
    fn post_key(&self, _hwnd: u64, _vk: u16, _ch: Option<char>) -> Result<(), String> { nope("post_key") }
    /// Post a mouse click at client coordinates to one window's queue.
    fn post_click(&self, _hwnd: u64, _x: i32, _y: i32, _double: bool) -> Result<(), String> { nope("post_click") }
    /// WM_SETTEXT on an edit control.
    fn set_text(&self, _hwnd: u64, _text: &str) -> Result<(), String> { nope("set_text") }
    /// Text of a control (WM_GETTEXT with a deadline; never blocks on a hung window).
    fn window_text(&self, _hwnd: u64, _timeout_ms: u32) -> Result<String, String> { nope("window_text") }
    /// Click a button child by label: BM_CLICK with a timeout first, UIA Invoke second.
    /// Returns the method used.
    fn click_button(&self, _dialog: u64, _labels: &[&str]) -> Result<String, String> { nope("click_button") }
    /// UI Automation: invoke the first descendant whose name contains `name`.
    fn uia_invoke(&self, _hwnd: u64, _name: &str) -> Result<String, String> { nope("uia_invoke") }
    /// UI Automation: ValuePattern.SetValue on the descendant whose name contains `name`.
    fn uia_set_value(&self, _hwnd: u64, _name: &str, _text: &str) -> Result<(), String> { nope("uia_set_value") }
    /// Set a dialog's check box (exact caption) on or off; "unchanged:" when it already was.
    fn uia_set_checked(&self, _hwnd: u64, _name: &str, _on: bool) -> Result<String, String> { nope("uia_set_checked") }
    /// UI Automation: names of descendants matching a substring (bounded).
    fn uia_find(&self, _hwnd: u64, _name: &str, _limit: usize) -> Result<Vec<String>, String> { nope("uia_find") }
    fn foreground(&self) -> Result<u64, String> { nope("foreground") }
    /// The window on top of the Z order (first visible, non-minimized, titled top-level
    /// window), which is what the user sees covering everything else; it can differ from
    /// the foreground (activated) window when a window raised itself without activation.
    fn z_top(&self) -> Result<u64, String> { nope("z_top") }
    /// Push a window to the bottom of the z-order without activating anything.
    fn push_to_background(&self, _hwnd: u64) -> Result<(), String> { nope("push_to_background") }
    /// Show without activating (SW_SHOWNOACTIVATE).
    fn show_no_activate(&self, _hwnd: u64) -> Result<(), String> { nope("show_no_activate") }
    /// The one sanctioned foreground: restore and activate (AttachThreadInput technique).
    fn bring_to_front(&self, _hwnd: u64) -> Result<bool, String> { nope("bring_to_front") }
    fn placement(&self, _hwnd: u64) -> Result<Placement, String> { nope("placement") }
    fn set_placement(&self, _hwnd: u64, _p: &Placement) -> Result<(), String> { nope("set_placement") }
    /// Is any part of the rect on a monitor?
    fn on_a_monitor(&self, _rect: (i32, i32, i32, i32)) -> Result<bool, String> { nope("on_a_monitor") }
    /// Write KiCad's own AUMID onto its standalone Start Menu shortcuts (the white taskbar
    /// icon); one row per shortcut with `written` / `already` / `missing` / `error`.
    fn repair_taskbar_shortcuts(&self, _version: &str) -> Result<Value, String> { nope("repair_taskbar_shortcuts") }
    /// Capture a window's pixels for the canvas-painted probe (PrintWindow); no focus change.
    fn capture_window(&self, _hwnd: u64) -> Result<Capture, String> { nope("capture_window") }
    /// The GL canvas child rect (class wxGLCanvas) in window-client coordinates, if any.
    fn gl_canvas_rect(&self, _hwnd: u64) -> Result<Option<(i32, i32, i32, i32)>, String> { nope("gl_canvas_rect") }
    /// Seconds since the user last touched keyboard or mouse.
    fn seconds_since_input(&self) -> Result<f64, String> { nope("seconds_since_input") }
    /// Set process DPI awareness once at startup (no-op where irrelevant).
    fn init_process(&self) {}

    // ---- Phase 3b: silent KiCad install (kicad_upgrade). The core orchestrates
    // (target version, scope, download, verify, seed); the OS file supplies the four
    // facts below. Defaults are honest: an OS with no installer path says so.

    /// Elevation and UAC facts for THIS process. Always carries the four summary keys
    /// `isAdmin`, `uacEnabled`, `consentPrompt`, `canInstallAllUsersSilently` (null where
    /// the OS has no such notion) plus the OS's raw evidence (Windows: the token
    /// elevation type and flag, EnableLUA, ConsentPromptBehaviorAdmin).
    fn elevation_info(&self) -> Result<Value, String> { nope("elevation_info") }
    /// Run a downloaded KiCad installer silently with the given scope and a deadline;
    /// the exit code comes back. On timeout the installer is killed and this is Err.
    fn run_installer(&self, _path: &Path, _all_users: bool, _timeout_secs: u64) -> Result<i32, String> { nope("run_installer") }
    /// What a KiCad installer is on this OS: "nsis-exe" (Windows), "dmg" (macOS),
    /// "distro-package" (Linux). Only nsis-exe has a silent path the bridge drives.
    fn installer_kind(&self) -> &'static str { "unknown" }
    /// The official downloads.kicad.org installer for `version` on this OS, or None
    /// when installs go through the OS package path instead (macOS, Linux).
    fn installer_download_url(&self, _version: &str) -> Option<String> { None }

    // ---- Phase 4: the etiquette loop (see docs/rust-port-plan.md "Phase 4 specification").
    // Defaults do nothing, so an OS without the loop still runs every verb; the Windows
    // implementation makes them real.

    /// Start the sentinel and event-hook threads once. Idempotent.
    fn etiquette_start(&self) {}
    /// Turn the "send a KiCad window the bridge just opened behind the user's window" guard on or
    /// off (setting `backgroundGuard`). Off means KiCad windows are never moved or pushed by the
    /// bridge at all; events are still logged so the two modes can be compared.
    fn etiquette_set_enabled(&self, _on: bool) {}
    /// Whether the background guard is on (setting `backgroundGuard`).
    fn etiquette_enabled(&self) -> bool { true }
    /// A Windows shell window (taskbar, Start, desktop, anything explorer.exe owns). The bridge
    /// never forces one of these to the foreground: doing so crashed Explorer (Taskbar.View.dll).
    fn is_shell_window(&self, _h: u64) -> bool { false }
    /// Ask (never force) for `h` to become the foreground window. True when Windows agreed.
    fn request_foreground(&self, _h: u64) -> bool { false }
    /// Cancel Windows' taskbar attention flash on bridge-owned KiCad windows (setting
    /// `activityIndicator`: progress and none suppress it, flash and both leave it).
    fn etiquette_set_flash_suppressed(&self, _on: bool) {}
    /// A window verb begins (arms the guard, snapshots the park baseline). `spawns` = the
    /// verb opens a new window and gets the long guard.
    fn etiquette_begin_verb(&self, _verb: &str, _spawns: bool) {}
    fn etiquette_end_verb(&self, _verb: &str) {}
    /// Record a KiCad process (and its children) as bridge-owned, persisted to disk.
    fn etiquette_register_pid(&self, _pid: u32, _project: Option<&Path>) {}
    /// A foreground the bridge itself sanctioned (bring_to_front with a reason): the loop
    /// ignores this window for `seconds`.
    fn etiquette_sanction(&self, _hwnd: u64, _seconds: f64) {}
    /// Recent focus transitions with their classification (the Python's focusEvents).
    fn etiquette_events(&self) -> Vec<Value> { Vec::new() }
    /// The Python's focusDebug block.
    fn etiquette_debug(&self) -> Value { Value::Null }
    /// Is this hwnd bridge-owned according to the ledger? None when there is no ledger.
    fn etiquette_owned(&self, _hwnd: u64) -> Option<bool> { None }

    // ---- Phase 5: real input for the demo tour only. Every call must refuse unless
    // `hwnd` is the measured foreground window (the tour foregrounds its beat once, with a
    // reason); nothing else in the bridge may synthesise input.

    /// Move the cursor to screen coordinates (absolute, virtual desktop).
    fn tour_mouse_move(&self, _hwnd: u64, _x: i32, _y: i32) -> Result<(), String> { nope("tour_mouse_move") }
    /// Press or release a mouse button: "left" | "middle" | "right".
    fn tour_mouse_button(&self, _hwnd: u64, _button: &str, _down: bool) -> Result<(), String> { nope("tour_mouse_button") }
    /// Wheel clicks, positive = up.
    fn tour_wheel(&self, _hwnd: u64, _clicks: i32) -> Result<(), String> { nope("tour_wheel") }
    /// Press and release a virtual key.
    fn tour_key(&self, _hwnd: u64, _vk: u16) -> Result<(), String> { nope("tour_key") }
    /// Play an audio file without a window (narration). Returns a handle for tour_audio_stop.
    fn tour_audio_play(&self, _path: &Path, _volume: f32) -> Result<u64, String> { nope("tour_audio_play") }
    fn tour_audio_stop(&self, _handle: u64) -> Result<(), String> { nope("tour_audio_stop") }

    // ---- Freerouting (kicad_autoroute engine "freerouting"). The core orchestrates
    // (status, download, verify, run, apply); the OS file supplies the release asset,
    // how it unpacks, and where it lives. Nothing here runs unless the core was asked.

    /// The Freerouting 2.4.1 release asset for this OS as (url, exact byte size, kind),
    /// kind "msi" (Windows) or "zip" (Linux). None where the bridge has no non-interactive
    /// path (macOS ships a dmg). Every build bundles its own Java runtime.
    fn freerouting_bundle(&self) -> Option<(String, u64, &'static str)> { None }
    /// Unpack a downloaded bundle into `dest` and return the executable. `unzip` is the
    /// core's std-only zip reader (extract archive into a directory) for the zip kinds;
    /// Windows runs `msiexec /a` instead. Must finish within a deadline of its own.
    fn freerouting_extract(&self, _archive: &Path, _dest: &Path, _unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> { nope("freerouting_extract") }
    /// Where the bridge keeps Freerouting: `%LOCALAPPDATA%\Adom Bridge\freerouting` on
    /// Windows, `~/.adom/freerouting` elsewhere (a temp dir when HOME is unset).
    fn freerouting_dir(&self) -> PathBuf {
        std::env::var_os("HOME").map(PathBuf::from).filter(|h| !h.as_os_str().is_empty()).unwrap_or_else(std::env::temp_dir).join(".adom").join("freerouting")
    }
}

/// Standard "this build cannot do that here" reply.
pub fn unsupported(feature: &str) -> Value {
    let caps = native().capabilities();
    json!({
        "success": false,
        "errorCode": "not_supported_on_platform",
        "error": format!("{feature} is not implemented in the {} build of the KiCad bridge yet", caps.os),
        "platform": caps.os,
        "_hint": format!("The {} port is owned by {}. Headless verbs (kicad-cli, file edits) work everywhere; window verbs are per OS.", caps.os, caps.owner),
    })
}

/// Shared helper: describe an install from its kicad-cli path, probing the exact version.
pub fn install_from_cli(cli: &std::path::Path, version_hint: &str, source: &str, per_user: bool) -> Option<KicadInstall> {
    if !cli.is_file() {
        return None;
    }
    let mut cmd = Command::new(cli);
    cmd.arg("version");
    native().quiet_command(&mut cmd);
    let cli_version = match cmd.output() {
        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
        _ => String::new(),
    };
    let bin = cli.parent()?.to_path_buf();
    let base = bin.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| bin.clone());
    let version = if !version_hint.is_empty() {
        version_hint.to_string()
    } else {
        cli_version.split('.').take(2).collect::<Vec<_>>().join(".")
    };
    Some(KicadInstall {
        version,
        base_dir: norm(&base),
        bin_dir: norm(&bin),
        kicad_cli: norm(cli),
        cli_version,
        source: source.into(),
        per_user,
    })
}

/// Where a KiCad install can sit under each drive root, beyond what the registry names (wiki #137: KiCad on D:):
/// `<root>\Program Files\KiCad`, `<root>\Program Files (x86)\KiCad` and `<root>\KiCad`, each holding version folders.
pub fn drive_install_bases(roots: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
    roots.iter().flat_map(|r| [r.join("Program Files").join("KiCad"), r.join("Program Files (x86)").join("KiCad"), r.join("KiCad")]).collect()
}

/// The version folders under one install base that hold `bin/<cli_name>`: (folder name, the kicad-cli path).
pub fn version_dirs(base: &std::path::Path, cli_name: &str) -> Vec<(String, std::path::PathBuf)> {
    let Ok(rd) = std::fs::read_dir(base) else { return Vec::new() };
    let mut out: Vec<(String, std::path::PathBuf)> = rd
        .flatten()
        .filter(|e| e.path().is_dir())
        .map(|e| (e.file_name().to_string_lossy().to_string(), e.path().join("bin").join(cli_name)))
        .filter(|(name, cli)| name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) && cli.is_file())
        .collect();
    out.sort();
    out
}

pub fn norm(p: &std::path::Path) -> String {
    p.to_string_lossy().replace('\\', "/")
}

pub fn major_minor(version: &str) -> String {
    version.split('.').take(2).collect::<Vec<_>>().join(".")
}

/// SPICE through KiCad's ngspice library (kicad_simulate).
pub mod ngspice;

#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub use windows::Native;

#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
pub use macos::Native;

#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::Native;

static NATIVE: Native = Native;

pub fn native() -> &'static dyn Platform {
    &NATIVE
}

#[cfg(test)]
mod install_root_tests {
    use super::*;

    #[test]
    fn a_kicad_on_another_drive_is_found_under_its_root() {
        // a fake drive root (wiki #137: D:\Program Files\KiCad\9.0 and D:\KiCad\10.0)
        let root = std::env::temp_dir().join(format!("kb-drive-{}", std::process::id()));
        for v in [root.join("Program Files/KiCad/9.0/bin"), root.join("KiCad/10.0/bin"), root.join("KiCad/notes")] {
            std::fs::create_dir_all(&v).unwrap();
        }
        std::fs::write(root.join("Program Files/KiCad/9.0/bin/kicad-cli.exe"), "").unwrap();
        std::fs::write(root.join("KiCad/10.0/bin/kicad-cli.exe"), "").unwrap();
        let bases = drive_install_bases(&[root.clone()]);
        assert_eq!(bases.len(), 3);
        let found: Vec<String> = bases.iter().flat_map(|b| version_dirs(b, "kicad-cli.exe")).map(|(n, _)| n).collect();
        assert_eq!(found, vec!["9.0".to_string(), "10.0".to_string()], "every version folder with a kicad-cli, nothing else");
        let _ = std::fs::remove_dir_all(&root);
    }
}