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.
← 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).
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 = ®["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();