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