master
John Lauer 0.9.301: Taskbar hygiene (John: frozen progress bars on every KiCad window, no Adom badge): the boot sweep that clears… 0d3d7f6 2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
"""win_taskbar.py — taskbar identity + Adom overlay badges for KiCad windows.

John's design (2026-08-17, after seeing pup's overlay badges): stamp the Adom
favicon as an OVERLAY on every bridge-driven KiCad window's taskbar button, so
the user sees at a glance "adom is controlling my kicad". Gated by the
`overlayBadges` setting (handlers/settings.py, default on).

THE LESSON (paid for by pup first, then fusion, now us — src/server.js in pup,
handlers/taskbar_overlay.py in fusion): an in-bridge ITaskbarList3::SetOverlayIcon
is IN-PROCESS-ONLY. The COM call returns S_OK cross-process and the shell silently
ignores it (verified live here 2026-08-17: badges never rendered on kicad.exe's
buttons). The working path is AD-core's `desktop_taskbar` verb: hand it the badge
PNG as base64 (resources/adom-overlay-32.png — mark anchored TOP-RIGHT of a
transparent 32px canvas, matching how pup places its favicon badge; John,
2026-08-21: "put the favicon in the upper right corner instead of the lower
right". Windows renders the overlay from that canvas, so the anchor in the
asset is what decides the corner)
and AD paints the overlay natively, cross-process.

    set:   desktop_taskbar {hwnd, overlay: {icon: <b64 png>, tooltip}}
    clear: desktop_taskbar {hwnd, overlay: {badge: "none"}}

SECOND LESSON (same day): eeschema.exe/pcbnew.exe taskbar buttons showed blank
white-document icons even though both exes carry icon resources — the shell's
icon cache for those exe paths was poisoned (buttons first appeared during the
pre-0.9.151 parking era, before wx assigned window icons, and Windows cached the
blank). Rebuilding the shell icon cache means killing Explorer; instead we
BYPASS the poisoned cache with pup's identity machinery: stamp each eeschema/
pcbnew window with a per-exe AUMID (`desktop_set_window_identity`) whose
iconPath is a real .ico extracted from the exe itself — a fresh identity gets a
fresh icon lookup. kicad.exe's cache entry is healthy; its windows stay native.
"""
import os
import sys
import threading
from pathlib import Path

IS_WINDOWS = sys.platform == "win32"

_BADGE_PATH = str(Path(__file__).resolve().parent.parent / "resources" / "adom-overlay-32.png")

# ⛔ THIS BRIDGE STAMPS NO AUMIDs. EVER. (John, 2026-08-23: "i just want
# default kicad behavior.")
#
# History, so nobody rebuilds this: weeks ago an AI stamped per-window AUMIDs
# on eeschema/pcbnew windows and at least one stamp ran before icon extraction
# succeeded - Windows caches the icon PER AUMID STRING, so those buttons became
# a white generic box that survived KiCad reinstalls. The first "fix" (.v2
# suffix, 0.9.240-242) replaced the poison with Adom-flavoured identity, which
# was still an override of default KiCad behaviour. The real fix is REMOVAL:
# `_identity_repair()` below clears the per-window AppUserModelID property on
# every eeschema/pcbnew window and deletes our Adom.KiCad.* registry
# registrations, so buttons fall back to the exes' own native icons.
#
# The overlay badge and the progress bar DO NOT need an AUMID - they ride
# ITaskbarList3 (SetOverlayIcon/SetProgressValue) against the hwnd's button
# directly. Activity indication keeps working on fully native buttons.
_ONCE_STAMPED_EXES = {"eeschema.exe", "pcbnew.exe"}
_AUMID_PREFIX = "Adom.KiCad."

_badged: set = set()
_B64 = None
_LOCK = threading.Lock()


def _badge_b64():
    global _B64
    if _B64 is not None:
        return _B64
    try:
        import base64
        _B64 = base64.b64encode(open(_BADGE_PATH, "rb").read()).decode()
    except Exception:
        _B64 = ""
    return _B64


def _ad(verb: str, args: dict) -> bool:
    try:
        from handlers import ad_client
        r = ad_client.call(verb, args, timeout=8)
        raw = str(r)
        return bool(r) and ("Taskbar updated" in raw
                            or (isinstance(r, dict) and r.get("success") is not False
                                and not r.get("error")))
    except Exception:
        return False


def _hwnd_exe_base(hwnd) -> str:
    try:
        from handlers.win_focus import _hwnd_exe_path
        return os.path.basename(_hwnd_exe_path(hwnd) or "").lower()
    except Exception:
        return ""


def _clear_window_aumid(hwnd) -> bool:
    """Blank the per-window PKEY_AppUserModel_ID (VT_EMPTY) so the button
    falls back to the exe's own identity. ctypes COM because the bridge has
    no pywin32; AD's desktop_set_window_identity has no clear path (an empty
    appId no-ops, verified live)."""
    import ctypes
    from ctypes import wintypes

    class GUID(ctypes.Structure):
        _fields_ = [("d1", ctypes.c_uint32), ("d2", ctypes.c_uint16),
                    ("d3", ctypes.c_uint16), ("d4", ctypes.c_ubyte * 8)]

    class PROPERTYKEY(ctypes.Structure):
        _fields_ = [("fmtid", GUID), ("pid", ctypes.c_uint32)]

    class PROPVARIANT(ctypes.Structure):
        _fields_ = [("vt", ctypes.c_uint16), ("r1", ctypes.c_uint16),
                    ("r2", ctypes.c_uint16), ("r3", ctypes.c_uint16),
                    ("data", ctypes.c_byte * 16)]

    try:
        iid_ips = GUID(0x886D8EEB, 0x8CF2, 0x4446, (ctypes.c_ubyte * 8)(
            0x8D, 0x02, 0xCD, 0xBA, 0x1D, 0xBD, 0xCF, 0x99))
        pkey = PROPERTYKEY(GUID(0x9F4C2855, 0x9F79, 0x4B39, (ctypes.c_ubyte * 8)(
            0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3)), 5)
        ps = ctypes.c_void_p()
        hr = ctypes.windll.shell32.SHGetPropertyStoreForWindow(
            wintypes.HWND(hwnd), ctypes.byref(iid_ips), ctypes.byref(ps))
        if hr != 0 or not ps.value:
            return False
        # IPropertyStore vtable: QI(0) AddRef(1) Release(2) GetCount(3)
        # GetAt(4) GetValue(5) SetValue(6) Commit(7)
        vtbl = ctypes.cast(ps.value,
                           ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents
        set_value = ctypes.WINFUNCTYPE(
            ctypes.c_long, ctypes.c_void_p, ctypes.POINTER(PROPERTYKEY),
            ctypes.POINTER(PROPVARIANT))(vtbl[6])
        commit = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_void_p)(vtbl[7])
        release = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_void_p)(vtbl[2])
        empty = PROPVARIANT()               # vt == VT_EMPTY
        ok = set_value(ps, ctypes.byref(pkey), ctypes.byref(empty)) == 0
        ok = (commit(ps) == 0) and ok
        release(ps)
        return ok
    except Exception:
        return False


def _delete_adom_aumid_registrations() -> int:
    """Delete every HKCU AppUserModelId registration we ever created
    (Adom.KiCad.*): icon paths, display names, the poisoned strings, all of
    it. Best-effort; returns how many keys went."""
    n = 0
    try:
        import winreg
        root = r"Software\Classes\AppUserModelId"
        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, root) as k:
            subs, i = [], 0
            while True:
                try:
                    subs.append(winreg.EnumKey(k, i))
                    i += 1
                except OSError:
                    break
        for s in subs:
            if s.startswith(_AUMID_PREFIX):
                try:
                    winreg.DeleteKey(winreg.HKEY_CURRENT_USER, root + "\\" + s)
                    n += 1
                except OSError:
                    pass
    except Exception:
        pass
    return n


def _rebuild_button(hwnd) -> None:
    """Win11 bakes the button's tile at creation; a cleared AUMID only shows
    after the button is recreated. Brief hide + re-show (no activation) forces
    that - except on the foreground window, which we never blink."""
    try:
        import ctypes
        u = ctypes.windll.user32
        if u.GetForegroundWindow() == hwnd:
            return
        if u.IsIconic(hwnd):
            u.ShowWindow(hwnd, 0)   # SW_HIDE
            u.ShowWindow(hwnd, 7)   # SW_SHOWMINNOACTIVE
        else:
            u.ShowWindow(hwnd, 0)
            u.ShowWindow(hwnd, 8)   # SW_SHOWNA
    except Exception:
        pass


def _identity_repair() -> None:
    """One-shot per boot: remove every taskbar-identity override this bridge
    (or any earlier AI experiment) ever applied, restoring default KiCad
    taskbar behaviour. Idempotent - a clean box is a fast no-op."""
    cleared = _delete_adom_aumid_registrations()
    fixed = 0
    for h in _enum_all_kicad_hwnds():
        if _hwnd_exe_base(h) not in _ONCE_STAMPED_EXES:
            continue
        if _clear_window_aumid(h):
            _rebuild_button(h)
            fixed += 1
    if cleared or fixed:
        print("[taskbar] identity repair: %d registry key(s) removed, "
              "%d window(s) restored to native identity" % (cleared, fixed))
    _repair_kicad_shortcut_aumids()


def _repair_kicad_shortcut_aumids() -> None:
    """Fill the AppUserModelID field KiCad's OWN installer forgot (solved live
    on John's taskbar, 2026-08-23).

    Standalone editor processes present AUMIDs like Kicad.Kicad.pcbnew.10.0
    (built at runtime; the pattern is confirmed by reading the taskbar buttons'
    AutomationIds). The 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 - on some machines it
    falls back to the window's icon (ConfRoomROG), on others it paints the
    generic white page (AdomLapper; why the fallback differs is unsolved).
    Writing KiCad's own AUMID onto KiCad's own user-level shortcut makes the
    icon resolution deterministic - the exact mechanism that keeps the main
    KiCad button correct. NO Adom identity involved.

    The mapping takes effect when explorer next (re)builds its Start Menu
    AUMID index (logon / explorer restart); we never restart explorer
    ourselves. Idempotent: shortcuts already carrying an AUMID are skipped."""
    try:
        import subprocess
        from kicad_detect import detect_kicad
        info = detect_kicad() or {}
        ver = str(info.get("version") or info.get("kicadVersion") or "")
        ver = ".".join(ver.split(".")[:2]) if ver else ""
        if not ver:
            return
        ps = r'''
$cs = @"
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
public class LnkAumid {
  [StructLayout(LayoutKind.Sequential, Pack=4)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }
  [StructLayout(LayoutKind.Explicit)] public struct PropVariant {
    [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p;
  }
  [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
  public interface IPropertyStore {
    int GetCount(out uint c); int GetAt(uint i, out PROPERTYKEY k);
    int GetValue(ref PROPERTYKEY k, out PropVariant v);
    int SetValue(ref PROPERTYKEY k, ref PropVariant v); int Commit();
  }
  [ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}
  public static bool Set(string path, string aumid) {
    var link = (object)new CShellLink();
    var pf = (IPersistFile)link;
    pf.Load(path, 2);
    var ps = (IPropertyStore)link;
    var k = new PROPERTYKEY { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };
    PropVariant cur; ps.GetValue(ref k, out cur);
    if (cur.vt == 31 && Marshal.PtrToStringUni(cur.p) != "") return false;
    var v = new PropVariant { vt = 31, p = Marshal.StringToCoTaskMemUni(aumid) };
    bool ok = ps.SetValue(ref k, ref v) == 0 && ps.Commit() == 0;
    pf.Save(path, true);
    Marshal.FreeCoTaskMem(v.p);
    return ok;
  }
}
"@
Add-Type -TypeDefinition $cs
$v = "%VER%"
$map = @{
  ("PCB Editor " + $v + " (standalone).lnk") = ("Kicad.Kicad.pcbnew." + $v)
  ("Schematic Editor " + $v + " (standalone).lnk") = ("Kicad.Kicad.eeschema." + $v)
  ("Gerber Viewer " + $v + ".lnk") = ("Kicad.Kicad.gerbview." + $v)
}
$n = 0
$root = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\KiCad " + $v
foreach ($name in $map.Keys) {
  $p = Join-Path $root $name
  if (Test-Path $p) { try { if ([LnkAumid]::Set($p, $map[$name])) { $n++ } } catch {} }
}
Write-Output $n
'''.replace("%VER%", ver)
        cnw = getattr(subprocess, "CREATE_NO_WINDOW", 0)
        r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
                           capture_output=True, timeout=60, creationflags=cnw)
        n = (r.stdout or b"").decode(errors="replace").strip()
        if n and n != "0":
            print("[taskbar] wrote KiCad's own AUMID onto %s standalone "
                  "shortcut(s) (installer gap); effective at next explorer "
                  "restart/logon" % n)
    except Exception:
        pass


def apply_adom_badge(hwnd) -> bool:
    """Stamp the Adom overlay on the window's taskbar button (idempotent per
    hwnd). ITaskbarList3 by hwnd - touches no AUMID, no base icon. Best-effort:
    never raises, never blocks a verb."""
    try:
        from handlers.settings import get_settings
        if not get_settings().get("overlayBadges", True):
            return False
    except Exception:
        pass
    if hwnd in _badged:
        return True
    with _LOCK:
        icon = _badge_b64()
        if not icon:
            return False
        ok = _ad("desktop_taskbar", {"hwnd": int(hwnd), "overlay": {
            "icon": icon, "tooltip": "Adom is driving this KiCad window"}})
        if ok:
            _badged.add(hwnd)
        return ok


def clear_adom_badge(hwnd) -> None:
    try:
        if hwnd in _badged:
            _ad("desktop_taskbar", {"hwnd": int(hwnd), "overlay": {"badge": "none"}})
        _badged.discard(hwnd)
    except Exception:
        pass


def _all_kicad_hwnds() -> set:
    """Only windows the bridge OWNS (tracked live + revived from the persisted
    ownership ledger at boot). 0.9.154 briefly badged EVERY enumerated KiCad
    window to survive restarts — wrong the moment the user opens their own
    KiCad (John, 2026-08-17). The ledger solves restart amnesia the right way;
    user-owned windows are never touched."""
    try:
        from handlers.win_focus import _bridge_windows
        return set(_bridge_windows)
    except Exception:
        return set()


def rebadge_all(enable: bool) -> int:
    """Setting flipped (or bridge booted): apply/clear on every KiCad window."""
    n = 0
    try:
        import ctypes as _ct
        for h in _all_kicad_hwnds():
            if not _ct.windll.user32.IsWindow(h):
                continue
            if enable:
                _badged.discard(h)  # force reapply
                if apply_adom_badge(h):
                    n += 1
            else:
                clear_adom_badge(h)
                n += 1
    except Exception:
        pass
    return n


def badge_existing_on_boot() -> None:
    """Server boot: badge KiCad windows that survived a bridge restart, off the
    boot path (AD calls + enumeration can stall; never block serving)."""
    if not IS_WINDOWS:
        return
    import time

    # INVERTED (2026-08-22, twice in one day and each move follows from the
    # badge's one-sentence meaning). First: boot re-BADGING was retired - a
    # freshly booted bridge is driving nothing, so stamping at boot lied.
    # Then the live idle-clear proof caught the mirror-image gap: a badge
    # stamped by the PREVIOUS bridge process survived the restart, because the
    # fresh process had no _badged memory and an unarmed watchdog - overlays
    # outlived a deploy by design. Same sentence, same conclusion: at boot,
    # Adom is driving nothing, so any overlay found on a KiCad window is stale.
    # CLEAR them, off the boot path.
    def _boot_clear():
        import time as _t
        # John, 2026-09-03: twelve KiCad buttons kept frozen green bars for
        # hours after a night of bridge_install restarts. This sweep ran ONCE at
        # +6s, while ab itself was still restarting, and every call failed
        # silently. Retry across the first two minutes until at least one
        # window answers ok, then keep a slow idle reaper alive (below).
        for delay in (6, 15, 30, 60, 120):
            _t.sleep(delay)
            cleared = 0
            try:
                for h in _enum_all_kicad_hwnds():
                    r = _ad("desktop_taskbar", {"hwnd": int(h), "overlay": {"badge": "none"},
                                                "progress": {"state": "none"}})
                    if r:
                        cleared += 1
            except Exception:
                cleared = 0
            if cleared:
                print(f"[taskbar] boot sweep cleared {cleared} KiCad button(s)")
                break
        _idle_reaper()

    def _idle_reaper():
        """Fail-safe, the way ab reaps captions: every 5 minutes while the
        bridge is driving nothing, clear progress and badge on every KiCad
        window. A bar that nobody is painting is a lie, whoever painted it."""
        import time as _t
        while True:
            _t.sleep(300)
            try:
                with _inflight_lock:
                    busy = _inflight > 0
                if busy or (_last_drive and (_t.time() - _last_drive) < 60):
                    continue
                for h in _enum_all_kicad_hwnds():
                    _ad("desktop_taskbar", {"hwnd": int(h), "overlay": {"badge": "none"},
                                            "progress": {"state": "none"}})
            except Exception:
                pass
    threading.Thread(target=_boot_clear, daemon=True,
                     name="kicad-boot-badge-clear").start()

    def _repair():
        import time as _t
        _t.sleep(8)              # off the boot path
        try:
            _identity_repair()
        except Exception:
            pass
    threading.Thread(target=_repair, daemon=True,
                     name="kicad-identity-repair").start()
    return


# ── ACTIVITY-DRIVEN overlay (John, 2026-08-22) ──────────────────────────────
# THE HIGH-LEVEL GOAL of the Adom overlay: tell the user "Adom is driving
# KiCad for you, right now". Which means it must track ACTIVITY, not ownership:
#
#   * John drove his own KiCad via web control and saw NO badge, because the
#     old code stamped only windows the bridge had itself spawned. But "who
#     launched it" is not what the badge claims - "who is driving it" is, and
#     a verb navigating the user's own KiCad is Adom driving it.
#   * The badge must also COME OFF when the driving stops, or it decays into
#     wallpaper. Five minutes with no KiCad verb -> cleared -> "it's yours".
#
# The 0.9.154 lesson still holds in its correct scope: never badge a user's
# KiCad that Adom is NOT touching (boot-time re-badging of enumerated windows
# was wrong). Activity semantics satisfy both: badge while verbs run against
# KiCad, whoever launched it; clear on idle.

_IDLE_CLEAR_SEC = 300           # "adom is no longer driving kicad, it's yours"
_last_drive = 0.0
_watchdog_on = False
# Verbs currently IN FLIGHT. The first ticker keyed "driving" off the
# note_driving timestamp alone, which is stamped once at dispatch START - so a
# 23s verb had its bar cleared at ~4s, mid-work (caught on a live screenshot:
# the direct paint showed, the ticker's did not). A counter cannot lie about
# in-flight the way a timestamp does.
_inflight = 0
_inflight_lock = threading.Lock()


def _enum_all_kicad_hwnds() -> set:
    """EVERY live KiCad window, not just bridge-owned - during activity the
    badge belongs on whatever Adom is actually driving."""
    try:
        from handlers.win_focus import _enum_kicad_hwnds
        return _enum_kicad_hwnds()
    except Exception:
        return set()


def note_driving() -> None:
    """A KiCad verb is running: stamp the overlay everywhere and reset the
    idle clock. Called from the dispatch chokepoint; best-effort, never
    blocks a verb."""
    global _last_drive, _watchdog_on, _inflight
    import time as _t
    _last_drive = _t.time()
    with _inflight_lock:
        _inflight += 1
    try:
        from handlers.settings import get_settings
        if not get_settings().get("overlayBadges", True):
            return
    except Exception:
        pass
    try:
        for h in _enum_all_kicad_hwnds():
            apply_adom_badge(h)
    except Exception:
        pass
    if not _watchdog_on:
        _watchdog_on = True
        threading.Thread(target=_idle_watchdog, daemon=True,
                         name="kicad-badge-idle").start()
    global _prog_ticker_on
    if not _prog_ticker_on:
        _prog_ticker_on = True
        print("[taskbar] progress ticker starting")
        threading.Thread(target=_progress_ticker, daemon=True,
                         name="kicad-taskbar-progress").start()


def note_done() -> None:
    """The verb finished (success, failure, or raise): its driving is over.
    Paired with note_driving at the dispatch chokepoint's finally."""
    global _inflight, _last_drive
    import time as _t
    with _inflight_lock:
        _inflight = max(0, _inflight - 1)
    _last_drive = _t.time()      # idle clock starts when the LAST verb ends


def _idle_watchdog() -> None:
    """Clear the overlay after _IDLE_CLEAR_SEC with no KiCad verb activity.
    Checks every 30s; a new verb simply re-stamps, so flapping costs nothing."""
    import time as _t
    while True:
        _t.sleep(30)
        try:
            if _last_drive and (_t.time() - _last_drive) >= _IDLE_CLEAR_SEC:
                # Sweep every enumerated window, not just _badged: process
                # restarts (deploys, the footprint-cache remedy) orphan badges
                # this set has never heard of.
                for h in (_enum_all_kicad_hwnds() | set(_badged)):
                    clear_adom_badge(h) if h in _badged else _ad(
                        "desktop_taskbar", {"hwnd": int(h), "overlay": {"badge": "none"}})
        except Exception:
            pass


# ── Taskbar PROGRESS instead of the orange flash (John, 2026-08-22) ────────
# The orange flash was never ours: this bridge calls FlashWindowEx nowhere.
# Windows itself flashes the button when a backgrounded KiCad's self-raise is
# denied by the focus sentinel - a side effect of the background-first
# contract. And a flash NAGS: it sits there until the user clicks it away, so
# it converts "Adom is working" into "you must do something". A progress bar
# says the same thing and then removes itself, which is the difference between
# informing and demanding. Adopted from the fusion bridge, toggle
# `activityIndicator` (progress|flash|both|none, default progress).

_FLASHW_STOP = 0


def _flash_stop(hwnd) -> None:
    """Cancel an active OS attention-flash on a window (FLASHW_STOP)."""
    try:
        import ctypes
        class FLASHWINFO(ctypes.Structure):
            _fields_ = [("cbSize", ctypes.c_uint), ("hwnd", ctypes.c_void_p),
                        ("dwFlags", ctypes.c_uint), ("uCount", ctypes.c_uint),
                        ("dwTimeout", ctypes.c_uint)]
        fi = FLASHWINFO(ctypes.sizeof(FLASHWINFO), int(hwnd), _FLASHW_STOP, 0, 0)
        ctypes.windll.user32.FlashWindowEx(ctypes.byref(fi))
    except Exception:
        pass


def _paint_progress(hwnd, percent) -> None:
    """Paint (or clear) the taskbar progress bar on one KiCad button."""
    if percent is None:
        _ad("desktop_taskbar", {"hwnd": int(hwnd), "progress": {"state": "none"}})
    elif percent < 0:
        _ad("desktop_taskbar", {"hwnd": int(hwnd),
                                "progress": {"state": "indeterminate"}})
    else:
        _ad("desktop_taskbar", {"hwnd": int(hwnd), "progress": {
            "state": "normal", "value": max(1, min(100, int(percent)))}})


_prog_ticker_on = False


def _progress_ticker() -> None:
    """While verbs run, mirror the bridge's MEASURED live progress frames onto
    every KiCad taskbar button; clear the bars the moment activity stops.

    2s cadence with a cached hwnd set - deliberately NOT per-paint enumeration
    at 0.7s, which the fusion code review measured at ~780 subprocess spawns
    per long verb."""
    import time as _t
    global _prog_ticker_on
    idle_polls = 0
    hwnds = set()
    try:
        while True:
            _t.sleep(2)
            mode = "progress"
            try:
                from handlers.settings import get_settings
                mode = get_settings().get("activityIndicator", "progress")
            except Exception:
                pass
            with _inflight_lock:
                _busy = _inflight > 0
            driving = _busy or (_last_drive and (_t.time() - _last_drive) < 3.5)
            if driving:
                idle_polls = 0
                hwnds = _enum_all_kicad_hwnds() or hwnds
                pct = None
                try:
                    from handlers import progress as _pg
                    frames = _pg.live(mine_only=False)
                    if frames:
                        pct = min((f.get("percent") for f in frames
                                   if f.get("percent") is not None), default=-1)
                        pct = -1 if pct is None else pct
                except Exception:
                    pct = -1
                for h in hwnds:
                    if mode in ("progress", "both"):
                        _paint_progress(h, pct if pct is not None else -1)
                    if mode in ("progress", "none"):
                        _flash_stop(h)   # the OS flash contradicts the bar
            else:
                idle_polls += 1
                if hwnds:
                    for h in hwnds:
                        _paint_progress(h, None)   # bars vanish WITH the work
                    hwnds = set()
                if idle_polls > 5:
                    _prog_ticker_on = False
                    return               # nothing to do; next verb restarts us
    except Exception as e:
        print(f"[taskbar] ticker DIED: {type(e).__name__}: {e}")
        _prog_ticker_on = False