← Commit history

1.0.58: Linux build in the same zip (wiki #127/#128: Flatpak KiCad, kicad-cli verbs, IPC live verbs; window verbs not yet). kicad_silk_fields_batch updates the parent footprints when KiCad refuses a field update (#129). Drill files with every Gerber export, kicad_export_pos (JLCPCB CPL), kicad_render_board, board-area SVG with background. Fixes #120 (read-only verbs leave dialogs open), #121 (Zoom to Selected Objects hint, ambiguous menu labels refused), #122 (same-name boards), #123 (PCM build-a-repository docs), #124 (DRC snapshot keeps project libraries), #125 (courtyard check uses real outlines).

John Lauer ·14b00c397c ·25d ago ·parent 064152f
5 files changed +389−9
rust/crates/kicad-core/src/kicad_common.rs+1−1
@@ -492,7 +492,7 @@ mod tests {         assert_eq!(r["changed"], json!(true));         assert_eq!(r["needsRestart"], json!(true));         assert_eq!(r["changedAt"], json!("2026-09-11T00:00:00Z"));-        assert!(r["kicadRunning"].is_null());+        assert!(matches!(r["kicadRunning"], Value::Null | Value::Bool(false)), "{r}");         let bak = d.join("kicad_common.json.adom-bak");         assert!(bak.exists());         assert_eq!(r["backup"].as_str().unwrap(), bak.to_string_lossy().replace('\\', "/"));
rust/crates/kicad-core/src/pcb.rs+66
@@ -136,6 +136,11 @@ pub struct Footprint {     /// courtyard. Not serialised: `crate::placement` turns it into a board-frame box.     #[serde(skip)]     pub courtyard: Vec<Point>,+    /// The courtyard's actual outline as edges in footprint-local mm (lines, rectangle and+    /// polygon sides, arcs and circles as short chords). Wiki #125: overlap is decided on+    /// these, not on the bounding box of `courtyard`.+    #[serde(skip)]+    pub courtyard_edges: Vec<(Point, Point)>, }  #[derive(Clone, Debug, Default, Serialize)]@@ -458,6 +463,7 @@ fn extract_footprints(tree: &Sx, name_based: bool) -> Vec<Footprint> {             pad_count: pads.len(),             pads,             courtyard: extract_courtyard(node),+            courtyard_edges: extract_courtyard_edges(node),         });     }     out@@ -517,6 +523,66 @@ fn extract_courtyard(fp: &Sx) -> Vec<Point> {     out } +/// The courtyard outline as edges (see `Footprint::courtyard_edges`).+fn extract_courtyard_edges(fp: &Sx) -> Vec<(Point, Point)> {+    let mut out: Vec<(Point, Point)> = Vec::new();+    let on_courtyard = |node: &Sx| matches!(node.find("layer").and_then(Sx::value), Some("F.CrtYd") | Some("B.CrtYd"));+    let pt = |x: f64, y: f64| Point { x, y };+    for node in fp.find_all("fp_line") {+        if on_courtyard(node) {+            if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {+                out.push((a, b));+            }+        }+    }+    for node in fp.find_all("fp_arc") {+        if on_courtyard(node) {+            if let (Some(a), Some(m), Some(b)) = (point_of(node.find("start")), point_of(node.find("mid")), point_of(node.find("end"))) {+                out.push((a, m.clone()));+                out.push((m, b));+            }+        }+    }+    for node in fp.find_all("fp_rect") {+        if on_courtyard(node) {+            if let (Some(a), Some(b)) = (point_of(node.find("start")), point_of(node.find("end"))) {+                let c = [pt(a.x, a.y), pt(b.x, a.y), pt(b.x, b.y), pt(a.x, b.y)];+                for i in 0..4 {+                    out.push((c[i].clone(), c[(i + 1) % 4].clone()));+                }+            }+        }+    }+    for node in fp.find_all("fp_circle") {+        if on_courtyard(node) {+            if let (Some(c), Some(e)) = (point_of(node.find("center")), point_of(node.find("end"))) {+                let r = dist((c.x, c.y), (e.x, e.y));+                let n = 24;+                let at = |k: usize| {+                    let t = std::f64::consts::TAU * k as f64 / n as f64;+                    pt(c.x + r * t.cos(), c.y + r * t.sin())+                };+                for k in 0..n {+                    out.push((at(k), at(k + 1)));+                }+            }+        }+    }+    for node in fp.find_all("fp_poly") {+        if on_courtyard(node) {+            if let Some(pts) = node.find("pts") {+                let v: Vec<Point> = pts.find_all("xy").filter_map(|xy| Some(pt(atom_f64(xy, 1)?, atom_f64(xy, 2)?))).collect();+                if v.len() >= 2 {+                    for i in 0..v.len() {+                        out.push((v[i].clone(), v[(i + 1) % v.len()].clone()));+                    }+                }+            }+        }+    }+    out+}+ fn point_of(node: Option<&Sx>) -> Option<Point> {     let n = node?;     Some(Point { x: atom_f64(n, 1)?, y: atom_f64(n, 2)? })
rust/crates/kicad-core/src/pcm.rs+1−1
@@ -1270,7 +1270,7 @@ mod tests {         assert_eq!(r["symbolLibraries"], json!(["PCM_Adom"]));         assert_eq!(r["footprintLibraries"], json!(["PCM_Adom"]));         assert_eq!(r["modelSets"], json!(["Adom.3dshapes"]));-        assert_eq!(r["restartNeeded"], json!(true));+        assert_eq!(r["restartNeeded"], json!(r["kicadRunning"] != json!(false)), "{r}");         assert_eq!(r["thirdPartyDir"], json!(norm(&t.0.join("Documents/KiCad/10.0/3rdparty"))));         let reg = read_installed(&t.0.join("config/installed_packages.json"));         let e = &reg["packages"][0];
rust/crates/kicad-core/src/placement.rs+126−1
@@ -155,6 +155,87 @@ pub struct Placement {     /// None when the board has no Edge.Cuts outline.     #[serde(rename = "insideOutline")]     pub inside_outline: Option<bool>,+    /// The courtyard's real outline in board mm (wiki #125); empty when the footprint has+    /// no courtyard shape, in which case the box decides.+    #[serde(skip)]+    pub outline: Vec<Edge>,+}++/// One courtyard edge in board mm.+pub type Edge = ((f64, f64), (f64, f64));++/// A footprint's courtyard edges placed at `origin` and `rotation`.+pub fn courtyard_edges_at(fp: &Footprint, origin: (f64, f64), rotation: f64) -> Vec<Edge> {+    fp.courtyard_edges.iter().map(|(a, b)| (to_board(origin, rotation, (a.x, a.y)), to_board(origin, rotation, (b.x, b.y)))).collect()+}++fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {+    (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)+}++/// Two segments cross through each other's interior. Touching at an end, or running along+/// each other, is not a crossing: courtyards may share an edge.+fn segments_cross(p: Edge, q: Edge) -> bool {+    const T: f64 = 1e-5;+    let d1 = orient(q.0, q.1, p.0);+    let d2 = orient(q.0, q.1, p.1);+    let d3 = orient(p.0, p.1, q.0);+    let d4 = orient(p.0, p.1, q.1);+    ((d1 > T && d2 < -T) || (d1 < -T && d2 > T)) && ((d3 > T && d4 < -T) || (d3 < -T && d4 > T))+}++/// Even-odd point-in-outline over a set of edges that together close one or more loops.+fn inside(pt: (f64, f64), edges: &[Edge]) -> bool {+    let mut odd = false;+    for &((x1, y1), (x2, y2)) in edges {+        if (y1 > pt.1) != (y2 > pt.1) {+            let x = x1 + (pt.1 - y1) * (x2 - x1) / (y2 - y1);+            if x > pt.0 {+                odd = !odd;+            }+        }+    }+    odd+}++fn on_boundary(pt: (f64, f64), edges: &[Edge]) -> bool {+    edges.iter().any(|&(a, b)| {+        let (dx, dy) = (b.0 - a.0, b.1 - a.1);+        let len2 = dx * dx + dy * dy;+        let t = if len2 > 0.0 { (((pt.0 - a.0) * dx + (pt.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };+        let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);+        (pt.0 - cx).powi(2) + (pt.1 - cy).powi(2) < 1e-10+    })+}++/// Points that sample an outline's region: its corners, the middle of each edge, and the+/// centre of its corners.+fn probes(e: &[Edge]) -> Vec<(f64, f64)> {+    let mut v: Vec<(f64, f64)> = Vec::with_capacity(e.len() * 2 + 1);+    for &(a, b) in e {+        v.push(a);+        v.push(((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0));+    }+    if !e.is_empty() {+        let n = e.len() as f64;+        v.push((e.iter().map(|(a, _)| a.0).sum::<f64>() / n, e.iter().map(|(a, _)| a.1).sum::<f64>() / n));+    }+    v+}++/// Do two courtyard outlines overlap with some area: an edge of one crosses an edge of the+/// other, or a sample point of one lies strictly inside the other. Sharing an edge or a+/// corner is not an overlap.+pub fn outlines_overlap(a: &[Edge], b: &[Edge]) -> bool {+    for &ea in a {+        for &eb in b {+            if segments_cross(ea, eb) {+                return true;+            }+        }+    }+    let strictly_in = |pt: (f64, f64), outer: &[Edge]| !on_boundary(pt, outer) && inside(pt, outer);+    probes(a).into_iter().any(|p| strictly_in(p, b)) || probes(b).into_iter().any(|p| strictly_in(p, a)) }  fn placement_of(fp: &Footprint, outline: Option<&Rect>) -> Placement {@@ -175,6 +256,7 @@ fn placement_of(fp: &Footprint, outline: Option<&Rect>) -> Placement {         pads: Some(fp.pads.iter().map(|p| PadNet { pad: p.number.clone(), net: p.net_name.clone().unwrap_or_default() }).collect()),         locked: fp.locked,         inside_outline: outline.map(|o| o.contains(&courtyard)),+        outline: courtyard_edges_at(fp, (x, y), rotation),     } } @@ -198,7 +280,9 @@ pub fn overlaps(places: &[Placement]) -> Vec<Overlap> {     for i in 0..places.len() {         for j in (i + 1)..places.len() {             let (p, q) = (&places[i], &places[j]);-            if p.side == q.side && p.courtyard.overlaps(&q.courtyard) {+            // The box is the broad phase; when both have a real courtyard outline, the+            // outlines decide (wiki #125: a stepped TSSOP courtyard is not its box).+            if p.side == q.side && p.courtyard.overlaps(&q.courtyard) && (p.outline.is_empty() || q.outline.is_empty() || outlines_overlap(&p.outline, &q.outline)) {                 let (a, b) = if ref_key(&p.reference) <= ref_key(&q.reference) { (&p.reference, &q.reference) } else { (&q.reference, &p.reference) };                 out.push(Overlap { a: a.clone(), b: b.clone(), side: p.side.clone() });             }@@ -425,6 +509,9 @@ pub struct Move {     #[serde(rename = "courtyardSource")]     pub courtyard_source: &'static str,     pub flipped: bool,+    /// The courtyard outline at the new pose (board mm), for the exact overlap check.+    #[serde(skip)]+    pub outline_after: Vec<Edge>, }  #[derive(Clone, Debug, Serialize, PartialEq)]@@ -533,6 +620,7 @@ pub fn plan_moves(board: &Board, args: &Value) -> Result<MovePlan, RoutingError>             courtyard_after,             courtyard_source,             flipped: side != current.side,+            outline_after: courtyard_edges_at(fp, (x, y), rotation),         });     }     // The board as it would be: moved footprints at their new pose, the rest as they are.@@ -546,6 +634,7 @@ pub fn plan_moves(board: &Board, args: &Value) -> Result<MovePlan, RoutingError>                 q.rotation = m.after.rotation;                 q.side = m.after.side.clone();                 q.courtyard = m.courtyard_after;+                q.outline = m.outline_after.clone();                 q.inside_outline = board_outline.as_ref().map(|o| o.contains(&m.courtyard_after));                 proposed.push(q);             }@@ -1714,3 +1803,39 @@ mod tests {         assert!(c.describe("U1").contains("nothing to compare"));     } }++#[cfg(test)]+mod outline_tests {+    use super::*;++    fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<Edge> {+        let c = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)];+        (0..4).map(|i| (c[i], c[(i + 1) % 4])).collect()+    }++    /// The stepped TSSOP-16 courtyard of wiki #125 (body narrow, pad rows wide), as a polygon.+    fn tssop(cx: f64, cy: f64) -> Vec<Edge> {+        let p = [(-2.45, -3.0), (2.45, -3.0), (2.45, -2.6), (3.9, -2.6), (3.9, 2.6), (2.45, 2.6), (2.45, 3.0), (-2.45, 3.0), (-2.45, 2.6), (-3.9, 2.6), (-3.9, -2.6), (-2.45, -2.6)];+        let v: Vec<(f64, f64)> = p.iter().map(|(x, y)| (cx + x, cy + y)).collect();+        (0..v.len()).map(|i| (v[i], v[(i + 1) % v.len()])).collect()+    }++    #[test]+    fn a_part_in_the_notch_of_a_stepped_courtyard_does_not_overlap() {+        // Inside the TSSOP's bounding box (its corner), but outside its real outline.+        let r = rect(3.0, 2.7, 4.8, 3.4);+        assert!(!outlines_overlap(&tssop(0.0, 0.0), &r));+    }++    #[test]+    fn crossing_and_containment_both_overlap() {+        assert!(outlines_overlap(&tssop(0.0, 0.0), &rect(3.5, 1.0, 5.0, 2.0)));+        assert!(outlines_overlap(&tssop(0.0, 0.0), &rect(-0.5, -0.5, 0.5, 0.5)));+        assert!(outlines_overlap(&rect(-0.5, -0.5, 0.5, 0.5), &tssop(0.0, 0.0)));+    }++    #[test]+    fn courtyards_that_only_share_an_edge_do_not_overlap() {+        assert!(!outlines_overlap(&rect(0.0, 0.0, 1.0, 1.0), &rect(1.0, 0.0, 2.0, 1.0)));+    }+}
rust/crates/kicad-platform/src/linux.rs+195−6
@@ -8,7 +8,7 @@ use std::process::Command;  use serde_json::{json, Value}; -use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform};+use crate::{install_from_cli, major_minor, Capabilities, KicadInstall, Platform, ProcessInfo};  pub struct Native; @@ -26,7 +26,7 @@ impl Platform for Native {             ui_automation: false,             dialog_sweep: false,             focus_etiquette: false,-            ipc_api: false,+            ipc_api: true,             silent_install: false,         }     }@@ -35,21 +35,38 @@ impl Platform for Native {     }     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"),-            (PathBuf::from("/var/lib/flatpak/exports/bin/org.kicad.KiCad"), "flatpak"),         ];+        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);             }@@ -57,8 +74,7 @@ impl Platform for Native {         out     }     fn config_dir(&self, version: &str) -> Option<PathBuf> {-        let base = std::env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from).or_else(|| home().map(|h| h.join(".config")))?;-        Some(base.join("kicad").join(major_minor(version)))+        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)))@@ -67,6 +83,16 @@ impl Platform for Native {         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"))     }@@ -77,7 +103,72 @@ impl Platform for Native {         Some(home()?.join("Documents"))     }     fn process_alive(&self, pid: u32) -> Option<bool> {-        Some(std::path::Path::new(&format!("/proc/{pid}")).exists())+        // 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@@ -139,6 +230,104 @@ impl Platform for Native {     } } +/// 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();