Skip to content

feat(notifications): show notifications in a YASB menu - #1070

Open
esershnr wants to merge 11 commits into
amnweb:mainfrom
esershnr:feat/notifications-menu
Open

feat(notifications): show notifications in a YASB menu#1070
esershnr wants to merge 11 commits into
amnweb:mainfrom
esershnr:feat/notifications-menu

Conversation

@esershnr

Copy link
Copy Markdown

Until now the notifications widget could only report a count: clicking it sent Win+N and handed the user off to the Windows Action Center, so the notifications themselves were never visible inside YASB. This renders them in YASB's own popup instead, the way the other popup widgets work, while keeping the Action Center one click away for anything the panel cannot do.

  • Replace the hand-off to the Windows Action Center with a YASB popup menu listing the toasts it holds, with app icons, per-app grouping, relative timestamps and per-item dismiss
  • Extend the existing notification listener to read and broadcast toast contents instead of only the unread count
  • Add a Do Not Disturb toggle backed by the shared DndService, and a footer link that still opens the Windows Notification Center
  • Everything is configurable under a new menu block and styleable through .notification-menu

How it works

The bar counter is unchanged: it still comes from the WNF state the widget already subscribed to. What is new is the list, which comes from UserNotificationListener on the same QThread the widget already starts, so no second listener, event loop or thread is introduced.

The widget and the listener talk over the existing EventService, the same way WindowsNotificationClear already did. Three events are added: WindowsNotificationsChanged (listener to widgets) plus WindowsNotificationRefresh and WindowsNotificationRemove (widgets to listener). Removal is one-way: the widget asks, the listener removes and re-broadcasts the new list, and the menu redraws from that. There is no local mutation of the list in the widget.

WinRT objects are bound to the listener thread, so each toast is flattened into a small NotificationItem dataclass before it crosses to the GUI thread.

The menu follows the structure of the GitHub widget's popup. App icons and click-to-activate reuse get_icon_for_aumid and activate_app_by_aumid, the Do Not Disturb toggle reuses DndService, and relative timestamps reuse get_relative_time.

Behaviour change

callbacks.on_left now defaults to toggle_menu instead of toggle_label, so the menu is discoverable out of the box. The old behaviour is untouched and still available: toggle_notification opens the Windows Notification Center exactly as before, and toggle_label is unchanged. Existing configs that set on_left explicitly are unaffected.

Limitations worth knowing

  • UserNotification.app_info raises E_NOTIMPL for senders whose AUMID is not registered, which covers plain Win32 apps and script-generated toasts. Those notifications have no app name, no icon and nothing to activate, so they are grouped under a generic Other header rather than being labelled with a name we do not have. Registered AUMIDs (packaged apps, shell shortcuts, or an AppUserModelId registry entry) resolve normally.
  • Windows exposes no public API to activate a toast, so clicking an item brings the sending app's window to the front instead of replaying the toast's own activation payload. Only the dismiss button removes a notification.
  • The bar count stays driven by WNF, so it keeps working exactly as before even if the toast list cannot be read.

Testing

Tested on Windows 11 with notifications from packaged apps (WhatsApp, Microsoft Store), shell-shortcut senders (PowerShell) and unregistered senders. Verified live updates while the menu is open, single dismiss and clear-all against the real Action Center, click-to-activate, the Do Not Disturb toggle against Focus Assist, and every combination of the new menu options across all alignments and directions. ruff check and ruff format pass, and schema.json was left untouched for CI to regenerate.

AI usage disclosure

Per the AI-generated code policy, AI was used as an assistive tool for a significant part of this change. I reviewed every line, verified the behaviour against the real Windows APIs on my machine, and can explain the changes.

@amnweb

amnweb commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR.

You can get images as well and show them there, but I can't remember why I stopped working on this a few months ago. There is some genuine restriction when running from an unpacked app or from Python outside of the Store, but I can't remember what it was. Anyway, I'll check when I have time, maybe I can find some reference on the Discord server if I wrote something about it there.

@esershnr

Copy link
Copy Markdown
Author

Just wanted to start by saying thanks for this awesome project! It keeps getting better with every release.

On the restriction, I went digging. Notification access seems to be the one permission Windows doesn't track per app for non-Store apps. Mic, camera and location all keep a list of individual exes under the consent store, which is what Settings shows per app. This one has no such list at all:

capability                 global   per-exe entries
userNotificationListener   Allow    none
microphone                 Allow    103
webcam                     Allow    25
location                   Allow    6

So YASB never shows up under Settings > Privacy & security > Notifications, it just inherits whatever the single global toggle says.

I also built the exe and ran the same checks on it and on Python, expecting a difference. There wasn't one:

frozen exe   access: Allowed   toasts returned: 2
python       access: Allowed   toasts returned: 2

What did bite me was app names coming back empty in the compiled build, until I added winrt.windows.applicationmodel to the cx_freeze includes. That's already in build.py, so maybe that was the thing you ran into.

One gap on my side: the code ignores the result of the access request, so if someone has that toggle off they'd just get an empty panel saying "no new notifications", with no idea why. I can make it say access is off instead, and the counter would keep working since it comes from WNF. Small change, happy to push it if you want it here.

On images, I'd rather leave those for a separate PR. The point of this one is just getting notifications out of the Windows panel and into YASB, and images feel like a new feature on top of that. That said, if you'd rather have them in before this gets merged, I'm happy to build them here too, just say the word.

Until now the widget could only report a count: clicking it sent Win+N and handed
the user off to the Windows Action Center, so the notifications themselves were
never visible inside YASB. This renders them in YASB's own popup instead, the way
the other popup widgets work, while keeping the Action Center one click away.

- Replace the hand-off to the Windows Action Center with a popup menu listing the toasts it holds, with app icons, per-app grouping, relative timestamps and per-item dismiss
- Extend the existing notification listener to read and broadcast toast contents instead of only the unread count, converting WinRT objects on its own thread and reusing its asyncio loop for refresh and removal
- Add a Do Not Disturb toggle backed by the shared DndService, and a footer link that still opens the Windows Notification Center
- Clicking a notification brings the sending app to the front, only the dismiss button removes it
- Group senders that expose no app info under a generic header instead of leaving them untitled, since unregistered AUMIDs make the listener return E_NOTIMPL
- Default callbacks.on_left is now toggle_menu, toggle_notification keeps the previous Action Center behaviour
- Apply the tooltip option that the widget already accepted but never used
@esershnr
esershnr force-pushed the feat/notifications-menu branch from 497cb6b to 68dfecc Compare August 25, 2026 15:24
The listener discarded the result of the access request, so a user who had turned off
"Let apps access my notifications" saw an empty menu claiming there were no new
notifications. Windows grants this permission through a single global switch for apps
installed outside the Store, with no per-app entry, so nothing hinted at the cause.

- Check the access status before every read rather than only at startup, since the user
  can revoke it at any time, and broadcast it to the widgets when it changes
- Skip reading the list while access is denied instead of asking for it on every refresh
- Say access is turned off in the menu, with a link to the Windows settings page that
  holds the switch
- The count on the bar is unaffected, it still comes from WNF
Clearing announced an empty Action Center before checking whether the removals had
taken effect, so a notification Windows refused to remove, or one that arrived while
the loop was running, stayed on screen while the widget claimed there was nothing left.
Zeroing the cached total also made the WNF callback skip the next update, because it
compares against that value.

- Read the notifications back after the removal loop and broadcast the real remainder
- Leave the count on the bar to the WNF callback rather than forcing it to zero
- Document that the count Windows reports and the number of entries in the menu can
  differ, and that an individual removal can fail

ClearNotifications() would avoid the loop but is not usable here, it fails with
ERROR_NOT_FOUND for apps installed outside the Store.
@esershnr

Copy link
Copy Markdown
Author

Thanks for the PR.

You can get images as well and show them there, but I can't remember why I stopped working on this a few months ago. There is some genuine restriction when running from an unpacked app or from Python outside of the Store, but I can't remember what it was. Anyway, I'll check when I have time, maybe I can find some reference on the Discord server if I wrote something about it there.

Added the access fix.

While testing it I ran into something else: clear all was announcing an empty Action Center before checking whether the removals had actually gone through, so anything Windows refused to remove stayed on screen while the widget claimed there was nothing left. It now reads the list back and reports what is really there. ClearNotifications() would have avoided the loop entirely, but it throws ERROR_NOT_FOUND outside the Store, which I'm guessing is why the loop was there to begin with. I also noticed the WNF count and the number of entries the listener returns don't always agree, I've seen it off by one or two, so I left the count where it was and just documented it rather than changing where it comes from.

On images I owe you a correction. The toast's own images aren't reachable from the listener. I sent a toast carrying appLogoOverride, hero and an inline image, and the binding still hands back nothing but text elements, there's no accessor for the rest. The app logo is the only image the API exposes. The toast images do sit in the notification database, but that's undocumented territory, so I'd rather raise it as its own thing than smuggle it in here.

@amnweb

amnweb commented Aug 30, 2026

Copy link
Copy Markdown
Owner

About the count, I think this needs some context on where those numbers come from, since it is all undocumented.

When I was reverse engineering this I found two WNF states, and they are not the same thing:

  • 0x0D83063EA3B8D035 (WNF_SHEL_NOTIFICATION_TOTAL) = Windows 11 only. This is the actual number of notifications in the Action Center. It stays correct until you delete them. Opening the notification center does not change it.
  • 0x0D83063EA3BC1035 (WNF_SHEL_NOTIFICATIONS) = the fallback, because the first state does not exist on Windows 10. This one is the taskbar badge, not a count. It behaves exactly like the Windows 10 notification badge.

So the behaviour depends on the OS. On Windows 11 the count should stay accurate until notifications are removed. On Windows 10 the moment you open the notification center the badge clears and the state returns 0, even if 10 notifications are still there.

I retested on a Windows 10 VM to be sure I am not wrong:
image

The yellow line is the moment I opened the action center. I did not touch any notification. WNF returned 0 and the taskbar badge disappeared at the same time.

Which Windows version did you see the mismatch on? If it was Windows 10, this explains it and it is working as intended, the number is the badge. If it was Windows 11, then something else is going on and I would like to know how to reproduce it.

@esershnr

Copy link
Copy Markdown
Author

Windows 11 25H2, build 26200, so it is the TOTAL state rather than the badge the startup line reads Notification service started (WNF_SHEL_NOTIFICATION_TOTAL).

Thanks for the breakdown, it made it much easier to know what to look for. I instrumented it instead of guessing: subscribed to both states and sampled the listener and the notification database alongside them.

Your Windows 10 result reproduces on Windows 11 too. Opening the Action Center dropped the badge state to 0 while TOTAL stayed where it was, with nothing touched. So the badge behaves the same on both versions, we simply don't read it on 11.

wnf_terminal

And I found the case you asked for. It is not the badge and it is not a timing artefact: once an app sends more notifications than Windows keeps for it, TOTAL settles one above the listener and stays there.

cap_terminal

26 toasts from a single AUMID, plus one notification already there from another app. Windows keeps 20 per app, so 21 end up visible and both the listener and wpndatabase agree on 21. TOTAL says 22 and does not come back down; it was still 22 twenty seconds later. Reproducible: I got the same +1 on a separate run that sent 5 toasts at a time until it crossed the limit.

So the listener is not the one that is wrong here, the count is. I don't know the mechanism — I tried 5, 6 and 10 past the limit and it was exactly one every time, so it does not look like a missed decrement per dropped entry.

There is a second, much smaller effect: during a burst the count trails the listener for a fraction of a second while Windows writes its own store first. That one showed up in 1 sample out of ~300, so it is not what anyone would notice.

Nothing to change in the code as far as I can see the count comes from the right state and the menu comes from the listener, and neither can do better than what Windows reports. What is wrong is the note I added in the docs: it says the count "is not always the number of entries in the Action Center" without saying why, which reads as if the number were unreliable in general. I would rather it said this:

{count} on the bar and the list in the menu come from two different places, so they do not always agree. The count is the number Windows publishes for the shell, and what it means depends on the version: on Windows 11 it is the number of notifications in the Action Center, on Windows 10 it is the taskbar badge, which clears as soon as the Action Center is opened even though the notifications are still there. The count can also settle slightly above the menu after an app has sent more notifications than Windows keeps for it, since the oldest are dropped from the list and the count does not always follow.

I have that ready to push unless you would put it differently.

@amnweb

amnweb commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Exactly. WNF_SHEL_NOTIFICATION_TOTAL shows what you can actually see in the notification center. One app can show a maximum of 21 notifications: the visible banner plus 20 in the center. In that case WNF_SHEL_NOTIFICATION_TOTAL shows 21. With two apps you can reach 42.

image image

So WNF_SHEL_NOTIFICATION_TOTAL is the number you can really see in the notification center, and nothing else.

image

And that is the mismatch you found. It is not great, because we cannot subscribe to the Windows API and drop WNF entirely. Windows simply does not allow that for an unpacked app like YASB.

For the count there are a few options. We could show a capped string like 9+, or we could keep WNF as the source of truth for notification events and read the count from the API whenever WNF publishes a new state or comparing by app id. There are several ways to do it, but I am not sure all of that is worth it. Showing something like 9+ may be the right answer here.

Separately, back to the UI. I ran into a number of problems testing this last night:

  • Some apps do not show icons. (Firefox exmaple)
  • The content cannot be transparent, and it needs to be.
  • The layout is slow and needs to be sped up. I have the same problem with the GitHub widget: once you have more than 20 or 30 items, the popup renders slowly.

I'll try to play around with this UI today.

@amnweb

amnweb commented Aug 30, 2026

Copy link
Copy Markdown
Owner

And there is also bug with "Clear All," so WNF is actually correct here.

image image

Notifications from apps installed outside the Store had no icon in the menu. The shell
draws icons from the Apps folder, which only holds registered apps, so anything else came
back empty and the item was left blank. Windows itself falls back to the icon the app
registers under its AUMID, which is how the same toast still shows a picture in the
Notification Center.

- Read IconUri from Software\Classes\AppUserModelId, user hive first, when the Apps
  folder has nothing
- Trim the transparent margin before fitting it: what is registered here is often a tile
  asset with its own padding, which otherwise lands visibly smaller than the icons the
  shell hands out
- Keep the shape and centre it rather than stretching it to the caller's box
- Skip ms-resource:// icons, which need the package that owns them to resolve

Firefox writes exactly this registration, and it is the case reported here. On this machine
it takes the senders with an icon from 200 of 279 to 227.
Clearing left one notification per app behind. The listener hands out at most twenty
notifications per app while the Action Center keeps one more, so removing the ones it gave
us cleared the menu but not the Action Center: the leftover kept its place there and stayed
in the count on the bar, which then disagreed with a menu that said there was nothing left.

- Clear the notification history of each sender after removing what the listener gave us,
  which takes the notification it could not hand out
- Clear per app rather than in one call, since ClearNotifications() fails with
  ERROR_NOT_FOUND for apps installed outside the Store

Sending 26 toasts from one app leaves the Action Center holding 21 and the listener
returning 20. Clear all now ends with both at zero instead of one still there.
A popup installs an event filter on the whole application while it is open, so every event
of every widget crosses into Python and is checked against the popup geometry, even though
only a mouse press can close it. Filling a popup with widgets generates thousands of those
events, and each one paid for an isinstance check and two attribute lookups before being
let go.

Test the event type first and return before doing anything else. Rebuilding a menu of 30
notifications makes about 4,000 filter calls and they cost 39% less; the build as a whole
goes from 63ms to 45ms.
…changed

Opening the menu drew the list the widget already held and asked the listener for a fresh
one at the same time, then rebuilt every item when the reply arrived. That reply is the
same list almost every time, so opening the menu built it twice to end up with the same
thing on screen, which is what made a long list feel slow to open.

Compare the incoming list with the one on screen and only rebuild when it differs.
…ffer

The note said the count "is not always the number of entries in the Action Center" without
saying why, which reads as if the number were unreliable. It is not: it is the number
Windows publishes for the shell, and it means different things on Windows 10 and 11.

Say which state each version reports, and name the two cases where the count really does
sit above the menu: a notification an app has past its limit, which the listener will not
hand out, and a badge, which is counted but is not something the list can show.
A scroll area fills its viewport with the palette background, and setWidget turns
the same fill back on for whatever is put inside it. Neither is reached by the
stylesheet rule on the QScrollArea, so the list sat on an opaque slab between the
popup and its own background and no amount of CSS could make it see-through.
The shell hands an app icon over COM in ten to a hundred milliseconds, and this
was asking for one sender after another while building the menu, on the thread
that had to draw it. A menu holding half a dozen apps stalled for a third of a
second on its first open, which is most of what made the popup feel slow.

The icons are now extracted by a pool and dropped into the item once they are
ready, and the list is asked for them as soon as it changes, so by the time
anybody clicks they are usually already there. A slot that is still waiting holds
its space with a transparent stand-in, so nothing shifts when the picture lands.
The number on the bar and the list in the menu come from two different places and
stop agreeing once an app has sent more notifications than Windows keeps for it,
so a bar reading 22 can sit above a menu holding 21. max_count writes anything
past it as 9+, which keeps the bar from advertising a number the menu cannot
account for and from widening as the count grows. Off by default, and the tooltip
and the menu header still carry the real number.
@esershnr

Copy link
Copy Markdown
Author

And there is also bug with "Clear All," so WNF is actually correct here.

image image

Thanks, that last comment was exactly what I needed, the 20-per-app limit explains the +1 completely.

Before the four UI points: five commits landed after you tested, so a couple of these were already fixed on my side and you were looking at the older head. Sorry about the timing. Here is where each of your points stands.

Some apps do not show icons (Firefox)

Fixed. Firefox is not in the Apps folder, so IShellItemImageFactory has nothing to draw and returns nothing. Windows itself falls back to the AUMID registration, which Firefox does write:

HKLM\Software\Classes\AppUserModelId\FirefoxToast-308046B0AF4A39CB
    DisplayName  Mozilla Firefox
    IconUri      C:\Program Files\Mozilla Firefox\browser\VisualElements\VisualElements_70.png

get_icon_for_aumid now tries that when the shell has nothing. It is not Firefox-specific, it covers anything installed outside the Store that registers an AUMID. What is registered there is often a tile asset with its own margin baked in, so the drawing is trimmed out of it first, otherwise it lands noticeably smaller than the icons the shell hands out.

The content cannot be transparent

Fixed, and you were right that it was impossible rather than just unstyled. A QScrollArea fills its viewport with the palette background, and setWidget() turns the same fill back on for whatever is put inside it. Neither is reachable from the QScrollArea { background: transparent } rule, so the list sat on an opaque slab between the popup and its own background and no stylesheet could get past it.

Now the viewport and the content widget both have autoFillBackground(False), plus a QScrollArea > QWidget > QWidget rule so a global QWidget rule cannot repaint it either. .notification-menu .contents is transparent by default and can still be given a background if someone wants one.

Worth knowing for the other popups: any widget that puts a QScrollArea in a PopupWidget has the same thing, since it is the default behaviour of the class rather than anything specific to this menu.

The layout is slow with 20-30 items

Fixed, but the layout was not the problem. Building 60 fully styled items offscreen costs about 32 ms, so that was never where the time went. It was the icons: get_icon_for_aumid goes through COM and costs 10-100 ms per distinct sender (Edge 100 ms, Store 50 ms, WhatsApp 42 ms cold on my machine), and it was being called for one sender after another while building the menu, on the thread that then had to draw it.

They are now extracted by a QThreadPool and dropped into the item when ready, and the list asks for them as soon as it changes rather than when the menu opens, so by the time anyone clicks they are usually already cached. A slot that is still waiting holds its space with a transparent pixmap of the same size, so nothing shifts when the picture lands.

Measured on 30 notifications from 6 distinct senders, cold cache:

before   click -> menu shown   190.9 ms
after    click -> menu shown    28.0 ms

Two smaller ones went in alongside it: the popup's application-wide event filter now leaves early for anything that is not a mouse press, since it was doing the full inside-the-popup geometry check for every event of every widget while a popup is open, and the menu no longer rebuilds itself when the listener replies with a list identical to the one it is already showing.

This is worth stealing for the GitHub widget if it turns out to be the same cause there, the per-item layout cost looks fine in both, so I would check what that popup does synchronously while building before touching the layout.

The count

I took your suggestion, as an opt-in option rather than a behaviour change: max_count writes anything past it as 9+, and is 0 (off) by default so nobody's bar changes. The tooltip and the menu header keep the real number.

I did not try to make the number itself agree with the menu. From what you described the count is the correct side and the listener is the capped one, and there is no API that hands out the notification the listener is holding back, so 9+ really does look like the right answer here.

The docs note is updated with the version-dependent explanation from earlier, plus the per-app cap.

Clear All

Also fixed after you tested. ClearNotifications() throws ERROR_NOT_FOUND outside the Store, and removing the notifications the listener hands out leaves the one per app it never gave us, which is what you saw left behind. It now does both passes, removes what the listener reports, then clears the history per sender with ToastNotificationManager.history.clear_with_id and reads the list back afterwards instead of assuming the Action Center is empty.

Images

Still keeping those out of this PR, but they are working on a branch of mine and I will open it separately once this lands. The short version, in case it saves you time later: the listener genuinely cannot reach them, but the payload Windows stored can. The catch is that the file the payload points at belongs to the sender and is usually deleted within seconds of the banner, a browser writes the icon a website asked for into a temp file and clears it almost immediately, while the notification itself stays in the Notification Center until it is dismissed. So the picture has to be copied aside the first time it is seen or it is gone by the next refresh, which is probably the restriction you ran into.

@esershnr

Copy link
Copy Markdown
Author
image

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