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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
//! 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);
}
}