Skip to content

taskbar-blob-shape v1.1.0: blobs for system buttons and the system tray - #5040

Merged
m417z merged 8 commits into
ramensoftware:mainfrom
Deen-0x:update-taskbar-blob-shape-mod
Aug 9, 2026
Merged

taskbar-blob-shape v1.1.0: blobs for system buttons and the system tray#5040
m417z merged 8 commits into
ramensoftware:mainfrom
Deen-0x:update-taskbar-blob-shape-mod

Conversation

@Deen-0x

@Deen-0x Deen-0x commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Taskbar Blob Shape 1.1.0 — blobs for system buttons and the system tray

Extends the Taskbar Blob Shape beyond task list buttons, each group behind its own toggle (all default off):

  • Start, Search and Task view — blob shown while their flyout is open. Search is supported in "Search icon only" mode (the icon lives nested in a TaskbarExtensionElement; the other search modes use different inner controls and are skipped).
  • Widgets — own toggle, shown while the widgets board is open.
  • Date and time — own toggle, shown while the notification center is open.
  • Other system tray buttons (control center, language, overflow chevron) — own toggle, shown while their flyout is open.
demo2

Implementation notes

  • One optional hook. System buttons and tray elements never pass through TaskListButton::UpdateVisualStates, so their activation is event-driven — ToggleButton Checked/Unchecked where available, otherwise CurrentStateChanged on the template root's state groups. Discovery rides a sweep of each taskbar's repeater (re-run on the grid's SizeChanged) plus an optional hook on TaskbarResources::OnExperienceToggleButtonVisualStateChanged — the same hook and sender-resolution approach taskbar-elastic-pill uses for its system-button tracking. It delivers the button as its sender argument and fires on every taskbar island, so secondary monitors that never produce task-button events are still discovered; if the symbol is missing, the mod degrades to sweep-only discovery and logs it. Everything downstream (per-button entries, geometry, composition glue, lifecycle, teardown) is the existing 1.0.0 machinery; the new kinds only differ in discovery and state source.
  • Multi-monitor behavior matches the native highlight. Checked state propagates across taskbars, and each monitor's instance of a button shows its own blob — the same semantics as the stock active indicator.
  • Tray hosting. The tray is reached from the taskbar sweep via XamlRoot().Content() (both frames share the XAML island), blobs are hosted in SystemTrayFrameGrid at the bottom of the z-order (which can never cover content), and elements are re-discovered on the tray grid's SizeChanged — which fires exactly when notification-area icons come or go. All re-sweep subscriptions are tracked and detached before the uninit barrier.
  • Deliberate exclusions. Notification-area app icons and MainStack status indicators (microphone, location) get no entries at all: Win32 Shell_NotifyIcon is a one-way protocol with no open-state feedback channel, and the status indicators only track hover states — neither can ever show a blob, so creating entries and state subscriptions for them would be pure overhead.
  • Settings fix. The CustomColor description was truncated in the UI — a space-before-# in the example triggered YAML's comment rule. Examples rewritten (no such sequence anywhere in the settings block now) and the Light | Dark syntax documented with concrete values.
  • Known behavior. Mid-session enable applies on first taskbar activity; logon and Explorer restart are instant; event-driven by design in this release (documented in the README).

Tested

  • Windows 11 25H2 (OS Build 26200.8973), dual monitor: all four groups activate/deactivate with their flyouts, including from the secondary taskbar's Start/Task view and clock; per-group toggles verified live in both directions; tray icon add/remove exercises the SizeChanged re-discovery; theme switching; enable/disable cycles restore all native visuals (BackgroundElement and BackgroundBorder); full regression pass over the 1.0.0 task-button behavior.

Changelog

  • Extends the blob beyond task list buttons, each group behind its own toggle (all default off): Start/Search/Task view, Widgets, date and time, and the other checkable system tray buttons (control center, language, overflow chevron), shown while their flyout is open — on every monitor's instance, matching the native active highlight.
  • System buttons are event-driven (ToggleButton Checked/Unchecked, with a CurrentStateChanged fallback) on top of the existing entry/geometry/expression/lifecycle machinery, discovered by sweeping each taskbar's repeater (re-swept on grid SizeChanged) and an optional hook on TaskbarResources::OnExperienceToggleButtonVisualStateChanged for islands without task-button activity. The tray is reached via XamlRoot().Content(), hosts blobs at the bottom of SystemTrayFrameGrid's z-order, and re-discovers elements on the tray grid's SizeChanged. Notification-area app icons and MainStack status indicators are deliberately excluded (no open-state exists for them: Shell_NotifyIcon is a one-way protocol, and the indicators only track hover states).
  • Fixes the CustomColor setting description being truncated in the UI (space-before-# triggered YAML's comment rule) and documents the Light | Dark color syntax with concrete examples.

Extends the blob beyond task list buttons, each group behind its own toggle: Start/Search/Task view, Widgets, date and time, and the other checkable system tray buttons (control center, language, overflow chevron), shown while their flyout is open.

No new hooks — these buttons never pass through TaskListButton::UpdateVisualStates, so they are event-driven (ToggleButton Checked/Unchecked, with a CurrentStateChanged fallback) on top of the existing entry/geometry/expression/lifecycle machinery. The tray is reached via XamlRoot().Content(), hosts blobs at the bottom of SystemTrayFrameGrid's z-order, and re-discovers elements on the tray grid's SizeChanged. Notification-area app icons and MainStack status indicators are deliberately excluded (no open-state exists for them: Shell_NotifyIcon is a one-way protocol, and the indicators only track hover states).

Also fixes the CustomColor setting description being truncated in  the UI (space-before-# triggered YAML's comment rule) and documents the Light | Dark color syntax with concrete examples.
@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 7, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Thanks for the pull request! This repository uses a two-stage review: an AI review that you run yourself, followed by a human review.

To get started, comment /ai-review. Once you're happy with the result, comment /ready-for-reviewer to hand it over to a human reviewer.

See the pull request review process for the full details.

Multi-monitor behavior matches the native highlight: checked state propagates across taskbars, and each monitor's instance shows its own blob.
@Deen-0x

Deen-0x commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 8, 2026
@Deen-0x
Deen-0x marked this pull request as ready for review August 8, 2026 05:20
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Solid, well-documented extension of the existing machinery — the lifecycle handling (weak refs, per-entry token tracking, [[clang::no_destroy]] on the entry container, UI-thread cleanup barrier) is in good shape. Three things to fix:

systemEventsAttached latches even when no subscription was attached. In EnsureBlobOnButton:

if (entry->kind != BlobEntry::KindTask && !entry->systemEventsAttached) {
    entry->systemEventsAttached = true;   // set unconditionally
    ...
    } else {
        auto root = VisualTreeHelper::GetChildrenCount(button) > 0
            ? VisualTreeHelper::GetChild(button, 0).try_as<FrameworkElement>()
            : nullptr;
        if (root) { /* subscribe CurrentStateChanged per group */ }
    }
}

If the element's template isn't applied yet on the first RefreshBlob (zero visual children — reachable when discovery runs during Explorer startup, or when the sweep reaches an item that was realized but not yet measured), root is null, nothing is subscribed, and the flag stays true forever. The later Loaded handler calls RefreshBlob again but the guard now skips it, so that element's blob is never driven by its own state changes — it only ever updates on the coarse SizeChanged re-sweeps, i.e. the blob won't appear/disappear when the flyout opens/closes. Same for a control that has no visual state groups on its template root. Only latch when something was actually attached:

bool attached = false;
if (auto toggle = button.try_as<...ToggleButton>()) {
    entry->checkedToken = ...; entry->uncheckedToken = ...;
    attached = true;
} else {
    ... for each group: entry->stateTokens.push_back(...);
    attached = !entry->stateTokens.empty();
}
entry->systemEventsAttached = attached;   // retried on the next RefreshBlob

The host SizeChanged detaches can escape the unload barrier. In Wh_ModBeforeUninit the re-sweep subscriptions are revoked by lambdas posted with RunAsync, but the early return sits after that loop:

for (auto& host : localHosts) { ... dispatcher.RunAsync(High, [trayGrid, token](){...}); }

if (localEntries.empty()) return;          // <-- posted work is never waited for
...
auto pending = std::make_shared<std::atomic<int>>((int)localEntries.size());

With entries empty and hosts non-empty (all buttons unloaded while a grid is still alive), Wh_ModBeforeUninit returns immediately, Wh_ModUninit returns, the DLL is unloaded, and the queued lambda — whose code lives in the mod image — runs afterwards and crashes Explorer. Count the host detaches in pending as well (create the event/counter before the host loop, sized localEntries.size() + posted host count, and decrement from the host lambdas), and only return early when both are empty.

Two related holes in the same area, worth closing together:

  • In SweepExistingButtons / SweepTray the handler is registered first and the token stored afterwards under the lock. If Wh_ModBeforeUninit moved the host vector out in between, the for (auto& host : ...) loop finds nothing, the token is dropped on the floor, and that subscription can never be revoked — same crash-after-unload. Revoke immediately when the store fails:

    bool stored = false;
    {
        std::lock_guard<std::mutex> lock(g_blobEntriesMutex);
        if (!g_unloading) {
            for (auto& host : g_taskbarHosts) {
                if (host.grid.get() == grid) { host.sizeToken = token; stored = true; break; }
            }
        }
    }
    if (!stored) grid.SizeChanged(token);  // nothing else will ever remove it
  • dispatcher.RunAsync(...) in both the host loop and the entry loop isn't wrapped in try/catch; it can throw (e.g. RPC_E_DISCONNECTED when the island's thread is going away) and the exception would escape Wh_ModBeforeUninit.

InsertBlobBelowRepeater lost its fail-safe. In 1.0.0 the function returned false when TaskbarFrameRepeater wasn't a direct child of the host grid, and both callers then bailed with setNativeHidden(false) so the native indicator stayed in charge. It now falls back to children.InsertAt(0, blobShape) unconditionally. That's the right behavior for the tray grid (no repeater by design), but on a taskbar RootGrid it converts "repeater not found where we expect it" into "blob inserted at the very bottom of the z-order, likely behind the taskbar's own background" — while show is still computed as isActive && entry->bound and setNativeHidden(true) suppresses BackgroundElement and RunningIndicator. The result on such a build would be active buttons with no indicator at all, which is exactly what the old fail-safe existed to prevent. Keep the two cases distinct, e.g.:

// hasRepeater: true for taskbar RootGrid hosts, false for the tray grid
bool InsertBlobBelowRepeater(Grid const& grid, Path const& blobShape, bool expectRepeater) {
    ... if found: InsertAt(i); return true;
    if (expectRepeater) { Wh_Log(L"TaskbarFrameRepeater not found as a direct RootGrid child"); return false; }
    children.InsertAt(0, blobShape);
    return true;
}

and restore the setNativeHidden(false); return; path in EnsureBlobOnButton for the false case.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • The tray grid gets registered as a taskbar host too. For a tray element, EnsureBlobOnButton resolves grid via GetHostRootGrid (→ SystemTrayFrameGrid) and then calls SweepExistingButtons(grid, ...), which pushes the tray grid into g_taskbarHosts and subscribes a second SizeChanged handler whose body (TaskbarSweepBody) always returns immediately on a tray grid (no TaskbarFrameRepeater child). So each tray grid carries one live-but-dead subscription. Skip the taskbar sweep when the resolved host isn't a taskbar RootGrid (you already know the kind from entry->kind).

  • Hard depth cap when locating the tray. FindDescendantByClass(content, L"SystemTray.SystemTrayFrame", 6) silently gives up past depth 6, and a miss disables all tray blobs with no diagnostic. Some layouts put the tray under extra wrappers (windows-11-taskbar-styler has selectors like ScrollViewer > ScrollContentPresenter > Border > Grid > SystemTray.SystemTrayFrame > Grid#SystemTrayFrameGrid). Consider a larger cap and a Wh_Log when the frame isn't found.

  • Entries are created for kinds whose toggle is off. With, say, TrayButtonsBlob disabled, every tray element still gets a BlobEntry, a hidden Path inserted into SystemTrayFrameGrid, Loaded/Unloaded/SizeChanged/CurrentStateChanged subscriptions and a theme handler — only isActive ends up false. Cheap to avoid: in RefreshBlob, when the kind is disabled and no entry exists yet for that element, skip creating one (the existing "toggle off ⇒ hide + restore" path still handles entries that already exist, so runtime toggling keeps working).

  • RefreshBlob runs the full path on every hover/press. The CurrentStateChanged fallback subscribes to all visual state groups of the template root, so pointer-over/pressed transitions on tray buttons each run the whole EnsureBlobOnButton body, including GetBlobShapeColorsApplication::Current().Resources() with two box_value lookups. Caching the accent color (invalidated on theme change / settings change), or early-outing when neither isActive nor the settings generation changed, would remove most of that.

  • Dead condition in IsSystemButtonChecked. name.find(L"Checked") != npos && name.find(L"Unchecked") == npos — no XAML state name can satisfy the first and fail the second, since Unchecked* spells the "c" lowercase. Harmless, but the second clause reads as if it's guarding something.

  • README visual. The demo.gif still shows 1.0.0 (task buttons only). Since the headline feature here is blobs on the system buttons and the tray, an updated or additional capture showing Start/Widgets/clock would help users understand what the four new toggles do.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • Active-state detection for Start / Search / Task view / Widgets rests entirely on IsChecked. The shell's own "flyout open" signal for these controls is the CommonStates group on ExperienceToggleButtonRootPanel / SearchBoxButtonRootPanel, with states ActiveNormal / ActivePointerOver / ActivePressed — that's what taskbar-elastic-pill reads (taskbar-elastic-pill.wh.cpp#L1755-L1763 and #L1796-L1804). IsSystemButtonChecked's fallback looks for a state name containing Checked, which can never match Active* — so for exactly the buttons the SystemButtonsBlob toggle targets there is effectively no fallback: if IsChecked stops tracking the flyout on a future build (or the class stops deriving from ToggleButton), the feature goes silent rather than degrading. Adding the Active* check to IsSystemButtonChecked (and subscribing the root panel's CommonStates in addition to Checked/Unchecked) would make it survive that. The Checked* heuristic does look right for the tray controls — SystemTray.OmniButton > Grid@CommonStates and SystemTray.ChevronIconView do define Checked / CheckedNormal / CheckedPointerOver / CheckedPressed.

  • The MainStack exclusion may be based on an incorrect premise. The comment in CollectTrayButtons says the MainStack IconViews' "state groups are hover/press feedback only", but the shared IconView template does define checked states there too — see windows-11-taskbar-styler's SystemTray.Stack#MainStack > ... > SystemTray.IconView > Grid#ContainerGrid@ > Border#BackgroundBorder block, which styles Background@Checked, @CheckedPointerOver and @CheckedPressed. The mic/location indicators do open a flyout when clicked on current builds, so it's worth re-checking whether they actually enter those states; if they do, they're a case TrayButtonsBlob would otherwise cover.

  • Vertical anchoring differs between hosts. yBase is measured from the host grid's origin, which is the taskbar RootGrid for task/system buttons but SystemTrayFrameGrid for tray elements. If that grid is ever inset from the top of the taskbar (theming mods, different taskbar heights), tray blobs and task-button blobs would no longer share a top edge. Worth a look on a themed setup; if it turns out to be off, transforming the tray grid's origin into the taskbar frame once and folding it into yBase would fix it.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 8, 2026
…ions

Full round applied — all three mandatory items, four of the optionals, and both comment corrections. Item by item:

Mandatory 1 — the attach latch. systemEventsAttached is now set from an attached result: true on the ToggleButton path (both tokens taken), and !entry->stateTokens.empty() on the fallback path. A not-yet-templated element — zero visual children during an early sweep, exactly the startup scenario the reviewer described — retries on its next refresh instead of silently becoming event-less forever. No double-attach risk: nothing was subscribed on the failed pass, so the retry starts clean.

Mandatory 2 — the barrier now owns the host detaches. The structure inverted: early return only when both entries and hosts are empty, pending initialized to entries + hosts up front, and every path through the host loop accounts for itself — dead grid, no dispatcher, inline detach, posted lambda, or failed post all decrement (with the signal check, covering the hosts-only case). Counting everything up front is what makes the zero-crossing race impossible: pending can't hit zero while anything remains unaccounted. Both RunAsync calls (host loop and entry loop) are wrapped with a fallback decrement on throw, so a failed post can neither hang the 2-second wait nor leak a count. And the token-store race in both sweeps: the store now happens under the lock with a !g_unloading check, and if it fails — uninit moved the list out between attach and store — the subscription is revoked inline on the spot, since nothing else will ever see it.

Mandatory 3 — fail-safe restored with the tray exception preserved. InsertBlobBelowRepeater takes expectRepeater (computed from the entry's kind at both call sites): taskbar hosts get the original contract back — missing repeater → log, false, caller bails with setNativeHidden(false) so the native indicator stays in charge — while tray grids keep the InsertAt(0) bottom-of-z-order path that's correct for them by design.

Optionals: O1 — the sweep call in Ensure is gated by kind, so tray grids no longer register as taskbar hosts with a permanently no-op subscription. O2 — tray frame search depth 6→10 plus a log line on miss, so a restructured build says why tray blobs are dark instead of nothing. O3 — the one with real design weight: FindOrCreateEntry gained createIfMissing, RefreshBlob classifies before materializing (new ClassifyButton/IsKindEnabled helpers, which also deduplicated the gate switch), so disabled kinds get no entries, no hidden Paths, no subscriptions. The piece the reviewer's note didn't cover: toggle-on would then find nothing to refresh — so Wh_ModSettingsChanged now re-sweeps the known hosts (posted at High, same priority-inversion rule as the rest) to materialize newly enabled kinds. O5 — the dead Unchecked clause is gone, and per functionality note 1, the scan now accepts the Active* family alongside Checked* (hstring bound to a local before the view — the dangling-temporary trap), so a future build where IsChecked stops tracking the flyout degrades to state names instead of going silent. The refresh triggering for that scenario is already covered by the TaskbarResources hook firing per state change, which is why I didn't also add CommonStates subscriptions to ToggleButtons — that would only add hover churn.

Skipped, deliberately: the hover-path caching (real, but an invasive perf refactor that doesn't belong in a review-fix commit — 1.2 roadmap material alongside the smart color picker), the README gif (yours), and the tray yBase margin note (a genuine subtlety, but purely theoretical until someone reports a themed setup where SystemTrayFrameGrid doesn't span the taskbar — noted in memory). Functionality note 2's challenge to the MainStack exclusion: the comment now states the actual basis — behaviorally tested, these instances never enter checked states — rather than a claim about the template, which the reviewer correctly showed styles Checked*.

Verification: braces/parens balanced, all new identifiers at expected counts, 1924 lines. Test focus for this round before you push: toggle every group off and back on (the O3 path is new machinery — entries must vanish-behaviorally on off and materialize on on, without an explorer restart), one enable while a system button's island is mid-startup if you can catch it (mandatory 1's retry), and a disable/enable cycle (the barrier restructure). The mandatory fixes don't change happy-path behavior, so the full matrix shouldn't need re-running — the toggles are the one genuinely new surface.
@Deen-0x

Deen-0x commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 8, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The three mandatory items from the previous round check out: the systemEventsAttached latch is now driven by an attached result, the uninit barrier counts entries + hosts up front with every path (dead grid, no dispatcher, inline, posted, failed post) decrementing exactly once, the token-store races in both sweeps revoke inline on failure, and InsertBlobBelowRepeater's fail-safe is back with expectRepeater. One regression came in with the optional O3 change:

A disabled group's buttons no longer register their taskbar as a sweep host. RefreshBlob now returns before an entry exists when the kind's toggle is off:

int8_t kind = ClassifyButton(button);
if (!IsKindEnabled(kind, localSettings)) return;   // <-- no entry, and no EnsureBlobOnButton

But host registration is a side effect of EnsureBlobOnButton (GetHostRootGridSweepExistingButtons), and it's what drives three separate things: the grid's SizeChanged re-sweep, the tray discovery (TaskbarSweepBodySweepTrayTrayRefreshAll), and the host list that the new Wh_ModSettingsChanged re-sweep iterates.

So on a taskbar island whose only discovery trigger is a system button — a secondary taskbar with "Show taskbar buttons on: taskbar where window is open" and nothing open on that monitor, where the TaskbarResources::OnExperienceToggleButtonVisualStateChanged hook is the only thing that ever fires — turning SystemButtonsBlob off also kills the tray and date-and-time blobs on that monitor, even though their own toggles are on. And since that island never lands in g_taskbarHosts, toggling TrayButtonsBlob/DateTimeBlob afterwards can't recover it either: the Wh_ModSettingsChanged re-sweep has no host to sweep. It stays dark until a window opens there. Before O3 the disabled kind still got an entry, so the grid was resolved and the island (tray included) was swept.

Register the host even when the entry is skipped:

int8_t kind = ClassifyButton(button);
if (!IsKindEnabled(kind, localSettings)) {
    // No entry for a disabled kind — but this element may still be the only
    // discovery trigger for its island, and the tray sweep plus the
    // settings-change re-sweep both hang off host registration.
    if (kind != BlobEntry::KindDateTime && kind != BlobEntry::KindTray) {
        if (auto grid = GetHostRootGrid(button)) {
            SweepExistingButtons(grid, localSettings);
        }
    }
    return;
}

SweepExistingButtons is idempotent and the re-entrant RefreshBlob for this same button hits the firstTime == false path, so it terminates. This needs forward declarations of GetHostRootGrid and SweepExistingButtons above RefreshBlob, alongside the existing FindOrCreateEntry / EnsureBlobOnButton ones.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • The orphan cleanup in FindOrCreateEntry is the one post that isn't accounted for by the unload barrier. Orphans are erased from g_blobEntries before the lambda is queued, so Wh_ModBeforeUninit never sees them and pending never counts them, and the lambda itself has no g_unloading check. In practice FIFO ordering saves it (the orphan is queued before the barrier's items, so it runs first), but if the two land the other way round — FindOrCreateEntry releases g_blobEntriesMutex, then Wh_ModBeforeUninit takes it, snapshots and posts, then the orphan post happens — the lambda runs after the DLL is gone. Since FindOrCreateEntry is called from RefreshBlob on the UI thread, the cheap fix is to do what the uninit paths already do and run it inline when you're already there:

    if (dispatcher && dispatcher.HasThreadAccess()) {
        /* stop timer, StopAnimation, RemoveFromParentPanel — inline */
    } else if (dispatcher) {
        dispatcher.RunAsync(...);
    }

    Nothing above it in the call chain is iterating grid.Children() at that point (TaskbarSweepBody has already broken out of its child loop, TrayRefreshAll iterates a pre-collected vector), so the inline removal is safe.

  • SweepTray registers a tray host and its SizeChanged even when both tray toggles are off. With DateTimeBlob and TrayButtonsBlob disabled, every tray resize (i.e. every notification-area icon appearing or disappearing) still runs the depth-12 CollectTrayButtons walk and a RefreshBlob per element that classifies and immediately returns. Bailing out of SweepTray when neither kind is enabled is safe now that Wh_ModSettingsChanged re-sweeps the taskbar hosts — that path goes through TaskbarSweepBodySweepTray again, so toggling either one back on still materializes the entries.

  • The ToggleButton subscribe branch isn't wrapped in try/catch, unlike the state-group branch below it. If toggle.Unchecked(...) throws after toggle.Checked(...) succeeded, systemEventsAttached never gets set, so the next RefreshBlob re-subscribes Checked and overwrites checkedToken — the first subscription then fires forever and is never revoked (including at unload). Same try/catch + attached treatment as the fallback path would close it.

  • else if (entry->kind == BlobEntry::KindUnknown) in RefreshBlob is now unreachable. Entries are only created a few lines above, where kind is assigned immediately, so an existing entry can never carry KindUnknown. Harmless, but it reads as if some other path creates entries.

  • README visual. The demo.gif still shows 1.0.0 (task buttons only) — an updated or additional capture showing Start / Widgets / clock would make the four new toggles self-explanatory.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The new Active* clause in IsSystemButtonChecked can't run for the buttons it was added for. The comment says accepting both families means "a future build where IsChecked stops tracking the flyout degrades to the state-name path instead of going silent", but the ToggleButton branch returns unconditionally:

    if (auto toggle = btn.try_as<...ToggleButton>()) {
        auto checked = toggle.IsChecked();
        return checked && checked.Value();   // <-- never falls through
    }

    Taskbar.ExperienceToggleButton (Start, Task view) and Taskbar.AugmentedEntryPointButton (Widgets) are ToggleButtons, so they never reach the state scan — the added clause only ever helps the non-toggle elements (tray controls, the search icon). If the intent is the stated fallback, return true on a checked toggle and otherwise fall through:

    if (auto toggle = btn.try_as<...ToggleButton>()) {
        auto checked = toggle.IsChecked();
        if (checked && checked.Value()) return true;
    }
    // fall through to the state-name scan

    Worth weighing against the opposite risk: once the scan runs for these controls too, a state group on some other build whose current state happens to start with Active would read as "open". taskbar-elastic-pill avoids the ambiguity by matching the exact three names on the specific group — ExperienceToggleButtonRootPanelCommonStatesActiveNormal / ActivePointerOver / ActivePressed (taskbar-elastic-pill.wh.cpp#L1755-L1763) — which is stricter than a starts_with on every group.

  • Tray z-order relies on SystemTrayFrameGrid having no opaque child at the bottom. InsertAt(0) puts the blob under the grid's children but above the grid's own Background, which is what theming mods actually set, so this should hold. If a build (or a styler config) ever injects a background Border as a child of that grid instead, the tray path has no equivalent of the taskbar path's expectRepeater fail-safe: show would still be true, the native BackgroundBorder would still be suppressed, and the active tray button would end up with no visible indicator at all. Not worth restructuring for now, but it's the one asymmetry left between the two hosts.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 8, 2026
Deen-0x added 3 commits August 8, 2026 10:06
…ote correction

Mandatory — host registration decoupled from entry creation. The disabled-kind path in RefreshBlob now still resolves GetHostRootGrid and calls SweepExistingButtons (for non-tray kinds) before returning entry-less. The comment in the code captures both the why (a system button may be its island's only discovery trigger, and the tray sweep plus the settings re-sweep both hang off host registration) and the termination argument (the re-entrant RefreshBlob for the same button hits firstTime == false). This was a good catch of a classic refactoring trap — O3 removed the entry, and host registration turned out to be a side effect riding on it. Forward declarations for both functions added beside the existing ones.

Optionals, all taken this round:

Orphan cleanup runs inline when already on the blob's UI thread — the common case, since FindOrCreateEntry is called from RefreshBlob. The comment records the real reason: a posted orphan cleanup is invisible to the unload barrier (orphans are erased from g_blobEntries before posting, so pending never counts them). The cross-thread posted fallback survives for the rare case and gained the g_unloading check.
SweepTray bails when both tray toggles are off — no host registration, no per-resize depth-12 walks for nothing. Safe precisely because of the settings-change re-sweep added last round, which the code comment cross-references.
ToggleButton subscription wrapped in try/catch with the sharp edge handled: on partial failure (Unchecked threw after Checked took), the Checked token is revoked and cleared — otherwise the retry would overwrite it and strand an unrevokable subscription firing into eventual unloaded code.
Unreachable KindUnknown else-if deleted — the mandatory fix rewrote that block anyway, and every creation path now assigns kind at birth.
README gif updated — one deliberate deviation from your instruction to flag: you gave the github.com/.../blob/... page URL, but that renders GitHub's file page, not the image, when embedded in markdown. I used https://raw.githubusercontent.com/Deen-0x/windhawk-assets/main/taskbar-blob-shape/demo2.gif — same form as the old URL, so it embeds on both windhawk.net and GitHub. If you specifically wanted the page link, say so and I'll swap it.

Functionality note 1 — taken, with the reviewer's own tightening: checked toggles now fall through to the state scan (so the comment's "degrades instead of going silent" claim is finally true for the buttons it was written about), and the Active* match became the exact three names rather than starts_with — their elastic-pill comparison was right that a prefix match becomes risky the moment ToggleButton-classed controls reach the scan. Functionality note 2 (the tray's InsertAt(0) having no fail-safe equivalent) — acknowledged and deliberately left, per their own "not worth restructuring now"; it's in memory as the one remaining host asymmetry if a themed setup ever surfaces it.

Verification clean: braces/parens balanced, all markers present, old gif URL fully gone, 1980 lines. Test focus: the disabled-kind discovery path is the new machinery — on the secondary monitor with no windows there, toggle SystemButtonsBlob off, restart explorer, hover the secondary Start, then confirm the secondary clock blob still works and that toggling SystemButtonsBlob back on lights Start without needing a window on that monitor. Plus one both-tray-toggles-off pass (the SweepTray bail) and toggle-recovery after. If the reviewer's pattern holds, next round should be convergent — this one already opened with all three prior mandatories verified.
One-line semantic change, heavily documented. if (checked) return checked.Value(); — a present IsChecked is final whether true or false, and only a null value (the property not tracking at all, which is the genuine future-build-degradation case the fallback was written for) reaches the state-name scan. The comment now records the frozen-blob mechanism in full — the event-model asymmetry between toggle entries (Checked/Unchecked only) and non-toggle entries (all groups subscribed) — specifically so that neither we nor a future contributor re-tries fall-through-on-false; it looks like a harmless robustness improvement and is actually a latch.
Mid-session enable applies on first taskbar activity (documented in the README); a standing visual-tree watcher was considered and deferred — discovery stays event-driven in this release.
@Deen-0x

Deen-0x commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 8, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


One item to fix; the rest is optional. The new discovery/state machinery is carefully scoped (kind gating, host re-sweeps, per-kind fail-safes) and the tray element classification matches what other mods observe in SystemTrayFrameGrid, so this is mostly about one gap in the unload path.

The unload barrier doesn't cover the refreshes posted from the hooks. DispatchElementRefresh (line 1639) posts a RunAsync lambda whose code lives in the mod DLL, but nothing tracks that post — Wh_ModBeforeUninit only waits for the entries and hosts it snapshotted. Two ways that goes wrong:

  • No barrier at all. Wh_ModBeforeUninit returns immediately when localEntries and localHosts are both empty (line 1792). That is exactly the state between the first TaskListButton::UpdateVisualStates hook and the first posted refresh actually running — entries and hosts are only created inside the posted lambda. So: hook fires → post queued → the taskbar thread is busy (Explorer still starting up) → the user disables the mod → both lists are still empty → no wait → Wh_ModUninit returns → FreeLibrary → the queued lambda executes unmapped code and takes Explorer down.
  • A post that lands behind the barrier's own posts. The ordering argument in the Wh_ModSettingsChanged comment (same priority ⇒ FIFO) only holds for work queued before the barrier queues its items. A hook that read g_unloading == false (lines 1661 / 1692) just before Wh_ModBeforeUninit set it can post after them, and the wait won't see it.

The fix is to count the posts rather than the entries — route every post site (the hook refresh, the settings-change re-sweep and per-entry refresh, the orphan cleanup) through one helper that takes the ticket under g_blobEntriesMutex, next to the same g_unloading check the snapshot uses:

std::atomic<int> g_pendingPosts{0};
HANDLE g_postsDrained = nullptr;  // manual-reset, created in Wh_ModInit

bool PostToUiThread(winrt::Windows::UI::Core::CoreDispatcher const& dispatcher,
                    std::function<void()> fn) {
    {
        std::lock_guard<std::mutex> lock(g_blobEntriesMutex);
        if (g_unloading) return false;  // can no longer start after the snapshot
        g_pendingPosts.fetch_add(1);
    }
    auto release = [] { if (g_pendingPosts.fetch_sub(1) == 1) SetEvent(g_postsDrained); };
    try {
        dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High,
                            [fn = std::move(fn), release]() { fn(); release(); });
        return true;
    } catch (...) { release(); return false; }  // a failed post must not leak a count
}

and then drop the both-empty early return in Wh_ModBeforeUninit and wait for g_pendingPosts to reach zero in addition to the existing per-entry/per-host barrier. That closes both holes with one counter and makes the "everything that can run mod code later is accounted for" property checkable in one place.

(This shape was inherited from taskbar-elastic-pill, which has the same gap, so it isn't a 1.1.0 regression — but 1.1.0 adds more post sites, and the rule is that nothing from the mod image may be running or scheduled once Wh_ModUninit returns.)

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • RefreshBlob can't tell "no entry yet" from "unloading". FindOrCreateEntry(button, false) (line 749) returns nullptr in both cases, so during unload the function still classifies, walks up with GetHostRootGrid, and calls SweepExistingButtons — which pushes a host into the g_taskbarHosts that Wh_ModBeforeUninit just cleared, attaches a SizeChanged handler and immediately revokes it again (the stored == false path), then runs a full TaskbarSweepBody + SweepTray for nothing. It's all self-correcting, but an if (g_unloading) return; at the top of RefreshBlob makes the dead work and the post-uninit writes to the host vectors go away.

  • All four new toggles default to true (lines 85-96), so everyone already running 1.0.0 gets blobs behind Start/Search/Task view/Widgets/tray the moment the mod updates. That may well be what you want, but it's worth being a deliberate choice rather than a side effect of the defaults.

  • IsSystemButtonChecked's Checked test is still a substring match while the Active* names were tightened to exact comparisons (line 641): name.find(L"Checked") != npos reads any future state whose name merely contains "Checked" as "flyout open". name.starts_with(L"Checked") (or an explicit name list, like the Active* set right below it) would match the tightening you already applied.

  • The "is this a tray kind" test is spelled out four times — lines 765, 1444, 1467 and 1511 all repeat kind != KindDateTime && kind != KindTray. A one-line IsTrayKind(int8_t kind) helper next to IsKindEnabled would keep the four sites from drifting apart.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The show-desktop sliver gets an entry it can never use. CollectTrayButtons accepts any SystemTray.IconView that isn't under MainStack (line 991), and ShowDesktopStack contains a SystemTray.IconView#SystemTrayIcon (see taskbar-tray-system-icon-tweaks.wh.cpp, which reaches it as ShowDesktopStack > Content > IconStack > ItemsPresenter > StackPanel > ContentPresenter > SystemTray.IconView). So it gets an entry, a hidden Path in the tray grid and CurrentStateChanged subscriptions on every one of its state groups — the exact per-icon overhead you deliberately avoided for MainStack and NotifyIconView. Either exclude it the same way (IsUnderElementNamed(child, L"ShowDesktopStack", 8)), or, if it does track a checked state, mention it in the TrayButtonsBlob description, since the setting currently promises only "control center, language, overflow chevron".

  • Search is limited to "Search icon only" mode because TaskbarSweepBody looks for a child named SearchIcon (line 883). taskbar-elastic-pill reaches the same button in the other modes by looking for the nested class instead — FindChildByClassName(searchBtn, L"SearchUx.SearchUI.SearchIconButton") then SearchBoxButtonRootPanel — and drives it from two optional SearchUx.UI.dll symbol hooks (SearchIconButton::UpdateVisualStates / PlayStateChange). Worth keeping in mind if you want to cover the search box modes later; the current limitation is documented, so this is just a pointer.

  • Mid-session enable (the README "Known behavior") is the same trade-off taskbar-elastic-pill makes, so it's a reasonable place to land. If you ever want instant apply without a standing visual-tree watcher, taskbar-ai-quota resolves the taskbar's XamlRoot straight from the Shell_TrayWnd / Shell_SecondaryTrayWnd windows and walks down from there — GetTaskbarXamlRoot — at the cost of two extra symbol hooks.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 8, 2026
Mandatory — the posted-refresh barrier. PostToUiThread takes its ticket under g_blobEntriesMutex beside the same g_unloading check the snapshot uses — that shared lock is the entire correctness argument, since it linearizes "may this post start?" against "has uninit begun?", killing the race that FIFO ordering couldn't. All four post sites route through it (hook refreshes, the settings re-sweep, settings per-entry refreshes, cross-thread orphan cleanup); the uninit barrier's own posts deliberately don't — they're issued after g_unloading and counted by their own pending, and ticketing them would deadlock against the helper's refusal. Wh_ModBeforeUninit lost the both-empty early return, resets the drain event immediately after the snapshot (from that point the count is monotonically non-increasing, which is what makes reset-check-wait sound against a manual-reset event that may be stale-signaled from normal operation), and waits on it as a second 2-second barrier after the entry/host one. The event lives Wh_ModInit → Wh_ModUninit; a lambda that throws can't leak a ticket (fn is wrapped, release always runs), and a failed RunAsync releases immediately. The three remaining raw RunAsync calls in the file are exactly the intended set: the helper itself plus the two uninit-barrier posts.

Optionals: RefreshBlob bails on g_unloading up top, with the comment explaining the ambiguity it resolves (null-from-FindOrCreateEntry meant two things, and the disabled-kind path was re-registering hosts into vectors uninit had just cleared); the Checked test is now starts_with — prefix, matching the Active* tightening and the actual name family; and IsTrayKind replaced all four spelled-out comparisons.

Defaults — all four toggles now false in both the YAML and the struct.
@Deen-0x

Deen-0x commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

/ready-for-reviewer

@windhawk-reviewer

Copy link
Copy Markdown

@Deen-0x /ready-for-reviewer can't be applied here: the most recent AI review covers cf984b0, but the current head of this pull request is ee85f90. Comment /ai-review to get a review of the current code.

@Deen-0x

Deen-0x commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 9, 2026
@windhawk-reviewer

Copy link
Copy Markdown

This pull request has already had 3 AI reviews in the last 24 hours, which is the limit, so no review was posted this time.

Comment /ai-review again after 2026-08-09 05:22 UTC (in 2 hours) to get another one.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 9, 2026
@Deen-0x

Deen-0x commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 9, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The unload machinery is in good shape overall — the ticket-counted post barrier, the inline-vs-posted orphan split and the per-entry revokes all hold up. Two remaining holes in it can leave mod code reachable after the DLL is gone:

1. Wh_ModBeforeUninit: the cleanup lambda captures a stale blobShape, so a blob created by an in-flight refresh is never removed and its ActualThemeChanged handler is never revoked.

blobShape is resolved on the Windhawk thread at snapshot time and captured by value:

{
    std::lock_guard<std::mutex> lock(g_blobEntriesMutex);
    blobShape = entry->blobShape.get();   // may still be null here
    btn = entry->button.get();
}
auto cleanup = [entry, blobShape]() { ... if (blobShape) { ...revoke themeToken, StopAnimation, RemoveFromParentPanel... } ... };

A RefreshBlob already running on the UI thread when the snapshot happens (it passed the g_unloading check a moment earlier) continues into EnsureBlobOnButton and creates the Path, inserts it into the RootGrid, subscribes ActualThemeChanged and sets entry->blobShape — all after that read. The posted cleanup then runs with blobShape == nullptr: the new Path stays parented in the taskbar tree with a live ActualThemeChanged subscription whose delegate lives in the mod image, and the expression animation is left running. The next theme switch calls into an unmapped DLL. (The native visuals hidden by that same refresh do get restored, since cleanup re-reads entry->bgHidden/indicatorHidden — only blobShape is captured stale.)

Fix: resolve it inside the lambda, on the UI thread. The pre-read is still needed to pick the dispatcher, but nothing else should use it:

auto cleanup = [entry]() {
    try {
        // Resolve here, not at snapshot time: a refresh in flight when the
        // snapshot was taken can create the blob afterwards.
        auto blobShape = entry->blobShape.get();
        ...

This closes the window completely, because the cleanup post is queued behind the in-flight refresh on the same dispatcher and no new refresh can start after the snapshot.

2. FindOrCreateEntry: the cross-thread orphan lambda bails on g_unloading without stopping the DispatcherTimer.

} else if (dispatcher) {
    PostToUiThread(dispatcher, [orphan, blob]() {
        if (g_unloading) return; // the uninit cleanup restores natives itself
        if (orphan->restoreTimer) { ...Stop... }

The comment doesn't hold for this path: the orphan was already erased from g_blobEntries before the post, so Wh_ModBeforeUninit's snapshot never sees it and no other code will ever stop its timer. A started DispatcherTimer is kept alive by the dispatcher, so it ticks into the unloaded image — the exact hazard the Tick body's self-stop comment describes. Same for the case where PostToUiThread returns false (refused because g_unloading was already set): nothing runs at all, and ~BlobEntry then releases the still-running timer from the calling thread.

The lambda is barrier-counted (its ticket was taken before g_unloading was set), so it is guaranteed to run before the DLL goes away — just drop the early return and always do the cleanup. And when the post is refused, fall back to the inline stop the no-dispatcher branch already does:

} else if (dispatcher) {
    if (!PostToUiThread(dispatcher, [orphan, blob]() {
            if (orphan->restoreTimer) { try { orphan->restoreTimer.Stop(); } catch (...) {} orphan->restoreTimer = nullptr; }
            try { ElementCompositionPreview::GetElementVisual(blob).Properties().StopAnimation(L"Translation"); } catch (...) {}
            RemoveFromParentPanel(blob);
        })) {
        if (orphan->restoreTimer) { try { orphan->restoreTimer.Stop(); } catch (...) {} orphan->restoreTimer = nullptr; }
    }
}

Background on why anything surviving Wh_ModUninit is fatal: https://github.com/ramensoftware/windhawk/wiki/Global-objects-and-process-shutdown

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • g_postsDrained leaks when Wh_ModInit fails. The event is created first thing in Wh_ModInit, but the function can still return FALSE (symbol hooking or the LoadLibraryExW hook failing), and Wh_ModUninit isn't called in that case — so every failed init plus settings-change retry leaks a handle. Create it after the hooks are in place, or close it on the failure paths. Related: if CreateEvent ever returns NULL, the second barrier is skipped silently (if (g_postsDrained && ...)) rather than degrading to something that still waits.

  • The Show Desktop button gets an entry it can never use. CollectTrayButtons matches SystemTray.IconView and only excludes the ones under MainStack; the Show Desktop element at the far right is a SystemTray.IconView under ShowDesktopStack (see taskbar-show-desktop-button-aero-peek.wh.cpp). It has no flyout, so it's exactly the case the MainStack exclusion exists for: a wasted entry, a hidden Path in the tray grid and hover-driven state subscriptions — plus a stray blob if that shared IconView template ever does enter a Checked* state on a click. Worth excluding it the same way.

  • The tray frame is re-discovered from scratch on every sweep. TaskbarSweepBody runs FindDescendantByClass(content, L"SystemTray.SystemTrayFrame", 10), a depth-10 DFS over the whole XAML root calling winrt::get_class_name (a QI + GetRuntimeClassName + hstring alloc) on every node, and this repeats on each grid SizeChanged and on every settings change. Since you already track the host, caching the resolved tray grid as a second weak ref in SweptHost would make repeat sweeps nearly free.

  • Refresh cost on plain hover. For non-toggle tray elements you subscribe CurrentStateChanged on every state group of the template root, so hover/press churn runs a full RefreshBlob: the whole entry list is pruned (resolving every weak ref), caches are re-validated, GetBlobShapeColors boxes two strings and hits Application::Current().Resources() when no custom color is set, and the color vector is rebuilt and compared. An early-out when isActive matches the last applied value and the blob is already in the right state would cut nearly all of it. You noted this as 1.2 material — fine to leave, just flagging the tray subscriptions widened the surface.

  • Comment volume. Several comments document the history of a decision rather than the code ("Falling through on false was tried and froze blobs on…", "the FIFO/priority argument alone can't…", "Uninit moved the host list out between attach and store"). The invariants are worth stating, but a lot of this reads as review dialogue and will age badly; a pass that keeps the invariant and drops the argument would make the file noticeably easier to maintain at 2000 lines.

  • IsKindEnabled's default: covers both KindSystem and KindUnknown. Since every creation path now assigns a kind at birth, making KindSystem explicit and letting default: return false would fail closed if a new kind is ever added.

Functionality notes

Non-critical observations about the feature behavior itself.

  • The state-name fallback is unreachable for the ToggleButton path. IsSystemButtonChecked returns checked.Value() whenever IsChecked holds a value — but a two-state ToggleButton always holds one (nullptr only happens for IsThreeState in the indeterminate state). So the comment's premise — that a future build where IsChecked stops tracking the flyout "degrades to state names instead of going silent" — doesn't actually hold: such a build would report false, not null, and the scan would never run. Not a bug today (you tested the behavior, and the sticking-blob fix that motivated it is real), but either the comment or the detection deserves a second look. For reference, taskbar-elastic-pill reads the CommonStates ActiveNormal/ActivePointerOver/ActivePressed names off ExperienceToggleButtonRootPanel for the same buttons and never consults IsChecked — worth being aware you're the first mod in the catalog relying on IsChecked here.

  • The fallback scan only inspects child 0's state groups. That matches the tray controls (SystemTray.OmniButton > Grid@CommonStates, SystemTray.IconView > Grid#ContainerGrid@, per windows-11-taskbar-styler's targets), so it's right for the elements that actually use it. Just noting that for the Widgets button the styler targets Taskbar.TaskListButtonPanel#ExperienceToggleButtonRootPanel > Border#BackgroundElement@CommonStates, i.e. a group that may not sit on the template root — if AugmentedEntryPointButton ever stops being a ToggleButton, that path would attach nothing and retry forever rather than failing loudly.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 9, 2026
Mandatory 1 — cleanup resolves on the UI thread. The lambda now captures [entry] alone and reads entry->blobShape inside, where the ordering guarantee holds: the cleanup post queues behind any in-flight refresh on the same dispatcher, and nothing new can start after the snapshot — so the read sees the blob that refresh created, revokes its ActualThemeChanged, stops its expression, and unparents it. The pre-read block survives strictly for dispatcher selection, with a comment stating exactly that so nobody "optimizes" the resolve back out. Elegant property of this fix: it's zero-cost — the same work, just moved to the only thread where the read is authoritative.

Mandatory 2 — the orphan lambda earns its ticket. The g_unloading bail is gone, with the comment inverted to state why it must not exist on this path: the orphan pre-erased from g_blobEntries is invisible to the uninit snapshot, making this lambda the only code that will ever stop its timer — and the ticket taken before g_unloading guarantees it runs before FreeLibrary. A refused post (uninit already started) falls back to the inline best-effort stop the no-dispatcher branch already had, with the sender self-stop as backstop.

Optionals, all six addressed: the drain event is created only after every init failure path (closing the leak-per-failed-init, since Wh_ModUninit never runs after FALSE), and a null event now degrades to a bounded 200×10 ms poll rather than silently skipping the barrier; the Show Desktop IconView under ShowDesktopStack is excluded alongside MainStack (same class, same no-flyout reality, and your point about a stray blob if the shared template ever enters Checked* on click is exactly right); SweptHost caches the resolved tray grid, so the depth-10 DFS runs only on cache miss — and the not-found log correspondingly fires only on genuine discovery attempts; IsKindEnabled names KindSystem explicitly and fails closed on anything future; and the comment pass trimmed the four review-dialogue passages they quoted down to their invariants.

Functionality note 1 — conceded, and worth being plain about: you are right that my null-fall-through "degradation path" was a fiction — a two-state ToggleButton's IsChecked always holds a value, so a broken future build reports false, and the scan never runs. The rewritten comment states the true invariant (toggles must read IsChecked, because their event model can't re-evaluate a mid-transition state read), demotes the null branch to the cheap safety valve it actually is, and records the known fix if IsChecked ever breaks: the elastic-pill panel-read. Being the first mod in the catalog relying on IsChecked here is now documented risk rather than accidental novelty. Note 2 (the scan reading only child 0, and Widgets' groups sitting deeper if it's ever de-toggled) — acknowledged, unchanged: the retry-forever behavior it warns about is the attach design working as intended, and the TaskbarResources hook keeps refreshes flowing regardless.
@Deen-0x

Deen-0x commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 9, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


No blocking issues — looks good to merge.

Both mandatory items from the previous round check out. The uninit cleanup lambda now captures [entry] and resolves blobShape on the UI thread, so a Path created by an in-flight refresh is unparented and its ActualThemeChanged revoked; the pre-read is correctly reduced to dispatcher selection. The cross-thread orphan lambda no longer bails on g_unloading, and the refused-post path falls back to the inline Stop(). I also walked the full set of things that can execute mod code after unload — host and anchor SizeChanged, Unloaded/Loaded, Checked/Unchecked, CurrentStateChanged, ActualThemeChanged, the DispatcherTimer, and every RunAsync post — and each one is either revoked on the UI thread by the entry/host barrier or ticket-counted by the post barrier. The [[clang::no_destroy]] std::optional<...> on g_blobEntries is justified (the entries own thread-affine DispatcherTimers) and the reset() in Wh_ModBeforeUninit fully releases, per https://github.com/ramensoftware/windhawk/wiki/Global-objects-and-process-shutdown. The settings block and the code reads match in both directions, and defaulting all four new toggles to false is the right call for an update to an already-published mod.

Optional improvements

Minor polish — none of this affects users, so it's your call.

  • Per-event cost on the disabled-kind path, which is now the default. With all four toggles off, every OnExperienceToggleButtonVisualStateChanged — including plain hover and focus on Start, Search and Task view, on every taskbar — still posts a High-priority RunAsync that runs RefreshBlob. That does: FindOrCreateEntry's full prune pass under g_blobEntriesMutex (one weak-ref resolve per tracked button), a ClassifyButton get_class_name, then GetHostRootGrid (up to 20 more get_class_name calls, each a QI + GetRuntimeClassName + hstring alloc) and a second lock+scan in SweepExistingButtons — all to re-register a host that was already registered on the first such event. The registration genuinely needs to happen once per island (that's the regression fix from two rounds ago), but not on every event: a cheap guard before GetHostRootGrid — e.g. compare button.XamlRoot() against the already-registered hosts', or cache the resolved grid per element — reduces the steady state to a lock and a pointer compare. Same family as the hover-refresh cost you deferred to 1.2; flagging it because the default-off path added a second copy of it.

  • An island with no system tray re-runs the full tray search on every sweep. SweptHost::trayGrid only caches the hit. When FindDescendantByClass(content, L"SystemTray.SystemTrayFrame", 10) misses — a secondary taskbar without a tray, or a restructured build — the depth-10 DFS over the whole XAML root (with a get_class_name per node) and the "not found" log re-run on every grid SizeChanged and every settings change, which is exactly the case the cache was meant to cover. A "searched, not found" marker on the host record, cleared when the record is rebuilt, would cap it; the trade-off is that a tray appearing later on that island wouldn't be picked up until the host record is rebuilt, so it's worth a comment either way.

  • ClassifyButton fails open while IsKindEnabled fails closed. Anything that isn't Taskbar.TaskListButton, Taskbar.AugmentedEntryPointButton or SystemTray.* falls through to return BlobEntry::KindSystem, so any future element that reaches RefreshBlob gets a blob under the "Start, Search and Task view" toggle. Now that IsKindEnabled's default: deliberately fails closed, matching Taskbar.ExperienceToggleButton explicitly (plus the search icon by its own class or name) and returning KindUnknown otherwise would make the two consistent.

  • @description still describes only the 1.0.0 scope — "Injects a customizable blob shape behind active taskbar items." It's what windhawk.net shows in the mod list, so it's worth extending to mention the system buttons and system tray coverage.

  • Two sequential 2-second barriers. Wh_ModBeforeUninit waits up to 2 s for the entry/host cleanup and then up to 2 s more for the post drain, so a wedged taskbar UI thread blocks the Windhawk thread for 4 s. Computing one deadline up front and passing the remaining time to the second WaitForSingleObject keeps the worst case at 2 s without weakening either barrier.

Functionality notes

Non-critical observations about the feature behavior itself.

  • SystemTray.CopilotIcon gets no blob. CollectTrayButtons matches an exact allowlist (SystemTray.OmniButton, SystemTray.ChevronIconView, SystemTray.IconView), and on builds that still show the Copilot tray entry its class is SystemTray.CopilotIcon — structurally the same as ChevronIconView (SystemTray.CopilotIcon#CopilotIcon > Grid#ContainerGrid > Border#BackgroundBorder, see windows-11-taskbar-styler.wh.cpp), so it would very likely work if it were collected. It silently falls out of "System tray buttons blob" today, and so would any future SystemTray.* button class. Not worth restructuring the allowlist into a heuristic, but adding CopilotIcon alongside ChevronIconView is a one-line change if you can still reproduce it on a build that has it.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 9, 2026
@Deen-0x

Deen-0x commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

/ready-for-reviewer

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-reviewer Ready for a human reviewer, and in the queue for one. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 9, 2026
@m417z
m417z merged commit 53c9298 into ramensoftware:main Aug 9, 2026
5 checks passed
@windhawk-reviewer windhawk-reviewer Bot removed the waiting-for-reviewer Ready for a human reviewer, and in the queue for one. label Aug 9, 2026
@Deen-0x
Deen-0x deleted the update-taskbar-blob-shape-mod branch August 9, 2026 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants