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.30: NATIVE (Rust) build. 1.0.30: kicad_taskbar_repair fixes the white taskbar icon on standalone KiCad editors by writing KiCad's own AUMID onto its standalone Start Menu shortcuts, once at every bridge start and on demand; no Adom identity; a stubborn button needs a logon or explorer restart.
4 files changed
+189−6
rust/crates/kicad-platform/src/win/mod.rs+1@@ -29,6 +29,7 @@ pub mod install; pub mod menu; pub mod messages; pub mod process;+pub mod shortcuts; pub mod tour_input; pub mod uia;
rust/crates/kicad-platform/src/win/shortcuts.rsadded+174@@ -0,0 +1,174 @@+//! KiCad's OWN AppUserModelID onto KiCad's OWN standalone Start Menu shortcuts.+//!+//! The white taskbar icon, solved end to end on John's taskbar on 2026-08-23 and recorded+//! in skills/kicad-bridge-dev/SKILL.md ("THE WHITE-ICON MECHANISM"): a standalone editor+//! process, which is how this bridge launches pcbnew, presents a runtime-built AUMID such+//! as `Kicad.Kicad.pcbnew.10.0`. KiCad's installer writes that AUMID onto the MAIN+//! shortcut only and leaves it blank on the standalone editor shortcuts, so the shell has+//! no shortcut to resolve the editor group's icon from. Some machines fall back to the+//! window's own icon; others paint the generic white page. Writing KiCad's own AUMID onto+//! KiCad's own user-level shortcut makes the resolution deterministic, the exact mechanism+//! that keeps the main KiCad button correct. No Adom identity is involved anywhere: this+//! bridge stamps NO AUMIDs of its own, ever.+//!+//! The Python bridge did this through inline C# in PowerShell (`handlers/win_taskbar.py`,+//! `_repair_kicad_shortcut_aumids`); this is the same operation in process. Idempotent: a+//! shortcut that already carries any AUMID is left alone. The shell reads the mapping when+//! it next rebuilds its Start Menu index (logon or an explorer restart); `SHChangeNotify`+//! is sent for each written file to invite an earlier refresh, and explorer is never+//! restarted from here.++use std::path::{Path, PathBuf};++use windows::core::{Interface, GUID, HSTRING, PWSTR};+use windows::Win32::Foundation::{PROPERTYKEY, RPC_E_CHANGED_MODE};+use windows::Win32::System::Com::StructuredStorage::{PropVariantClear, PROPVARIANT};+use windows::Win32::System::Com::{+ CoCreateInstance, CoInitializeEx, CoTaskMemAlloc, CoTaskMemFree, CoUninitialize, IPersistFile,+ CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, STGM_READWRITE,+};+use windows::Win32::System::Variant::VT_LPWSTR;+use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;+use windows::Win32::UI::Shell::{IShellLinkW, SHChangeNotify, ShellLink, SHCNE_UPDATEITEM, SHCNF_FLUSHNOWAIT, SHCNF_PATHW};++/// `System.AppUserModel.ID` (propkey.h): {9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}, 5.+const PKEY_APPUSERMODEL_ID: PROPERTYKEY = PROPERTYKEY { fmtid: GUID::from_u128(0x9F4C2855_9F79_4B39_A8D0_E1D42DE1D5F3), pid: 5 };++/// One shortcut's fate.+#[derive(Debug, Clone)]+pub struct ShortcutRepair {+ pub path: String,+ pub aumid: String,+ /// `written`, `already` (carried an AUMID, untouched), `missing` (no such shortcut), `error`.+ pub outcome: &'static str,+ pub detail: Option<String>,+}++/// The standalone shortcuts KiCad's installer writes without an AUMID, with the AUMID each+/// running process actually presents (read from the taskbar buttons' AutomationIds).+/// `version` is major.minor ("10.0"): KiCad names both the folder and the shortcuts by it.+pub fn shortcuts_for(version: &str) -> Vec<(PathBuf, String)> {+ let Some(appdata) = std::env::var_os("APPDATA") else { return Vec::new() };+ let root = Path::new(&appdata).join("Microsoft").join("Windows").join("Start Menu").join("Programs").join(format!("KiCad {version}"));+ [+ (format!("PCB Editor {version} (standalone).lnk"), format!("Kicad.Kicad.pcbnew.{version}")),+ (format!("Schematic Editor {version} (standalone).lnk"), format!("Kicad.Kicad.eeschema.{version}")),+ (format!("Gerber Viewer {version}.lnk"), format!("Kicad.Kicad.gerbview.{version}")),+ ]+ .into_iter()+ .map(|(name, aumid)| (root.join(name), aumid))+ .collect()+}++/// COM apartment for this thread, released on drop; a thread already in an apartment of+/// the other kind keeps it (RPC_E_CHANGED_MODE is not a failure for us).+struct Com(bool);++impl Com {+ fn init() -> Result<Com, String> {+ // SAFETY: CoInitializeEx on the current thread; balanced in Drop only when this+ // call took the reference.+ let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };+ if hr.is_ok() {+ Ok(Com(true))+ } else if hr == RPC_E_CHANGED_MODE {+ Ok(Com(false))+ } else {+ Err(format!("CoInitializeEx: {hr}"))+ }+ }+}++impl Drop for Com {+ fn drop(&mut self) {+ if self.0 {+ // SAFETY: matches the successful CoInitializeEx; every COM object made in+ // between is dropped before this runs (they live inside repair_one).+ unsafe { CoUninitialize() };+ }+ }+}++/// Fill the AUMID on every standalone shortcut that lacks one. Never fails as a whole:+/// each shortcut reports its own outcome, and a missing shortcut is `missing`, not an error.+pub fn repair_kicad_shortcut_aumids(version: &str) -> Result<Vec<ShortcutRepair>, String> {+ let _com = Com::init()?;+ let mut out = Vec::new();+ for (path, aumid) in shortcuts_for(version) {+ let shown = path.to_string_lossy().replace('\\', "/");+ if !path.is_file() {+ out.push(ShortcutRepair { path: shown, aumid, outcome: "missing", detail: None });+ continue;+ }+ match repair_one(&path, &aumid) {+ Ok(true) => out.push(ShortcutRepair { path: shown, aumid, outcome: "written", detail: None }),+ Ok(false) => out.push(ShortcutRepair { path: shown, aumid, outcome: "already", detail: None }),+ Err(e) => out.push(ShortcutRepair { path: shown, aumid, outcome: "error", detail: Some(e) }),+ }+ }+ Ok(out)+}++/// Ok(true) written, Ok(false) already carried an AUMID.+fn repair_one(path: &Path, aumid: &str) -> Result<bool, String> {+ let wide = HSTRING::from(path.as_os_str());+ // SAFETY: standard COM object creation and QueryInterface on a live object; every+ // pointer handed to the shell outlives the call that reads it.+ unsafe {+ let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).map_err(|e| format!("CoCreateInstance(ShellLink): {e}"))?;+ let file: IPersistFile = link.cast().map_err(|e| format!("IPersistFile: {e}"))?;+ file.Load(&wide, STGM_READWRITE).map_err(|e| format!("Load: {e}"))?;+ let store: IPropertyStore = link.cast().map_err(|e| format!("IPropertyStore: {e}"))?;+ let mut current = store.GetValue(&PKEY_APPUSERMODEL_ID).map_err(|e| format!("GetValue: {e}"))?;+ let existing = read_lpwstr(¤t);+ let _ = PropVariantClear(&mut current);+ if existing.map(|s| !s.is_empty()).unwrap_or(false) {+ return Ok(false);+ }+ let mut value = lpwstr_variant(aumid);+ let set = store.SetValue(&PKEY_APPUSERMODEL_ID, &value).map_err(|e| format!("SetValue: {e}"));+ let commit = set.and_then(|_| store.Commit().map_err(|e| format!("Commit: {e}")));+ free_lpwstr_variant(&mut value);+ commit?;+ file.Save(&wide, true).map_err(|e| format!("Save: {e}"))?;+ // Invite explorer to refresh its view of this item now rather than at next logon.+ SHChangeNotify(SHCNE_UPDATEITEM, SHCNF_PATHW | SHCNF_FLUSHNOWAIT, Some(wide.as_ptr() as *const _), None);+ }+ Ok(true)+}++/// The string in a VT_LPWSTR PROPVARIANT, None for any other type (VT_EMPTY on a shortcut+/// that has no AUMID).+unsafe fn read_lpwstr(pv: &PROPVARIANT) -> Option<String> {+ let inner = &pv.Anonymous.Anonymous;+ if inner.vt != VT_LPWSTR {+ return None;+ }+ let p: PWSTR = inner.Anonymous.pwszVal;+ if p.is_null() {+ return Some(String::new());+ }+ Some(p.to_string().unwrap_or_default())+}++/// A VT_LPWSTR PROPVARIANT owning a CoTaskMem copy of `s` (what IPropertyStore expects).+unsafe fn lpwstr_variant(s: &str) -> PROPVARIANT {+ let mut units: Vec<u16> = s.encode_utf16().collect();+ units.push(0);+ let bytes = units.len() * 2;+ let mem = CoTaskMemAlloc(bytes) as *mut u16;+ std::ptr::copy_nonoverlapping(units.as_ptr(), mem, units.len());+ let mut pv = PROPVARIANT::default();+ let inner = &mut *(&mut pv.Anonymous.Anonymous as *mut _ as *mut windows::Win32::System::Com::StructuredStorage::PROPVARIANT_0_0);+ inner.vt = VT_LPWSTR;+ inner.Anonymous.pwszVal = PWSTR(mem);+ pv+}++unsafe fn free_lpwstr_variant(pv: &mut PROPVARIANT) {+ let inner = &pv.Anonymous.Anonymous;+ let p = inner.Anonymous.pwszVal;+ if !p.is_null() {+ CoTaskMemFree(Some(p.0 as *const _));+ }+}
rust/crates/kicad-platform/src/windows.rs+4@@ -229,6 +229,10 @@ impl Platform for Native { fn on_a_monitor(&self, rect: (i32, i32, i32, i32)) -> Result<bool, String> { Ok(win::enumerate::on_a_monitor(rect)) }+ fn repair_taskbar_shortcuts(&self, version: &str) -> Result<serde_json::Value, String> {+ let rows = win::shortcuts::repair_kicad_shortcut_aumids(version)?;+ Ok(serde_json::Value::Array(rows.into_iter().map(|r| serde_json::json!({"path": r.path, "aumid": r.aumid, "outcome": r.outcome, "detail": r.detail})).collect()))+ } fn capture_window(&self, h: u64) -> Result<Capture, String> { win::capture::capture(live(h)?) }
tools/build_native_release.py+10−6@@ -24,6 +24,14 @@ ROOT = Path(__file__).resolve().parent.parent RUST = ROOT / "rust" TARGET = "x86_64-pc-windows-gnu" +# The native zip is a Windows x64 exe. Both manifests carry this so a macOS or Linux installer+# refuses with a reason instead of installing something that cannot run (wiki #108).+PLATFORMS = {+ "windows": {"supported": True, "notes": "Pre-built x64 kicad-bridge.exe ships in the zip."},+ "macos": {"supported": False, "notes": "Kyle's build: cargo build --release on a Mac, crates/kicad-platform/src/macos.rs."},+ "linux": {"supported": False, "notes": "Barrett's build: cargo build --release on Ubuntu, crates/kicad-platform/src/linux.rs."},+}+ def sh(cmd, cwd=None): r = subprocess.run(cmd, cwd=cwd, text=True, capture_output=True) if r.returncode != 0:@@ -90,11 +98,7 @@ def main(): manifest.pop("dependencies", None) manifest["verbs"] = verbs manifest["runtime"] = "native"- manifest["platforms"] = {- "windows": {"supported": True, "notes": "Pre-built x64 kicad-bridge.exe ships in the zip."},- "macos": {"supported": False, "notes": "Kyle's build: cargo build --release on a Mac, crates/kicad-platform/src/macos.rs."},- "linux": {"supported": False, "notes": "Barrett's build: cargo build --release on Ubuntu, crates/kicad-platform/src/linux.rs."},- }+ manifest["platforms"] = PLATFORMS dist = RUST / "dist"; dist.mkdir(exist_ok=True) stage = dist / "stage"; shutil.rmtree(stage, ignore_errors=True); stage.mkdir() shutil.copy2(exe, stage / "kicad-bridge.exe")@@ -141,7 +145,7 @@ def main(): m.update(version=ver, sha256=sha, size=zpath.stat().st_size, url=f"https://wiki.adom.inc/download/{REF}/{ver}/{zpath.name}", released_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),- runtime="native")+ runtime="native", platforms=PLATFORMS) mpath.write_text(json.dumps(m, indent=2) + "\n") # 4. INSIDERS tier only. The public manifest moves through tools/promote.py. ins = dict(m); ins["tier"] = "insiders"