← 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.

John Lauer ·46293d1f3d ·1mo ago ·parent a0bc522
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(&current);+        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"