Repository navigation
The Guard Watcher
The watcher — called the guard in the UI and in code — is the component that makes
“show all tray icons” stick: it reconciles HKCU\Control Panel\NotifyIconSettings against
the desired visibility whenever the store changes. This page documents its design, every
timing knob with its exact range, the three visibility modes, the statistics it exposes, and
the defect that made the event-driven path inert in every build from 8.3.0 to 8.6.2 — kept on
record, together with the fix that shipped in 8.6.3 and how it was verified end-to-end on a
real Windows machine.
- Design as implemented
- Visibility modes
- Timing knobs and their exact ranges
- Statistics and events
- Pause, resume and manual passes
- Defect history: the armed event was never waited on
- Fix shipped in 8.6.3
- See also
The guard is one worker thread plus a small state struct, both behind an SRWLOCK
(src/core/guard.cpp):
-
Arm — open
HKCU\Control Panel\NotifyIconSettingsread-only and callRegNotifyChangeKeyValuewithbWatchSubtree = TRUE,REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET, an auto-reset event and asynchronous delivery. The notification is one-shot: the watch is re-armed after every pass and after every paused iteration. -
Wait —
WaitForMultipleObjectson {stop event, registry event, wake event} with a timeout taken fromsettings.guard.pollMs(INFINITEwhenpollMsis0). The registry event is what makes event-driven mode work; the wake event makes pause/resume take effect immediately instead of waiting for the next change. Before 8.6.3 only the stop event was in the wait set — see the defect history below. -
Debounce — after waking, wait
settings.guard.debounceMsmore (clamped 50–5000 on load, default 400) so that burst writes from an application registering several icons settle into one pass. -
Pass — rescan the store and reconcile per the visibility mode (below); write
IsPromotedonly where it differs; log anEVENT promoted N icon(s): …line; emit a UI event"N|name1, name2"(names truncated past 160 characters). -
Optional shell restart — if
autoRestartExploreris on, at least two consecutive passes changed something, andexplorerCooldownSec(floor 10 s, default 60 s) elapsed since the last restart, restart Explorer and emit anExplorerRestartedevent. -
Loop — re-arm and wait again.
Stop()signals the stop event and joins with a 4 s budget; the handles are closed only when that join succeeded (a timed-out join leaks them deliberately rather than leaving the thread waiting on closed handles).
flowchart TD
A["ArmRegistryWatch<br/>RegNotifyChangeKeyValue"] --> B["wait: stop | registry | wake<br/>timeout = pollMs or INFINITE"]
B -->|stopped| Z["exit thread"]
B -->|timeout| C["debounce wait"]
C -->|paused?| A
C --> D["RunPass: rescan + reconcile"]
D --> E{"changed and<br/>auto-restart armed?"}
E -->|yes| F["Explorer restart<br/>with cooldown"]
E -->|no| A
F --> A
Important
Builds 8.3.0 – 8.6.2 waited on the stop event only: the registry event from step 1 was
signaled by the operating system but was not part of any wait set, so with the default
pollMs = 0 the loop never ran a pass at all. Fixed in 8.6.3 — see
the defect history and
the fix.
Set from the tray menu radios, the Guard page, or --mode <value>; stored as
guard.displayMode (full | custom | anything else normalizes to custom).
| Mode | Desired state per icon | Use case |
|---|---|---|
full |
promoted | “show everything, always” |
custom (default) |
promoted unless the executable matches an exclusion wildcard or the icon id is in icons.userHidden
|
per-icon control |
compact |
promoted only for TrayIconGuard's own registration (guard::OwnIconId, matched by module path) |
minimal taskbar |
Changing the mode saves settings, logs an event, and runs an immediate reconcile pass so
the effect is visible at once (guard::SetDisplayMode).
Exclusions are case-insensitive wildcards matched against both the file name and the full
executable path (store::IsExcluded), e.g. onedrive.exe or *teams*.
| Setting (settings.json) | UI control | Values | Default | Load-time clamp |
|---|---|---|---|---|
guard.debounceMs |
Guard → Debounce segment | 200 / 400 / 800 / 1500 ms | 400 | 50–5000 |
guard.pollMs |
Guard → Polling fallback segment | 0 (off) / 2000 / 5000 / 15000 ms | 0 | ≥ 0 |
guard.autoRestartExplorer |
Guard → toggle | bool | false | — |
guard.explorerCooldownSec |
Guard → Cooldown segment | 30 / 60 / 120 / 300 s | 60 | ≥ 10 |
guard.enabled |
Guard → master toggle; first run forces true | bool | true | — |
guard.legacyBelt |
Guard → repairs toggle | bool | true | — |
With pollMs = 0 the intended semantics are “event-driven only”; with pollMs > 0 the
loop wakes on the poll timer (this is the path that actually reconciles in the published
builds — see the defect).
guard::Stats (read under lock) powers the Guard page statistics card and the status bar:
running, paused, promotionsSession, passes, lastChangeTick, lastEventTime
(local SYSTEMTIME), startTick for uptime. Event kinds emitted to the UI sink:
Started, Stopped, Paused, Resumed, Promoted (payload N|names), Discovered,
ExplorerRestarted, Error. The UI marshals them with a registered window message
(TrayIconGuard.GuardEvent.v8, WM_APP+1 fallback) and a heap-copied payload the UI
thread deletes; the tray icon shows a balloon on Promoted when
behavior.notifications is on, and its state dot turns green (guarding) or amber (paused).
- Pause/Resume: tray menu item, Dashboard button, Guard master toggle, or
--toggle-guardIPC to a running instance. A paused guard still re-arms its watch each iteration but skips passes. - Manual pass:
--promote-all(CLI), Dashboard Promote all now, Icons page Promote all, tray menu Promote all now. All route throughguard::PromoteAllNow, which runs one reconcile pass off-thread-safe and returns the change count; UI variants create an automatic snapshot first whenbehavior.autoBackupis on. -
--restore-defaultsdeletes everyIsPromotedvalue and removes the legacy belt, handing control back to Windows.
(Present in 8.3.0 – 8.6.2; fixed in 8.6.3. Kept as the record of what was wrong and why it stayed invisible for so long.)
In src/core/guard.cpp (ThreadProc) the wait is:
const DWORD pollMs = static_cast<DWORD>(settings::Get().pollMs);
const DWORD timeout = pollMs > 0 ? pollMs : INFINITE;
const DWORD wait = WaitForSingleObject(g_stopEvent, timeout);g_regEvent — the event handed to RegNotifyChangeKeyValue in ArmRegistryWatch — never
appears in any wait. Consequences, traced:
- With the default
pollMs = 0, after arming, the thread blocks on the stop event indefinitely: no pass ever runs, no icon is ever re-promoted, statistics stay at zero passes. The guard reports “guarding” (green dot,running = true). - With
pollMs > 0the loop works, but as a pure poller: the event-driven behavior documented since v7 is not what executes. -
--watchinherits the same loop, so headless deployments are affected identically.
The defect is invisible on unsupported stores (the thread logs
guard cannot start: NotifyIconSettings store absent and never starts) and invisible in
short smoke tests; it manifests exactly as the decay problem the project exists to solve —
silently, days later.
A secondary robustness issue sat in Stop(): the stop and registry event handles were closed
after a 4 s join budget even if the worker was still inside a pass, leaving a window where the
thread waits on closed handles. Also fixed in 8.6.3.
src/core/guard.cpp now waits on all three events, coalesces bursts while still listening, and
keeps Stop() from closing handles a running thread may be using:
HANDLE waits[3] = {g_stopEvent, g_regEvent, g_wakeEvent};
const DWORD wait = WaitForMultipleObjects(3, waits, FALSE, timeout);
if (wait == WAIT_OBJECT_0) break; // stop requested
// a burst of writes keeps the debounce window open; a stop inside it breaks out
if (!DebounceBurst(static_cast<DWORD>(settings::Get().debounceMs))) break;
...
ArmRegistryWatch(); // one-shot notification: re-arm after every passBecause g_regEvent is auto-reset, one burst of registry activity collapses into a single
wake; the poll timer remains a safety net (pollMs > 0 still bounds the maximum reconcile
latency). Resume() signals g_wakeEvent, so resuming reconciles immediately.
Verification recipe actually executed on Windows 11 (build 26300) against the released
binaries — a promoted icon whose application is not running, default guard.pollMs = 0:
$key = "HKCU:\Control Panel\NotifyIconSettings\<id of a promoted icon>"
Start-Process .\TrayIconGuard.exe -ArgumentList '--watch' -RedirectStandardOutput watch.log
Set-ItemProperty $key IsPromoted 0 -Type DWord # simulate Explorer hiding the icon
Start-Sleep 6
(Get-ItemProperty $key).IsPromoted # 8.6.3: 1 | 8.6.2: 0
Get-Content watch.log # [event] promoted 3|PSExpressBroker.exe| Build |
IsPromoted 6 s after being set to 0 |
--watch log |
|---|---|---|
| 8.6.2 (and every earlier 8.x) | 0 — nothing happens | only [event] watcher armed
|
| 8.6.3 | 1 — the guard re-promoted it | [event] promoted 3|PSExpressBroker.exe |
The app log records the same pass: [EVENT] promoted 3 icon(s): PSExpressBroker.exe, roughly
2.5 s after --watch started.
- How Tray Visibility Works — why reconciliation is necessary at all
-
Configuration Reference — full
guard.*key table with clamps -
Command-Line Interface —
--watch,--promote-all,--toggle-guard,--mode - Troubleshooting — “icons hid again” decision tree
- Architecture — thread model and event marshalling
TrayIconGuard · feed 8.6.3 / source tree 8.6.3 (see Changelog and Versioning) · PolyForm Noncommercial 1.0.0 · Repository · Report an issue · Home · Glossary · FAQ · Troubleshooting
Every claim in this wiki is traced to a source line at commit 124e849 or reproduced by
executing the program (build + Wine battery, 2026-10-16); measured discrepancies between
documentation, source and shipped binaries are documented, not smoothed over.
Home Getting Started Features Overview Comparison with Alternatives
Installation and Deployment Configuration Reference Command-Line Interface
How Tray Visibility Works Legacy Script Editions The Guard Watcher
GUI Pages Guide Backups and Snapshots Localization and Themes Self-Updater Guide Security and Privacy Troubleshooting
Architecture Roadmap and Ideas Independent-Audits Testing and QA Building from Source Changelog and Versioning FAQ Glossary
8.6.3 feed / 8.6.3 source · PolyForm Noncommercial 1.0.0