← 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 ·064152f96e ·25d ago ·parent 1b14f54
11 files changed +645−36
rust/Cargo.lock+3−3
@@ -244,7 +244,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.55"+version = "1.0.58" dependencies = [  "kicad-core",  "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.55"+version = "1.0.58" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.55"+version = "1.0.58" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.55"+version = "1.0.58" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/catalog.rs+27−6
@@ -107,11 +107,11 @@ pub static VERBS: &[Verb] = &[     },     Verb {         name: "kicad_export_gerber",-        summary: "Export Gerbers (and drill files) from a board via kicad-cli.",+        summary: "Export Gerbers AND Excellon drill files (plated and non-plated separate, with drill maps) from a board via kicad-cli: a complete fab set in one call.",         mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,-        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\"}",+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"drill\"?: true}",         example: "kicad_export_gerber {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",-        hint: "Absolute board path; outputDir defaults to <board dir>/gerbers_output.",+        hint: "Absolute board path; outputDir defaults to <board dir>/gerbers_output. Drill files land in the same folder (drillFiles in the reply); drill:false skips them. For JLCPCB assembly add kicad_export_pos {fab:\"jlcpcb\"} and a BOM.",         related: &["kicad_export_step", "kicad_export_bom_csv", "kicad_run_drc"],         pitfalls: &["Run kicad_run_drc first; never ship Gerbers from a board with DRC errors."],     },@@ -128,11 +128,31 @@ pub static VERBS: &[Verb] = &[         name: "kicad_export_svg",         summary: "Plot a board or schematic to SVG via kicad-cli.",         mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,-        input: "{\"filePath\": \"C:/.../x.kicad_pcb|.kicad_sch\", \"outputDir\"?: \"C:/...\", \"layers\"?: [\"F.Cu\", ...]}",-        example: "kicad_export_svg {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"layers\":[\"F.Cu\",\"Edge.Cuts\"]}",-        hint: "The file extension picks board or schematic.",+        input: "{\"filePath\": \"C:/.../x.kicad_pcb|.kicad_sch\", \"outputDir\"?: \"C:/...\", \"layers\"?: [\"F.Cu\", ...], \"pageSize\"?: \"board\"|\"page\", \"background\"?: \"#333333\", \"theme\"?: \"KiCad Default\", \"blackAndWhite\"?: bool}",+        example: "kicad_export_svg {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"layers\":[\"F.Cu\",\"B.Cu\",\"F.Silkscreen\",\"Edge.Cuts\"],\"pageSize\":\"board\",\"background\":\"#333333\"}",+        hint: "The file extension picks board or schematic. For a board picture: pageSize:\"board\" crops to the board area with no drawing frame, background paints a solid colour behind the layers, and every layer goes into ONE svg. Rasterise it in the container (rsvg-convert, cairosvg) for a PNG; kicad_render_board gives a 3D PNG directly.",         related: &["kicad_export_pdf"], pitfalls: &[],     },+    Verb {+        name: "kicad_export_pos",+        summary: "Export the pick-and-place (component position / CPL) file from a board via kicad-cli, optionally reformatted for a fab's assembly service.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"side\"?: \"both\"|\"front\"|\"back\", \"format\"?: \"csv\"|\"ascii\"|\"gerber\", \"units\"?: \"mm\"|\"in\", \"smdOnly\"?: bool, \"excludeDnp\"?: true, \"fab\"?: \"jlcpcb\"}",+        example: "kicad_export_pos {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"fab\":\"jlcpcb\"}",+        hint: "Defaults: both sides, csv, mm, DNP parts excluded. fab:\"jlcpcb\" also writes <board>_CPL_JLCPCB.csv with JLCPCB's columns (Designator, Mid X, Mid Y, Layer, Rotation). Pair it with a BOM and kicad_export_gerber for an assembly order.",+        related: &["kicad_export_gerber", "kicad_export_bom_csv"],+        pitfalls: &["KiCad's footprint rotation is not always the fab's: some packages (SOT-23, some ICs) need a per-package rotation correction. Check every part in the fab's assembly preview before ordering."],+    },+    Verb {+        name: "kicad_render_board",+        summary: "Render a 3D image (PNG/JPG) of a board from a side via kicad-cli pcb render, headless, no KiCad window.",+        mechanism: Mechanism::Cli, risk: "write", timeout_sec: 600,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"outputDir\"?: \"C:/...\", \"side\"?: \"top\"|\"bottom\"|\"left\"|\"right\"|\"front\"|\"back\", \"width\"?: 1600, \"height\"?: 900, \"quality\"?: \"basic\"|\"high\", \"background\"?: \"default\"|\"transparent\"|\"opaque\", \"zoom\"?: 1.0, \"perspective\"?: bool, \"format\"?: \"png\"|\"jpg\"}",+        example: "kicad_render_board {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"side\":\"top\"}",+        hint: "One call per side (top and bottom for a review). quality:\"high\" raytraces and is much slower. For a flat picture of chosen layers on a dark background use kicad_export_svg {pageSize:\"board\", background:\"#333333\"} instead.",+        related: &["kicad_export_svg", "kicad_export_step", "kicad_model_check"],+        pitfalls: &["Missing 3D models render as bare pads; run kicad_model_check first."],+    },     Verb {         name: "kicad_export_step",         summary: "Export a STEP model of the board via kicad-cli.",@@ -199,6 +219,7 @@ pub fn describe(version: &str) -> Value {         "version": version,         "contributing": "Source of every reply here is the wiki page's own repo: adom-wiki repo clone adom/kicad-bridge. A wrong reply, a refusal that should not be, or a missing verb: file it with the verbatim JSON (adom-wiki issue create adom/kicad-bridge), disclose any workaround, and if you fix it send a branch PR off the current head (adom-wiki branch create, repo push --branch, pr create --from); merged within minutes in business hours. Skill: kicad-bridge-contributing.",         "runtime": "native",+        "_skills": "The long-form recipes (routing, pours, PCM repositories on the wiki, schematics, demos) live in this bridge's skills. If the AI's workspace lacks them: adom-wiki pkg install adom/kicad-bridge. Guides: https://wiki.adom.inc/adom/kicad-bridge (docs/ holds the sub-guides, e.g. docs/pcm.md).",         "verbCount": verbs.len(),         "verbs": verbs,         "_hint": "Native bridge: headless, schematic edit and live routing verbs are served here. Window, show and demo verbs arrive with phase 3; until then the Python bridge serves them.",
rust/crates/kicad-bridge/src/verbs.rs+267−1
@@ -68,6 +68,8 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal         "kicad_export_svg" => export(state, args, if is_sch(args) { "sch" } else { "pcb" }, "svg", "SVG"),         "kicad_export_step" => export(state, args, "pcb", "step", "STEP"),         "kicad_export_bom_csv" => export(state, args, "sch", "bom", "BOM CSV"),+        "kicad_export_pos" => export_pos(state, args),+        "kicad_render_board" => render_board(state, args),         "kicad_uninstall" => { let info = state.kicad_info(); kicad_core::uninstall::handle(&info.installs, args) }         _ => fail("not_implemented", format!("{command} is catalogued but not implemented in this phase"), "Use the Python bridge for this verb until the phase that ports it lands."),     };@@ -277,6 +279,22 @@ fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label:         let layers = if layers.is_empty() { "F.Cu,B.Cu,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts".to_string() } else { layers };         argv.push("--layers".into());         argv.push(layers);+        if export_type == "svg" {+            // One file with every layer (the multi-file mode writes a file per layer).+            argv.push("--mode-single".into());+            if arg_str(args, "pageSize").map(|p| p == "board").unwrap_or(false) {+                argv.push("--page-size-mode".into());+                argv.push("2".into());+                argv.push("--exclude-drawing-sheet".into());+            }+            if let Some(t) = arg_str(args, "theme") {+                argv.push("--theme".into());+                argv.push(t.to_string());+            }+            if args.get("blackAndWhite").and_then(Value::as_bool).unwrap_or(false) {+                argv.push("--black-and-white".into());+            }+        }     }     argv.push(path_s.clone());     let argv_ref: Vec<&str> = argv.iter().map(String::as_str).collect();@@ -286,6 +304,46 @@ fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label:         v["stdout"] = json!(r.stdout);         return v;     }+    // Gerbers alone are not a fab set: add the Excellon drill files (and maps) unless drill:false.+    let mut drill_files: Vec<String> = Vec::new();+    let mut drill_error: Option<String> = None;+    if export_type == "gerbers" && args.get("drill").and_then(Value::as_bool).unwrap_or(true) {+        // Files this run wrote, by modification time: a rerun into the same folder overwrites the+        // drill files, so "new names since before" would report none (seen on ConfRoomROG, 1.0.57).+        let started = std::time::SystemTime::now() - Duration::from_secs(2);+        let drill_out = format!("{}/", out_s.trim_end_matches('/'));+        let dargv = ["pcb", "export", "drill", "--output", drill_out.as_str(), "--format", "excellon", "--excellon-separate-th", "--generate-map", "--map-format", "gerberx2", path_s.as_str()];+        let dr = cli::run(&cli_path, &dargv, Duration::from_secs(120));+        if dr.exit_code != 0 {+            drill_error = Some(format!("drill export failed: {}", dr.stderr.trim()));+        } else {+            drill_files = std::fs::read_dir(&out_dir)+                .map(|rd| {+                    rd.flatten()+                        .filter(|e| e.metadata().and_then(|m| m.modified()).is_ok_and(|t| t >= started))+                        .map(|e| e.file_name().to_string_lossy().to_string())+                        .filter(|f| { let l = f.to_lowercase(); l.ends_with(".drl") || l.contains("drl_map") || l.contains("-pth") || l.contains("-npth") })+                        .collect()+                })+                .unwrap_or_default();+            drill_files.sort();+        }+    }+    // A solid background behind an SVG plot (kicad-cli has no option for it).+    if export_type == "svg" && out_arg.is_file() {+        if let Some(bg) = arg_str(args, "background") {+            if let Ok(svg) = std::fs::read_to_string(&out_arg) {+                if let Some(start) = svg.find("<svg") {+                    if let Some(end_rel) = svg[start..].find('>') {+                        let at = start + end_rel + 1;+                        let safe: String = bg.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '#' || *c == '(' || *c == ')' || *c == ',' || *c == '.' || *c == ' ').collect();+                        let patched = format!("{}<rect x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\" fill=\"{}\"/>{}", &svg[..at], safe, &svg[at..]);+                        let _ = std::fs::write(&out_arg, patched);+                    }+                }+            }+        }+    }     let files: Vec<String> = if export_type == "gerbers" {         std::fs::read_dir(&out_dir).map(|rd| rd.flatten().filter(|e| e.path().is_file()).map(|e| e.file_name().to_string_lossy().to_string()).collect()).unwrap_or_default()     } else if out_arg.is_file() {@@ -301,7 +359,215 @@ fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label:         "path": path_s,         "cliElapsedMs": r.elapsed_ms,         "cliVersion": inst.cli_version,-        "_hint": if export_type == "gerbers" { "Run kicad_run_drc first; never ship Gerbers from a board with DRC errors." } else { "Pull the file with ab pull_file to bring it into the container." },+        "drillFiles": drill_files,+        "drillError": drill_error,+        "_hint": if export_type == "gerbers" { "Gerbers plus drill files are in output_path. Run kicad_run_drc first; never ship Gerbers from a board with DRC errors." } else { "Pull the file with ab pull_file to bring it into the container." },+    })+}++/// kicad_export_pos: the component position (pick-and-place / CPL) file, optionally in a fab's columns.+fn export_pos(state: &mut State, args: &Value) -> Value {+    let Some(path) = arg_any(args, &["filePath", "boardPath"]) else {+        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the .kicad_pcb.");+    };+    let path = PathBuf::from(path);+    if !path.is_file() {+        return fail("file_not_found", format!("PCB file not found: {}", path.display()), "Use the desktop's absolute path.");+    }+    let info = state.kicad_info();+    let Some(inst) = info.primary() else {+        let mut v = fail("not_installed", "KiCad not installed", "See alternatives.");+        v["alternatives"] = alternatives();+        return v;+    };+    let cli_path = PathBuf::from(&inst.kicad_cli);+    let side = arg_str(args, "side").unwrap_or("both");+    let format = arg_str(args, "format").unwrap_or("csv");+    let units = arg_str(args, "units").unwrap_or("mm");+    if !["both", "front", "back"].contains(&side) || !["csv", "ascii", "gerber"].contains(&format) || !["mm", "in"].contains(&units) {+        return fail("bad_arg", "side must be both|front|back, format csv|ascii|gerber, units mm|in", "Use the documented values.");+    }+    let fab = arg_str(args, "fab").map(|f| f.to_lowercase());+    if fab.as_deref().map(|f| f != "jlcpcb").unwrap_or(false) {+        return fail("bad_arg", "fab supports only \"jlcpcb\" today", "Omit fab for KiCad's own format.");+    }+    if fab.is_some() && format != "csv" {+        return fail("bad_arg", "fab:\"jlcpcb\" needs format:\"csv\"", "Drop format or set it to csv.");+    }+    let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());+    let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| path.parent().unwrap_or(std::path::Path::new(".")).join("pos_output"));+    if let Err(e) = std::fs::create_dir_all(&out_dir) {+        return fail("output_dir", format!("cannot create {}: {e}", out_dir.display()), "Pass an outputDir the desktop user can write.");+    }+    let ext = match format { "csv" => "csv", "gerber" => "gbr", _ => "pos" };+    let out_file = out_dir.join(format!("{stem}-pos.{ext}"));+    let out_s = norm(&out_file);+    let path_s = norm(&path);+    let mut argv: Vec<String> = vec!["pcb".into(), "export".into(), "pos".into(), "--output".into(), out_s.clone(), "--side".into(), side.into(), "--format".into(), format.into(), "--units".into(), units.into()];+    if args.get("smdOnly").and_then(Value::as_bool).unwrap_or(false) {+        argv.push("--smd-only".into());+    }+    if args.get("excludeDnp").and_then(Value::as_bool).unwrap_or(true) {+        argv.push("--exclude-dnp".into());+    }+    argv.push(path_s.clone());+    let argv_ref: Vec<&str> = argv.iter().map(String::as_str).collect();+    let r = cli::run(&cli_path, &argv_ref, Duration::from_secs(120));+    if r.exit_code != 0 || !out_file.is_file() {+        let mut v = fail(if r.timed_out { "cli_timeout" } else { "cli_failed" }, format!("Position export failed: {}", r.stderr.trim()), "kicad-cli's stderr names the reason.");+        v["stdout"] = json!(r.stdout);+        return v;+    }+    let mut files = vec![norm(&out_file)];+    let mut parts = 0usize;+    if let Ok(text) = std::fs::read_to_string(&out_file) {+        parts = text.lines().skip(1).filter(|l| !l.trim().is_empty()).count();+        if fab.as_deref() == Some("jlcpcb") {+            let jlc = jlcpcb_cpl(&text, units);+            let jlc_file = out_dir.join(format!("{stem}_CPL_JLCPCB.csv"));+            if std::fs::write(&jlc_file, jlc).is_ok() {+                files.push(norm(&jlc_file));+            }+        }+    }+    json!({+        "success": true,+        "output": format!("Position file exported: {parts} part(s), side {side}, {units}"),+        "data": {"output_path": norm(&out_dir), "files": files, "parts": parts},+        "path": path_s,+        "cliElapsedMs": r.elapsed_ms,+        "cliVersion": inst.cli_version,+        "_hint": "Check each part's rotation in the fab's assembly preview before ordering: some packages need a per-package correction.",     }) } +/// KiCad's csv position file (Ref,Val,Package,PosX,PosY,Rot,Side) in JLCPCB's CPL columns.+pub(crate) fn jlcpcb_cpl(kicad_csv: &str, units: &str) -> String {+    let suffix = if units == "in" { "in" } else { "mm" };+    let mut out = String::from("Designator,Mid X,Mid Y,Layer,Rotation\n");+    let mut lines = kicad_csv.lines();+    let header: Vec<String> = lines.next().map(|h| split_csv(h)).unwrap_or_default();+    let col = |name: &str| header.iter().position(|h| h.eq_ignore_ascii_case(name));+    let (Some(ri), Some(xi), Some(yi), Some(rot), Some(si)) = (col("Ref"), col("PosX"), col("PosY"), col("Rot"), col("Side")) else {+        return out;+    };+    for line in lines {+        let f = split_csv(line);+        if f.len() <= *[ri, xi, yi, rot, si].iter().max().unwrap() {+            continue;+        }+        let layer = if f[si].eq_ignore_ascii_case("bottom") { "Bottom" } else { "Top" };+        let r: f64 = f[rot].parse().unwrap_or(0.0);+        let r = ((r % 360.0) + 360.0) % 360.0;+        out.push_str(&format!("{},{}{},{}{},{},{}\n", f[ri], f[xi], suffix, f[yi], suffix, layer, trim_num(r)));+    }+    out+}++fn split_csv(line: &str) -> Vec<String> {+    let mut out = Vec::new();+    let mut cur = String::new();+    let mut quoted = false;+    for c in line.chars() {+        match c {+            '"' => quoted = !quoted,+            ',' if !quoted => out.push(std::mem::take(&mut cur)),+            _ => cur.push(c),+        }+    }+    out.push(cur);+    out.into_iter().map(|s| s.trim().to_string()).collect()+}++fn trim_num(v: f64) -> String {+    let s = format!("{v:.6}");+    let s = s.trim_end_matches('0').trim_end_matches('.');+    if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() }+}++/// kicad_render_board: a 3D image of the board from one side, headless (kicad-cli pcb render).+fn render_board(state: &mut State, args: &Value) -> Value {+    let Some(path) = arg_any(args, &["filePath", "boardPath"]) else {+        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the .kicad_pcb.");+    };+    let path = PathBuf::from(path);+    if !path.is_file() {+        return fail("file_not_found", format!("PCB file not found: {}", path.display()), "Use the desktop's absolute path.");+    }+    let info = state.kicad_info();+    let Some(inst) = info.primary() else {+        let mut v = fail("not_installed", "KiCad not installed", "See alternatives.");+        v["alternatives"] = alternatives();+        return v;+    };+    let cli_path = PathBuf::from(&inst.kicad_cli);+    let side = arg_str(args, "side").unwrap_or("top");+    if !["top", "bottom", "left", "right", "front", "back"].contains(&side) {+        return fail("bad_arg", "side must be top|bottom|left|right|front|back", "Use one of the documented sides.");+    }+    let quality = arg_str(args, "quality").unwrap_or("basic");+    let background = arg_str(args, "background").unwrap_or("default");+    let format = arg_str(args, "format").unwrap_or("png");+    if !["png", "jpg"].contains(&format) || !["basic", "high", "user", "job_settings"].contains(&quality) || !["default", "transparent", "opaque"].contains(&background) {+        return fail("bad_arg", "format png|jpg, quality basic|high, background default|transparent|opaque", "Use the documented values.");+    }+    let width = args.get("width").and_then(Value::as_u64).unwrap_or(1600).clamp(64, 8192);+    let height = args.get("height").and_then(Value::as_u64).unwrap_or(900).clamp(64, 8192);+    let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());+    let out_dir = arg_str(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| path.parent().unwrap_or(std::path::Path::new(".")).join("render_output"));+    if let Err(e) = std::fs::create_dir_all(&out_dir) {+        return fail("output_dir", format!("cannot create {}: {e}", out_dir.display()), "Pass an outputDir the desktop user can write.");+    }+    let out_file = out_dir.join(format!("{stem}-{side}.{format}"));+    let out_s = norm(&out_file);+    let path_s = norm(&path);+    let w = width.to_string();+    let h = height.to_string();+    let mut argv: Vec<String> = vec!["pcb".into(), "render".into(), "--output".into(), out_s.clone(), "--side".into(), side.into(), "--quality".into(), quality.into(), "--background".into(), background.into(), "--width".into(), w, "--height".into(), h];+    if let Some(z) = args.get("zoom").and_then(Value::as_f64) {+        argv.push("--zoom".into());+        argv.push(format!("{z}"));+    }+    if args.get("perspective").and_then(Value::as_bool).unwrap_or(false) {+        argv.push("--perspective".into());+    }+    argv.push(path_s.clone());+    let argv_ref: Vec<&str> = argv.iter().map(String::as_str).collect();+    let r = cli::run(&cli_path, &argv_ref, Duration::from_secs(560));+    if r.exit_code != 0 || !out_file.is_file() {+        let mut v = fail(if r.timed_out { "cli_timeout" } else { "cli_failed" }, format!("Render failed: {}", r.stderr.trim()), "kicad-cli's stderr names the reason; a machine with no usable OpenGL may need software rendering.");+        v["stdout"] = json!(r.stdout);+        return v;+    }+    let bytes = std::fs::metadata(&out_file).map(|m| m.len()).unwrap_or(0);+    json!({+        "success": true,+        "output": format!("Rendered the {side} side to {out_s}"),+        "data": {"output_path": out_s, "bytes": bytes, "side": side, "width": width, "height": height},+        "path": path_s,+        "cliElapsedMs": r.elapsed_ms,+        "cliVersion": inst.cli_version,+        "_hint": "Pull the image with ab pull_file. Render the other side with side:\"bottom\".",+    })+}+++#[cfg(test)]+mod pos_tests {+    use super::jlcpcb_cpl;++    #[test]+    fn kicad_position_csv_becomes_jlcpcb_cpl_columns() {+        let kicad = "Ref,Val,Package,PosX,PosY,Rot,Side\n\"C1\",\"100n\",\"C_0402\",12.5,-30.25,90.000000,top\n\"U3\",\"LM317\",\"SOT-223\",4,5,-90,bottom\n";+        let out = jlcpcb_cpl(kicad, "mm");+        let lines: Vec<&str> = out.lines().collect();+        assert_eq!(lines[0], "Designator,Mid X,Mid Y,Layer,Rotation");+        assert_eq!(lines[1], "C1,12.5mm,-30.25mm,Top,90");+        assert_eq!(lines[2], "U3,4mm,5mm,Bottom,270");+    }++    #[test]+    fn a_position_file_without_the_expected_header_yields_only_the_jlcpcb_header() {+        assert_eq!(jlcpcb_cpl("nonsense\n1,2,3\n", "mm"), "Designator,Mid X,Mid Y,Layer,Rotation\n");+    }+}
rust/crates/kicad-bridge/src/verbs_maint.rs+3−3
@@ -63,7 +63,7 @@ pub static VERBS: &[Verb] = &[         mechanism: Mechanism::File, risk: "read", timeout_sec: 60,         input: "{\"checkUpdates\"?: true}",         example: "kicad_pcm_list {}",-        hint: "Reads installed_packages.json, checks the 3rdparty directories exist, then fetches each package's repository (matched by repository_id against kicad.json pcm.repositories) and compares the newest compatible version with the installed one: updateAvailable, latestVersion, updateCheck per package, updatesAvailable in total. checkUpdates:false skips the network.",+        hint: "Reads installed_packages.json, checks the 3rdparty directories exist, then fetches each package's repository (matched by repository_id against kicad.json pcm.repositories) and compares the newest compatible version with the installed one: updateAvailable, latestVersion, updateCheck per package, updatesAvailable in total. checkUpdates:false skips the network. To BUILD a PCM repository (package your own libraries, host repository.json + packages.json + zips on an Adom wiki page so one URL installs and updates them all): the worked recipe is https://wiki.adom.inc/adom/kicad-bridge/files/docs/pcm.md, with a sample repository; skills: adom-wiki pkg install adom/kicad-bridge.",         related: &["kicad_pcm_install", "kicad_pcm_add_repository"],         pitfalls: &[             "updateCheck repository_not_registered means the package's repository is not in kicad.json (KiCad's own PCM cannot offer the update either): kicad_pcm_add_repository fixes both",@@ -76,7 +76,7 @@ pub static VERBS: &[Verb] = &[         mechanism: Mechanism::File, risk: "write", timeout_sec: 900,         input: "{\"repositoryUrl\"?: \"https://.../repository.json\", \"identifier\": \"com.adom.kicad-bridge.sample-lib\", \"version\"?: \"1.1.0\", \"update\"?: bool, \"libPrefix\"?: \"PCM_\", \"force\"?: bool}",         example: "kicad_pcm_install {\"repositoryUrl\":\"https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json\",\"identifier\":\"com.adom.kicad-bridge.sample-lib\"}",-        hint: "repositoryUrl (a repository.json) + identifier installs the newest compatible version (or version). update:true is PCM's Update button: repositoryUrl may be omitted for an installed package (its own repository is used), an installed newest version answers upToDate, otherwise the old files are replaced and installed_packages.json rewritten (previousVersion, updated:true). Extracts to $KICAD_3RD_PARTY/<folder>/<pkgid>/. KiCad registers PCM_<lib> rows itself on next load, so a running KiCad must restart (reported, never done).",+        hint: "repositoryUrl (a repository.json) + identifier installs the newest compatible version (or version). update:true is PCM's Update button: repositoryUrl may be omitted for an installed package (its own repository is used), an installed newest version answers upToDate, otherwise the old files are replaced and installed_packages.json rewritten (previousVersion, updated:true). Extracts to $KICAD_3RD_PARTY/<folder>/<pkgid>/. KiCad registers PCM_<lib> rows itself on next load, so a running KiCad must restart (reported, never done). To BUILD a PCM repository (package your own libraries, host repository.json + packages.json + zips on an Adom wiki page so one URL installs and updates them all): the worked recipe is https://wiki.adom.inc/adom/kicad-bridge/files/docs/pcm.md, with a sample repository; skills: adom-wiki pkg install adom/kicad-bridge.",         related: &["kicad_pcm_list", "kicad_pcm_uninstall", "kicad_pcm_add_repository", "kicad_install_library_bundle"],         pitfalls: &[             "a RUNNING KiCad does not see the new libraries until restarted",@@ -101,7 +101,7 @@ pub static VERBS: &[Verb] = &[         mechanism: Mechanism::File, risk: "config", timeout_sec: 60,         input: "{\"url\": \"https://.../repository.json\", \"name\"?: \"Adom KiCad Bridge sample PCM\"}",         example: "kicad_pcm_add_repository {\"url\":\"https://wiki.adom.inc/api/pages/adom/kicad-bridge/files/pcm/repository.json\"}",-        hint: "Fetches the repository.json to confirm it is a PCM repository and to take its name (name overrides, and registers it unverified when the fetch fails). Idempotent on the URL. Writes kicad.json with a .adom-bak beside it and an atomic replace; when kicad.json had no pcm.repositories key, KiCad's built-in official repository row is kept in front. KiCad reads the list at start: restartNeeded is always true.",+        hint: "Fetches the repository.json to confirm it is a PCM repository and to take its name (name overrides, and registers it unverified when the fetch fails). Idempotent on the URL. Writes kicad.json with a .adom-bak beside it and an atomic replace; when kicad.json had no pcm.repositories key, KiCad's built-in official repository row is kept in front. KiCad reads the list at start: restartNeeded is always true. To BUILD a PCM repository (package your own libraries, host repository.json + packages.json + zips on an Adom wiki page so one URL installs and updates them all): the worked recipe is https://wiki.adom.inc/adom/kicad-bridge/files/docs/pcm.md, with a sample repository; skills: adom-wiki pkg install adom/kicad-bridge.",         related: &["kicad_pcm_remove_repository", "kicad_pcm_install", "kicad_pcm_list"],         pitfalls: &[             "a KiCad that is running NOW overwrites kicad.json when it saves settings on exit: close it first when in doubt",
rust/crates/kicad-bridge/src/verbs_routing.rs+1−1
@@ -122,7 +122,7 @@ pub static VERBS: &[Verb] = &[         mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,         input: "{\"filePath\", \"net\": \"+VBAT\", \"add\"?: false}",         example: "kicad_select_net {\"filePath\":\"C:/p/b.kicad_pcb\",\"net\":\"/DRV_SHA\"}",-        hint: "The way to walk a board net by net on camera: select the net, then kicad_send_key {hwnd, menu:\"Zoom to Selection\"} on the PCB Editor frames it; kicad_select_clear afterwards. Selection is not an edit (no undo step, revision unchanged). add:true keeps the previous selection.",+        hint: "The way to walk a board net by net on camera: select the net, then kicad_send_key {hwnd, menu:\"Zoom to Selected Objects\"} on the PCB Editor frames it; kicad_select_clear afterwards. Selection is not an edit (no undo step, revision unchanged). add:true keeps the previous selection.",         related: &["kicad_select_clear", "kicad_send_key", "kicad_zone_state", "kicad_routing_state"],         pitfalls: &["net names are exact (KiCad's spelling, with the leading slash for local labels)", "a net with no copper yet has nothing to select"],     },
rust/crates/kicad-bridge/src/verbs_windows.rs+112−11
@@ -358,13 +358,17 @@ pub(crate) fn post_verb(state: &mut State, command: &str, args: &Value, out: &mu     // CONSTANT DIALOG COVERAGE: KiCad throws modal error boxes constantly; sweep after     // every GUI verb so the AI never runs blind past one and the next verb is unblocked.     let rounds = if spawns { 5 } else { 1 };-    if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600)) {+    // Wiki #120: a read-only verb never presses anything. It still reports what it saw.+    let read_only = crate::catalog::find(command).map(|v| v.risk == "read").unwrap_or(false);+    if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600), !read_only) {         let had_error = swept["hadError"].as_bool().unwrap_or(false);         if had_error {             let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();-            out["_dialogHint"] = json!(format!(-                "KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.",-                swept["count"], titles.join(", ")));+            out["_dialogHint"] = json!(if read_only {+                format!("KiCad has {} dialog(s) open (non-benign: {}). This verb is read-only, so it pressed nothing: read each body in _autoDialogs and decide (kicad_dismiss_dialogs, or answer it yourself).", swept["count"], titles.join(", "))+            } else {+                format!("KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.", swept["count"], titles.join(", "))+            });         }         // KiCad says this once per process start on a machine without usable OpenGL (a VM with no         // GPU, RDP): 2D falls back to software, the 3D viewer stays BLACK. Remember it, so the 3D@@ -934,6 +938,12 @@ pub(crate) fn scan_dialogs() -> Vec<Dialog> {         if seen.contains(&w.hwnd) {             continue;         }+        // Wiki #120: only a dialog OWNED BY A KICAD PROCESS (an orphaned pcbnew/eeschema+        // included) is ever ours. Before 1.0.56 any titled #32770 matched, so a read-only+        // kicad_state pressed a button on an unrelated Remote Desktop Connection dialog.+        if !w.is_kicad() {+            continue;+        }         if wm::looks_like_global_dialog(&w.title, &w.class_name) {             out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });             seen.push(w.hwnd);@@ -1047,7 +1057,7 @@ fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value /// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late /// dialog, screenshots and reads each one found, expires it (never a progress dialog), /// and returns None when nothing was ever seen.-fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration) -> Option<Value> {+fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration, dismiss: bool) -> Option<Value> {     let rounds = settle_rounds.max(1);     let mut found = Vec::new();     for r in 0..rounds {@@ -1080,6 +1090,12 @@ fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize,             seen.push(d.hwnd);             match wm::auto_sweep_decision(&d.title, &d.body) {                 DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),+                DialogAction::Dismiss { benign } if !dismiss => {+                    if !benign {+                        had_error = true;+                    }+                    dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": "left-open (read-only verb)"}));+                }                 DialogAction::Dismiss { benign } => {                     if !benign {                         had_error = true;@@ -1127,15 +1143,28 @@ fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>,         invalidate();         return Ok(Some((it.id, it.path.last().unwrap().replace('&', ""))));     }-    for it in items {+    // Wiki #121: a substring that matches more than one command is refused with the choices,+    // never resolved to whichever item comes first.+    let mut hits: Vec<(u32, String)> = Vec::new();+    for it in &items {         let Some(label) = it.path.last() else { continue };-        if clean(label).contains(&want) && it.id != 0 {-            native().menu_invoke(hwnd, it.id)?;+        if it.id != 0 && clean(label).contains(&want) && !hits.iter().any(|(id, _)| *id == it.id) {+            hits.push((it.id, label.replace('&', "").split('\t').next().unwrap_or("").trim().to_string()));+        }+    }+    match hits.len() {+        0 => Ok(None),+        1 => {+            let (id, label) = hits.remove(0);+            native().menu_invoke(hwnd, id)?;             invalidate();-            return Ok(Some((it.id, label.replace('&', ""))));+            Ok(Some((id, label)))         }+        _ => Err(format!(+            "ambiguous menu label '{needle}': it matches {}. Pass the exact label of the one you mean.",+            hits.iter().map(|(_, l)| format!("\"{l}\"")).collect::<Vec<_>>().join(", ")+        )),     }-    Ok(None) }  /// win_menu.find_by_accelerator: a chord to the menu command that carries it.@@ -1671,10 +1700,29 @@ pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Va     // Already open: the existing window, not a fresh reload.     invalidate();     if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {+        // Wiki #122: a title match is only a basename match. For a board, ask KiCad which+        // file it really has open before reusing the window.+        let mut path_verified = false;+        if kind == WindowKind::PcbEditor {+            if let Ok(open) = kicad_core::ipc::open_board_paths(Duration::from_secs(3)) {+                let wanted = norm(&path);+                if open.iter().any(|p| kicad_core::ipc::same_board_path(p, &wanted)) {+                    path_verified = true;+                } else if !open.is_empty() {+                    return json!({+                        "success": false, "errorCode": "different_board_same_name", "hwnd": w.hwnd, "title": w.title,+                        "error": format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")),+                        "openBoards": open, "requested": wanted,+                        "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close that board (save it first if it has changes), or open a copy of yours under a unique file name.",+                    });+                }+            }+        }         return json!({             "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",+            "pathVerified": path_verified,             "output": format!("{label} editor already has {name} open"),-            "_hint": "The existing window was reused, not reloaded. kicad_close first if you need a fresh load from disk.",+            "_hint": if path_verified { "The existing window was reused, not reloaded (KiCad confirmed the exact path). kicad_close first if you need a fresh load from disk." } else { "The existing window was reused on a file-name match that could not be confirmed against the full path (no KiCad API answer). If a same-named file from another folder could be open, kicad_close it first." },         });     }     let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {@@ -1690,6 +1738,9 @@ pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Va     // Verify by window title with one deadline (no nested sleeps).     let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);     let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    if !native().capabilities().window_control {+        return open_file_without_windows(kind, &path, pid, log, deadline, wait_s, exe_name, label, lint_verb);+    }     let mut appeared: Option<Win> = None;     while Instant::now() < deadline {         invalidate();@@ -1739,6 +1790,56 @@ pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Va     out } +/// Wiki #127: no window list on this OS (Linux for now). A board is confirmed through KiCad's+/// own API (the editor reports the exact path it has open); a schematic by the process staying up.+#[allow(clippy::too_many_arguments)]+fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Option<PathBuf>, deadline: Instant, wait_s: f64, exe_name: &str, label: &str, lint_verb: &str) -> Value {+    let wanted = norm(path);+    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let mut out = json!({+        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),+        "output": format!("Launched {label} editor with {name}"),+        "windowControl": false,+    });+    let mut confirmed = false;+    let mut last_open: Vec<String> = Vec::new();+    while Instant::now() < deadline {+        if native().process_alive(pid) == Some(false) {+            out["success"] = json!(false);+            out["errorCode"] = json!("editor_exited");+            out["error"] = json!(format!("{exe_name} exited before the {label} loaded"));+            out["_hint"] = json!(format!("The launch log (logPath) carries KiCad's own message; {lint_verb} says what is wrong with the file."));+            return out;+        }+        if kind == WindowKind::PcbEditor {+            if let Ok(open) = kicad_core::ipc::open_board_paths(Duration::from_secs(2)) {+                if open.iter().any(|p| kicad_core::ipc::same_board_path(p, &wanted)) {+                    confirmed = true;+                    break;+                }+                last_open = open;+            }+        } else if Instant::now() + Duration::from_secs_f64(wait_s) > deadline + Duration::from_secs(5) {+            // A schematic: no API answer to wait for on KiCad 10; five seconds alive is the signal.+            confirmed = true;+            break;+        }+        sleep_ms(500);+    }+    out["loaded"] = json!(confirmed);+    out["_hint"] = json!(if confirmed {+        if kind == WindowKind::PcbEditor { "KiCad's API confirms the board is open, so the live verbs (routing, placement, zones) can use it now. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_render_board or kicad_export_svg for a picture." } else { "The schematic editor is running. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_export_svg or kicad_export_pdf for a picture." }+    } else if last_open.is_empty() {+        "The editor is running but KiCad's API did not report the board in time. A cold start can take longer: poll kicad_routing_state with filePath. If it answers api_server_off, run kicad_ipc_api {\"enable\":true} with KiCad closed."+    } else {+        "The editor is running, but KiCad's API reports a different board (openBoards). KiCad serves one API per machine: the first KiCad that started owns it."+    });+    if !last_open.is_empty() && !confirmed {+        out["openBoards"] = json!(last_open);+    }+    out+}+ // ── kicad_open_symbol_editor ──────────────────────────────────────────────────  fn lib_ctx(info: &KicadInfo) -> LibCtx {
rust/crates/kicad-core/src/install.rs+5−2
@@ -2517,8 +2517,11 @@ mod tests {         assert_eq!(r["data"]["footprints"], json!(1));         assert_eq!(r["data"]["modelsInstalled"], json!(1));         assert_eq!(r["data"]["modelRefsRewritten"], json!(1));-        assert_eq!(r["data"]["kicadRunning"], Value::Null);-        assert_eq!(r["data"]["restartNeeded"], json!(true));+        // Unknown (null) where the OS cannot list processes; false on a host with no KiCad running.+        assert!(matches!(r["data"]["kicadRunning"], Value::Null | Value::Bool(false)), "{r}");+        if r["data"]["kicadRunning"].is_null() {+            assert_eq!(r["data"]["restartNeeded"], json!(true));+        }         assert!(r["proofSkipped"].as_str().unwrap().contains("window-phase"));         let user = bx.ctx.user_dir.clone().unwrap();         assert!(user.join("symbols/Cloud.kicad_sym").is_file());
rust/crates/kicad-core/src/install_kicad.rs+108−2
@@ -475,6 +475,9 @@ pub fn handle(installs: &[KicadInstall], args: &Value, fetch: Fetch) -> Value {     }      let os = native().capabilities().os;+    if os == "linux" {+        return flatpak_install(installs, args, &p, started);+    }     let Some(url) = p.url.clone() else {         let mut v = fail(             "unsupported_platform",@@ -666,6 +669,99 @@ pub fn handle(installs: &[KicadInstall], args: &Value, fetch: Fetch) -> Value {     }) } +/// Run a command with a deadline; (exit code, last lines of stdout+stderr).+fn run_capped(cmd: &mut std::process::Command, timeout: Duration) -> Result<(i32, String), String> {+    let dir = std::env::temp_dir().join(format!("kicad-flatpak-{}-{}", std::process::id(), crate::schematic::new_uuid()));+    let f = std::fs::File::create(&dir).map_err(|e| e.to_string())?;+    let f2 = f.try_clone().map_err(|e| e.to_string())?;+    let mut child = cmd.stdin(std::process::Stdio::null()).stdout(f).stderr(f2).spawn().map_err(|e| e.to_string())?;+    let deadline = Instant::now() + timeout;+    let code = loop {+        if let Some(st) = child.try_wait().map_err(|e| e.to_string())? {+            break st.code().unwrap_or(-1);+        }+        if Instant::now() > deadline {+            let _ = child.kill();+            let _ = child.wait();+            let _ = std::fs::remove_file(&dir);+            return Err(format!("timed out after {}s", timeout.as_secs()));+        }+        std::thread::sleep(Duration::from_millis(250));+    };+    let text = std::fs::read_to_string(&dir).unwrap_or_default();+    let _ = std::fs::remove_file(&dir);+    let tail: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();+    Ok((code, tail[tail.len().saturating_sub(12)..].join("\n")))+}++/// Wiki #127: on Linux the bridge installs KiCad from Flathub into the user's own Flatpak+/// installation (`--user`: no sudo, and it lives under home, so it survives a box whose+/// system packages are rebuilt). An existing user install is updated in place.+fn flatpak_install(installs: &[KicadInstall], args: &Value, p: &Probe, started: Instant) -> Value {+    const APP: &str = "org.kicad.KiCad";+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    let has_flatpak = std::process::Command::new("flatpak").arg("--version").output().map(|o| o.status.success()).unwrap_or(false);+    if !has_flatpak {+        let mut v = fail("flatpak_missing", "flatpak is not installed on this computer, and installing it needs root, which the bridge never uses.".into());+        v["_hint"] = json!("Ask the user to install flatpak once (Debian/Ubuntu: sudo apt install flatpak), or to install KiCad from the distro (sudo apt install kicad). Then rerun kicad_upgrade or kicad_readiness.");+        return v;+    }+    if !p.target.is_empty() {+        if let Some(v) = already_current(installs, &p.target, force) {+            return v;+        }+    }+    let (running, pids, _) = p.running.clone();+    if running == Some(true) && !force {+        let mut v = fail("kicad_running", format!("KiCad is running (pids {:?}); updating the Flatpak under it can break the open session.", pids));+        v["kicadPids"] = json!(pids);+        v["_hint"] = json!("Ask the user to save their work, run kicad_close, then rerun kicad_upgrade. A fresh --user install next to a running system KiCad is safe: pass force:true.");+        return v;+    }+    let home = std::env::var("HOME").unwrap_or_default();+    let user_has = Path::new(&home).join(".local/share/flatpak/app").join(APP).join("current").exists();+    let previous = installs.first().map(|i| full_version(i).to_string());+    let mut steps = Vec::new();+    let remote = run_capped(+        std::process::Command::new("flatpak").args(["remote-add", "--user", "--if-not-exists", "flathub", "https://dl.flathub.org/repo/flathub.flatpakrepo"]),+        Duration::from_secs(120),+    );+    steps.push(json!({"step": "remote-add flathub (--user)", "result": match &remote { Ok((c, t)) => json!({"exit": c, "tail": t}), Err(e) => json!({"error": e}) }}));+    let action = if user_has { "update" } else { "install" };+    let mut cmd = std::process::Command::new("flatpak");+    if user_has {+        cmd.args(["update", "--user", "-y", "--noninteractive", APP]);+    } else {+        cmd.args(["install", "--user", "-y", "--noninteractive", "flathub", APP]);+    }+    log(&format!("flatpak {action} --user {APP}"));+    let t0 = Instant::now();+    let ran = run_capped(&mut cmd, Duration::from_secs(INSTALL_TIMEOUT_SECS));+    let install_secs = round1(t0.elapsed().as_secs_f64());+    steps.push(json!({"step": format!("flatpak {action} --user {APP}"), "seconds": install_secs, "result": match &ran { Ok((c, t)) => json!({"exit": c, "tail": t}), Err(e) => json!({"error": e}) }}));+    let fresh = detect::detect();+    let user_now = Path::new(&home).join(".local/share/flatpak/app").join(APP).join("current").exists();+    let primary = fresh.installs.first().cloned();+    let ok = user_now && primary.is_some() && matches!(ran, Ok((0, _)));+    let seeded = primary.as_ref().filter(|_| ok).map(seed_new_install);+    json!({+        "success": ok,+        "upgraded": ok,+        "method": "flatpak --user",+        "action": action,+        "app": APP,+        "previousVersion": previous,+        "installedVersion": primary.as_ref().map(|i| full_version(i).to_string()),+        "kicadCli": primary.as_ref().map(|i| i.kicad_cli.clone()),+        "source": primary.as_ref().map(|i| i.source.clone()),+        "latestVersion": p.target,+        "steps": steps,+        "firstRunSeeded": seeded,+        "elapsedSec": round1(started.elapsed().as_secs_f64()),+        "_hint": if ok { "KiCad is installed in the user's Flatpak (under home). The bridge runs it through its own launchers (kicadCli), which share the host's files and /tmp with the sandbox; kicad_readiness confirms." } else { "The Flatpak step failed: steps[].tail carries flatpak's own message (network, disk space, or a Flathub outage). Rerun kicad_upgrade; flatpak resumes a partial download." },+    })+}+ /// server.py after a successful upgrade: `ensure_win_user_config` (dirs and lib tables /// from the install template) and the first-run JSON documents, for the NEW version. fn seed_new_install(inst: &KicadInstall) -> Value {@@ -857,8 +953,9 @@ mod tests {             assert_eq!(r["downloadUrl"], Value::Null);             assert_eq!(r["silentInstallSupported"], json!(false));             assert_eq!(r["serverInstallerBytes"], Value::Null);-            assert_eq!(r["kicadRunning"], Value::Null);-            assert!(r["kicadRunningUnknownBecause"].as_str().unwrap().contains("not implemented"));+            if r["kicadRunning"].is_null() {+                assert!(r["kicadRunningUnknownBecause"].as_str().unwrap().contains("not implemented"));+            }             assert_ne!(r["installerKind"], json!("nsis-exe"));         }     }@@ -868,6 +965,15 @@ mod tests {         if native().capabilities().os == "windows" {             return; // the real path downloads a gigabyte; John runs that by hand         }+        if native().capabilities().os == "linux" {+            // Linux installs through flatpak --user; never run a real install from a test.+            if std::process::Command::new("flatpak").arg("--version").output().is_ok_and(|o| o.status.success()) {+                return;+            }+            let r = handle(&[], &json!({"version": "10.0.6"}), &offline);+            assert_eq!(r["errorCode"], json!("flatpak_missing"), "{r}");+            return;+        }         let r = handle(&[], &json!({"version": "10.0.6"}), &offline);         assert_eq!(r["success"], json!(false));         assert_eq!(r["errorCode"], json!("unsupported_platform"));
rust/crates/kicad-core/src/ipc.rs+59−1
@@ -174,6 +174,22 @@ fn normalize_existing(s: &str) -> String {     resolved.to_lowercase() } +/// The absolute paths of every board open in the PCB editor that answers on the IPC+/// socket, or Err when no editor answers (API off, schematic-only, KiCad not running).+/// Wiki #122: the window title shows only the file name, so two boards with the same+/// basename in different folders are told apart by asking KiCad, never by the title.+pub fn open_board_paths(timeout: std::time::Duration) -> Result<Vec<String>, String> {+    let name = format!("adom-openpaths-{}", std::process::id());+    let client = KiCadClientBlocking::builder().client_name(name).timeout(timeout).connect().map_err(|e| format!("{e:?}"))?;+    let docs = client.get_open_documents(DocumentType::Pcb).map_err(|e| format!("{e:?}"))?;+    Ok(docs.iter().map(document_path).collect())+}++/// True when two board paths name the same file (case, separators and symlinks folded).+pub fn same_board_path(a: &str, b: &str) -> bool {+    normalize_existing(a) == normalize_existing(b)+}+ /// The absolute path of an open document (`document_path` in the Python). pub fn document_path(d: &DocumentSpecifier) -> String {     let filename = d.board_filename.clone().unwrap_or_default();@@ -248,6 +264,15 @@ pub fn new_errors(before: &Value, after: &Value) -> RResult<Vec<Value>> {     Ok(added) } +/// Copy a KiCad library table, replacing ${KIPRJMOD} with the original project folder so+/// the copy resolves the same libraries from wherever it is read.+pub fn copy_lib_table(src: &Path, dst: &Path, project_dir: &Path) -> std::io::Result<()> {+    let text = std::fs::read_to_string(src)?;+    let dir = project_dir.to_string_lossy().replace('\\', "/");+    let dir = dir.trim_end_matches('/');+    std::fs::write(dst, text.replace("${KIPRJMOD}", dir))+}+ /// `_drc`: KiCad DRC on a disposable snapshot of the live board text, with the project's /// own rules copied alongside and zones refilled so connectivity reflects the proposed /// copper. The live document and the source file are never touched.@@ -278,6 +303,18 @@ fn drc_in(exe: &Path, source: &Path, text: &str, tmp: &Path) -> RResult<Value> {             copied.push(sibling.to_string_lossy().to_string());         }     }+    // Wiki #124: the project's library tables travel with the snapshot too, with ${KIPRJMOD}+    // pointed back at the real project folder, so project-local footprint libraries resolve+    // and DRC does not report them missing.+    if let Some(project_dir) = source.parent() {+        for table in ["fp-lib-table", "sym-lib-table"] {+            let src = project_dir.join(table);+            if src.is_file() {+                copy_lib_table(&src, &tmp.join(table), project_dir).map_err(|e| RoutingError::new("drc_rules_copy_failed", format!("Cannot preserve library table {}: {e}", src.display())).with("mutated", json!(false)))?;+                copied.push(src.to_string_lossy().to_string());+            }+        }+    }     let report = tmp.join("drc.json");     let report_s = report.to_string_lossy().to_string();     let candidate_s = candidate.to_string_lossy().to_string();@@ -612,7 +649,7 @@ pub fn select_net(ctx: &Ctx, args: &Value) -> RResult<Value> {     s.client.add_to_selection(ids.clone()).map_err(map_err)?;     Ok(json!({         "success": true, "mutated": false, "source": "live-editor", "net": net, "selected": count, "byKind": kinds, "itemIds": ids,-        "_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). Frame it with kicad_send_key {hwnd, menu:\"Zoom to Selection\"} on the PCB editor; clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",+        "_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). Frame it with kicad_send_key {hwnd, menu:\"Zoom to Selected Objects\"} on the PCB editor; clear it with kicad_select_clear. Selection is not an edit: nothing to undo, the revision is unchanged.",     })) } @@ -1732,6 +1769,7 @@ mod tests {             courtyard_after: placement::Rect { min_x: 29.0, min_y: 38.0, max_x: 31.0, max_y: 42.0 },             courtyard_source: "courtyard",             flipped: false,+            outline_after: Vec::new(),         };         let out = build_footprint_updates(vec![EditablePcbItem::Footprint(fp.clone())], &[mv.clone()]).unwrap();         assert_eq!(out.len(), 1);@@ -2014,3 +2052,23 @@ pub fn silk_text_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {  mod silk_fields; pub use silk_fields::silk_fields_batch;++#[cfg(test)]+mod lib_table_tests {+    #[test]+    fn a_copied_library_table_points_kiprjmod_back_at_the_real_project() {+        let base = std::env::temp_dir().join(format!("adom-libtable-test-{}", std::process::id()));+        let proj = base.join("proj");+        let snap = base.join("snap");+        std::fs::create_dir_all(&proj).unwrap();+        std::fs::create_dir_all(&snap).unwrap();+        let table = "(fp_lib_table\n  (version 7)\n  (lib (name \"Load\")(type \"KiCad\")(uri \"${KIPRJMOD}/lib/Load.pretty\")(options \"\")(descr \"\"))\n)\n";+        std::fs::write(proj.join("fp-lib-table"), table).unwrap();+        super::copy_lib_table(&proj.join("fp-lib-table"), &snap.join("fp-lib-table"), &proj).unwrap();+        let out = std::fs::read_to_string(snap.join("fp-lib-table")).unwrap();+        let expect = format!("{}/lib/Load.pretty", proj.to_string_lossy().replace('\\', "/"));+        assert!(out.contains(&expect), "{out}");+        assert!(!out.contains("${KIPRJMOD}"));+        let _ = std::fs::remove_dir_all(&base);+    }+}
rust/crates/kicad-core/src/ipc/silk_fields.rs+59−5
@@ -3,7 +3,7 @@ use crate::silk_fields as fields; use kicad_ipc_rs::FieldItem;  fn classify_field_failure(mut error: RoutingError, version: &str) -> RoutingError {-    if error.code == "mutation_rolled_back"+    if (error.code == "mutation_rolled_back" || error.code == "drc_rejected")         && error.detail.get("mutated") == Some(&json!(false))         && error.message.contains("ISC_INVALID_TYPE")         && error.message.contains("PCB_FIELD_T")@@ -12,7 +12,7 @@ fn classify_field_failure(mut error: RoutingError, version: &str) -> RoutingErro         error.code = "native_field_update_unsupported".into();         error = error.with("kicadVersion", json!(version))             .with("retryable", json!(false))-            .with("_hint", json!("This KiCad IPC server can read fields but cannot update Field payloads. The transaction rolled back. Do not retry as a transient failure or replace whole footprints. On KiCad 10.0.5, use an explicitly disclosed saved-file edit on a saved copy, then reload the exact editor and verify both views, DRC and preservation; that is not a live IPC edit."));+            .with("_hint", json!("This KiCad IPC server cannot update a Field as an item of its own, and the transaction rolled back. From 1.0.57 the bridge then updates the parent footprints with only those fields changed (updateMechanism: footprint); this error reaches you only if that path is unavailable."));     }     error }@@ -90,6 +90,33 @@ fn apply_geometry(mut field: FieldItem, e: &fields::Edit) -> RResult<EditablePcb     Ok(EditablePcbItem::Field(field)) } +/// The parent footprints with only the requested fields changed (wiki #129).+fn footprint_updates(s: &Session, plan: &fields::Plan) -> RResult<Vec<EditablePcbItem>> {+    let mut fp_ids: Vec<String> = plan.edits.iter().map(|e| e.footprint_id.clone()).collect();+    fp_ids.sort();+    fp_ids.dedup();+    let native = s.client.get_editable_items_by_id(fp_ids.clone()).map_err(map_err)?;+    let mut out = Vec::new();+    for fid in &fp_ids {+        let Some(EditablePcbItem::Footprint(fp)) = native.iter().find(|i| i.id() == Some(fid.as_str())) else {+            return Err(RoutingError::new("footprint_not_found", format!("KiCad did not return footprint {fid}; nothing was changed")).with("mutated", json!(false)));+        };+        let mut fp = fp.clone();+        let p = fp.proto_mut();+        for e in plan.edits.iter().filter(|e| &e.footprint_id == fid) {+            let slot = [&mut p.reference_field, &mut p.value_field]+                .into_iter()+                .flatten()+                .find(|f| f.text.as_ref().and_then(|t| t.id.as_ref()).map(|x| x.value.as_str()) == Some(e.text.id.as_str()))+                .ok_or_else(|| RoutingError::new("native_field_unavailable", format!("Footprint {fid} has no reference/value field {}", e.text.id)).with("mutated", json!(false)))?;+            let EditablePcbItem::Field(done) = apply_geometry(FieldItem::from_proto(slot.clone()), e)? else { unreachable!() };+            *slot = done.proto().clone();+        }+        out.push(EditablePcbItem::Footprint(fp));+    }+    Ok(out)+}+ pub fn silk_fields_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {     if args["expectedRevision"]         .as_str()@@ -151,7 +178,7 @@ pub fn silk_fields_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {         );     }     check_revision(args, &s.revision()?)?;-    transaction(&s, "Adom reference/value fields", || {+    let direct = transaction(&s, "Adom reference/value fields", || {         let reply = s.client.update_editable_items(updates.clone())?;         let actual: HashSet<_> = reply.iter().filter_map(id).collect();         if actual != ids.iter().map(String::as_str).collect() {@@ -161,8 +188,30 @@ pub fn silk_fields_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {         }         Ok(())     })-    .map_err(|e| classify_field_failure(e, &s.version.full_version))?;-    let mut out = json!({"success":true,"committed":true,"mutated":true,"undoSteps":1,"updatedIds":ids,"footprintIds":plan.edits.iter().map(|e|&e.footprint_id).collect::<Vec<_>>(),"saved":false,"drc":after,"_hint":"Only nested reference/value fields were updated; no whole footprint replacement. Inspect postCommit errors before any retry, then refresh the exact linked views and inspect the board."});+    .map_err(|e| classify_field_failure(e, &s.version.full_version));+    // Wiki #129: KiCad 10's API cannot update a field as an item of its own (ISC_INVALID_TYPE+    // PCB_FIELD_T); a field lives inside its footprint. After a confirmed rollback, update the+    // parent footprints with only those fields changed, the way kicad-python does. The pose and+    // every other child go back exactly as KiCad sent them, and the preservation check below+    // compares everything outside the requested fields.+    let mechanism = match direct {+        Ok(()) => "field",+        Err(e) if e.code == "native_field_update_unsupported" => {+            let fps = footprint_updates(&s, &plan)?;+            let fp_ids: HashSet<String> = plan.edits.iter().map(|e| e.footprint_id.clone()).collect();+            transaction(&s, "Adom reference/value fields", || {+                let reply = s.client.update_editable_items(fps.clone())?;+                let actual: HashSet<String> = reply.iter().filter_map(|i| i.id().map(str::to_string)).collect();+                if actual != fp_ids {+                    return Err(IpcFailure::from("Footprint update did not return every parent footprint".to_string()));+                }+                Ok(())+            })?;+            "footprint"+        }+        Err(e) => return Err(e),+    };+    let mut out = json!({"success":true,"committed":true,"mutated":true,"undoSteps":1,"updatedIds":ids,"footprintIds":plan.edits.iter().map(|e|&e.footprint_id).collect::<Vec<_>>(),"saved":false,"drc":after,"updateMechanism":mechanism,"_hint":if mechanism == "field" {"Only nested reference/value fields were updated; no whole footprint replacement. Inspect postCommit errors before any retry, then refresh the exact linked views and inspect the board."} else {"This KiCad cannot update a field on its own, so the parent footprints were sent back with only these fields changed (pose and every other child exactly as KiCad sent them); otherBoardDataVerified and fieldsVerified confirm it. Inspect postCommit errors before any retry, then refresh the exact linked views."}});     match s.client.get_editable_items_by_id(ids.clone()) {         Ok(actual) => {             let matches = updates.iter().all(|want| {@@ -241,6 +290,11 @@ mod tests {             assert_eq!(e.code, code);             assert_eq!(e.detail["mutated"], Value::Null);         }+        let e = classify_field_failure(+            RoutingError::new("drc_rejected", format!("item request status error `{message}`")).with("mutated", json!(false)),+            "10.0.6",+        );+        assert_eq!(e.code, "native_field_update_unsupported", "wiki #129: the shape seen on 10.0.6");         let e = classify_field_failure(             RoutingError::new("mutation_rolled_back", "ISC_INVALID_TYPE: other type")                 .with("mutated", json!(false)),