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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
//! Linux implementation (owner: Barrett). Headless parts are complete: kicad-cli on PATH,
//! /usr and /usr/local, snap and flatpak locations, XDG config, ~/Documents/KiCad/<ver>.
//! Window control on Linux depends on the session (X11 versus Wayland portals); add it
//! here in phase 3 and flip the capability flags.
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{json, Value};
use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform, ProcessInfo};
pub struct Native;
fn home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
impl Platform for Native {
fn capabilities(&self) -> Capabilities {
Capabilities {
os: "linux",
owner: "Barrett (Ubuntu build of the bridge)",
kicad_cli: true,
window_control: false,
ui_automation: false,
dialog_sweep: false,
focus_etiquette: false,
ipc_api: true,
silent_install: false,
}
}
fn cli_file_name(&self) -> &'static str {
"kicad-cli"
}
fn find_installs(&self) -> Vec<KicadInstall> {
let mut out = Vec::new();
// Wiki #127: a Flatpak KiCad has no kicad-cli on the host. The bridge writes its own
// launchers (kicad-cli, kicad, pcbnew, eeschema) that run the Flatpak with the host's
// files and /tmp shared, so every path the bridge hands kicad-cli resolves inside the
// sandbox, and the API socket KiCad opens under /tmp/kicad is the host's.
if let Some((installation, bin)) = flatpak::ensure_wrappers() {
if let Some(inst) = install_from_cli(&bin.join("kicad-cli"), "", installation, installation == "flatpak (user)") {
out.push(inst);
}
}
let mut candidates: Vec<(PathBuf, &str)> = vec![
(PathBuf::from("/usr/bin/kicad-cli"), "distro package"),
(PathBuf::from("/usr/local/bin/kicad-cli"), "local build"),
(PathBuf::from("/snap/bin/kicad-cli"), "snap"),
];
if let Some(h) = home() {
candidates.push((h.join(".local/bin/kicad-cli"), "~/.local/bin"));
}
if let Ok(path) = std::env::var("PATH") {
for dir in path.split(':') {
candidates.push((PathBuf::from(dir).join("kicad-cli"), "PATH"));
}
}
let have_flatpak = !out.is_empty();
for (c, source) in candidates {
if out.iter().any(|i: &KicadInstall| i.kicad_cli == crate::norm(&c)) {
continue;
}
// A user's own wrapper around the same Flatpak adds nothing over ours, and theirs
// may not share /tmp with the sandbox.
if have_flatpak && flatpak::is_flatpak_wrapper(&c) {
continue;
}
if let Some(inst) = install_from_cli(&c, "", source, false) {
out.push(inst);
}
}
out
}
fn config_dir(&self, version: &str) -> Option<PathBuf> {
Some(self.config_root()?.join(major_minor(version)))
}
fn user_dir(&self, version: &str) -> Option<PathBuf> {
Some(home()?.join("Documents/KiCad").join(major_minor(version)))
}
fn quiet_command(&self, cmd: &mut Command) {
cmd.stdin(std::process::Stdio::null());
}
fn config_root(&self) -> Option<PathBuf> {
// KiCad's own override wins (it passes into a Flatpak sandbox with the rest of the environment).
if let Ok(d) = std::env::var("KICAD_CONFIG_HOME") {
if !d.is_empty() {
return Some(PathBuf::from(d));
}
}
// A Flatpak KiCad keeps its settings under ~/.var/app/<id>/config, not ~/.config.
if flatpak::installation().is_some() && !native_cli_present() {
return Some(home()?.join(".var/app").join(flatpak::APP_ID).join("config/kicad"));
}
let base = std::env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from).or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("kicad"))
}
fn user_root(&self) -> Option<PathBuf> {
Some(home()?.join("Documents/KiCad"))
}
fn documents_dir(&self) -> Option<PathBuf> {
Some(home()?.join("Documents"))
}
fn process_alive(&self, pid: u32) -> Option<bool> {
// A zombie still has a /proc entry; it is not alive.
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => Some(!matches!(stat_field(&stat, 0).as_deref(), Some("Z") | Some("X"))),
Err(_) => Some(false),
}
}
fn processes(&self, exe_names: &[&str]) -> Result<Vec<ProcessInfo>, String> {
let wanted: Vec<String> = exe_names.iter().map(|n| n.trim_end_matches(".exe").to_lowercase()).collect();
let mut out = Vec::new();
for e in std::fs::read_dir("/proc").map_err(|e| e.to_string())?.flatten() {
let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::<u32>().ok()) else { continue };
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { continue };
// comm is truncated to 15 bytes; the exe link (when readable) has the full name.
let path = std::fs::read_link(format!("/proc/{pid}/exe")).map(|p| p.to_string_lossy().trim_end_matches(" (deleted)").to_string()).unwrap_or_default();
let comm = stat.get(stat.find('(').map(|i| i + 1).unwrap_or(0)..stat.rfind(')').unwrap_or(0)).unwrap_or("").to_string();
let exe = Path::new(&path).file_name().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or(comm);
if !wanted.is_empty() && !wanted.contains(&exe.to_lowercase()) {
continue;
}
if matches!(stat_field(&stat, 0).as_deref(), Some("Z") | Some("X")) {
continue;
}
let parent_pid = stat_field(&stat, 1).and_then(|s| s.parse().ok()).unwrap_or(0);
out.push(ProcessInfo { pid, exe, path, parent_pid, started_at: None });
}
Ok(out)
}
fn spawn_background(&self, exe: &Path, args: &[String], env: &[(String, String)], log: Option<&Path>) -> Result<u32, String> {
use std::os::unix::process::CommandExt;
let mut cmd = Command::new(exe);
cmd.args(args).stdin(std::process::Stdio::null());
for (k, v) in env {
cmd.env(k, v);
}
match log.map(std::fs::File::create) {
Some(Ok(f)) => {
let f2 = f.try_clone().map_err(|e| e.to_string())?;
cmd.stdout(f).stderr(f2);
}
_ => {
cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
}
}
// Its own session: KiCad outlives a bridge restart and never gets the bridge's signals.
cmd.process_group(0);
let mut child = cmd.spawn().map_err(|e| format!("spawn {}: {e}", exe.display()))?;
let pid = child.id();
// Reap it when it exits so it does not linger as a zombie.
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(pid)
}
fn kill_process(&self, pid: u32) -> Result<(), String> {
let st = Command::new("kill").arg("-TERM").arg(pid.to_string()).status().map_err(|e| e.to_string())?;
if !st.success() {
return Err(format!("kill {pid} failed"));
}
for _ in 0..30 {
if self.process_alive(pid) == Some(false) {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let _ = Command::new("kill").arg("-KILL").arg(pid.to_string()).status();
Ok(())
}
// ---- Phase 3b: kicad_upgrade. No silent installer path here: KiCad on Linux comes
// from the distro package, the PPA, snap or flatpak. The verb explains that; these four keep the facts honest.
fn elevation_info(&self) -> Result<Value, String> {
let mut cmd = Command::new("id");
cmd.arg("-u");
self.quiet_command(&mut cmd);
let euid: Option<u32> = cmd.output().ok().filter(|o| o.status.success()).and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok());
let root = euid.map(|u| u == 0);
Ok(json!({
"platform": "linux",
"user": std::env::var("USER").or_else(|_| std::env::var("LOGNAME")).unwrap_or_default(),
"euid": euid,
"isAdmin": root,
"uacEnabled": Value::Null,
"consentPrompt": Value::Null,
"canInstallAllUsersSilently": root,
"note": "no UAC on Linux; isAdmin is euid == 0. Package installs need sudo, which the bridge never runs",
}))
}
fn installer_kind(&self) -> &'static str {
"distro-package"
}
fn installer_download_url(&self, _version: &str) -> Option<String> {
None
}
// ---- Freerouting: the linux-x64 zip (bundled Java). 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-linux-x64.zip".into(), 91_927_019, "zip"))
}
fn freerouting_extract(&self, archive: &Path, dest: &Path, unzip: &dyn Fn(&Path, &Path) -> Result<(), String>) -> Result<PathBuf, String> {
unzip(archive, dest)?;
// The zip carries unix modes the std-only reader does not restore: the launcher,
// and the runtime's helpers, need +x.
let root = std::fs::read_dir(dest)
.map_err(|e| e.to_string())?
.flatten()
.map(|e| e.path())
.find(|p| p.is_dir() && p.join("bin").join("freerouting").is_file())
.ok_or_else(|| format!("no <folder>/bin/freerouting under {} after unzip", dest.display()))?;
for rel in ["bin/freerouting", "lib/runtime/lib/jexec", "lib/runtime/lib/jspawnhelper"] {
let p = root.join(rel);
if p.is_file() {
chmod_x(&p)?;
}
}
if let Ok(rd) = std::fs::read_dir(root.join("lib").join("runtime").join("bin")) {
for e in rd.flatten() {
if e.path().is_file() {
chmod_x(&e.path())?;
}
}
}
Ok(root.join("bin").join("freerouting"))
}
}
/// Field `n` after the `(comm)` of /proc/<pid>/stat: 0 = state, 1 = ppid.
fn stat_field(stat: &str, n: usize) -> Option<String> {
stat.get(stat.rfind(')')? + 1..)?.split_whitespace().nth(n).map(str::to_string)
}
fn native_cli_present() -> bool {
["/usr/bin/kicad-cli", "/usr/local/bin/kicad-cli", "/snap/bin/kicad-cli"].iter().any(|p| Path::new(p).is_file())
}
/// KiCad from Flathub (`org.kicad.KiCad`), system or per-user.
pub mod flatpak {
use std::path::{Path, PathBuf};
pub const APP_ID: &str = "org.kicad.KiCad";
fn home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
/// "flatpak (user)" or "flatpak (system)" when the app is deployed; user wins, as flatpak run does.
pub fn installation() -> Option<&'static str> {
if home().is_some_and(|h| h.join(".local/share/flatpak/app").join(APP_ID).join("current").exists()) {
return Some("flatpak (user)");
}
if Path::new("/var/lib/flatpak/app").join(APP_ID).join("current").exists() {
return Some("flatpak (system)");
}
None
}
/// Where the bridge keeps its launchers: $ADOM_BRIDGE_DATA_DIR/bin, else ~/.local/share/adom-kicad-bridge/bin.
pub fn wrapper_dir() -> Option<PathBuf> {
match std::env::var("ADOM_BRIDGE_DATA_DIR") {
Ok(d) if !d.is_empty() => Some(PathBuf::from(d).join("bin")),
_ => Some(home()?.join(".local/share/adom-kicad-bridge/bin")),
}
}
/// A script that runs the KiCad Flatpak (someone's own ~/.local/bin wrapper, say).
pub fn is_flatpak_wrapper(p: &Path) -> bool {
std::fs::read(p).ok().filter(|b| b.len() < 8192).map(|b| String::from_utf8_lossy(&b).contains(APP_ID)).unwrap_or(false)
}
fn has_gpu() -> bool {
std::fs::read_dir("/dev/dri").map(|d| d.flatten().any(|e| e.file_name().to_string_lossy().starts_with("renderD"))).unwrap_or(false)
}
/// The launcher text for one KiCad program. Software GL when there is no render node,
/// because the 3D viewer and the OpenGL canvas fail outright without one.
pub fn wrapper_script(command: &str, gpu: bool) -> String {
let gl = if gpu { String::new() } else { " --env=LIBGL_ALWAYS_SOFTWARE=1 --env=GALLIUM_DRIVER=llvmpipe".to_string() };
let run = format!("flatpak run --no-documents-portal --filesystem=host --filesystem=/tmp --command={command}{gl} {APP_ID} \"$@\"");
let head = format!("#!/bin/sh\n# Written by the Adom KiCad Bridge (wiki #127): KiCad's {command} from the Flatpak, with the host's\n# files and /tmp shared so the paths the bridge passes resolve inside the sandbox.\n");
if command == "kicad-cli" {
return format!("{head}exec {run}\n");
}
// The GUI needs a D-Bus session bus: GTK's sandboxed image loader (glycin) aborts the
// editor at startup without one, and a bridge started by a service has none.
format!("{head}if [ -z \"$DBUS_SESSION_BUS_ADDRESS\" ] && command -v dbus-run-session >/dev/null 2>&1; then\n exec dbus-run-session -- {run}\nfi\nexec {run}\n")
}
/// Write (or refresh) the launchers when the Flatpak is installed. Returns the installation and the dir.
pub fn ensure_wrappers() -> Option<(&'static str, PathBuf)> {
use std::os::unix::fs::PermissionsExt;
let installation = installation()?;
let dir = wrapper_dir()?;
std::fs::create_dir_all(&dir).ok()?;
let gpu = has_gpu();
for command in ["kicad-cli", "kicad", "pcbnew", "eeschema"] {
let p = dir.join(command);
let text = wrapper_script(command, gpu);
if std::fs::read_to_string(&p).ok().as_deref() != Some(text.as_str()) {
std::fs::write(&p, &text).ok()?;
}
let mut perm = std::fs::metadata(&p).ok()?.permissions();
if perm.mode() & 0o111 != 0o111 {
perm.set_mode(0o755);
std::fs::set_permissions(&p, perm).ok()?;
}
}
Some((installation, dir))
}
#[cfg(test)]
mod tests {
#[test]
fn wrapper_shares_tmp_and_host() {
let s = super::wrapper_script("kicad-cli", false);
assert!(s.starts_with("#!/bin/sh\n"));
assert!(s.contains("--filesystem=/tmp") && s.contains("--filesystem=host") && s.contains("--command=kicad-cli"));
assert!(s.contains("LIBGL_ALWAYS_SOFTWARE=1"));
assert!(!super::wrapper_script("pcbnew", true).contains("LIBGL"));
assert!(super::wrapper_script("pcbnew", true).contains("dbus-run-session --"));
assert!(!s.contains("dbus-run-session"));
}
}
}
fn chmod_x(p: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.permissions();
perm.set_mode(perm.mode() | 0o755);
std::fs::set_permissions(p, perm).map_err(|e| format!("chmod +x {}: {e}", p.display()))
}