Skip to content

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

Closed
esershnr wants to merge 31 commits into
amnweb:mainfrom
esershnr:feat/notifications-menu
Closed

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

Conversation

@esershnr

@esershnr esershnr commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Until now the notifications widget could only show a count. Clicking it sent Win+N and left the rest to the Windows Notification Center, so the notifications themselves were never visible inside YASB. This shows them in a YASB popup instead, like the other popup widgets, with the images they carry, and keeps the Notification Center one click away.

  • A popup menu listing the notifications Windows holds, grouped by sender, with app icons, relative timestamps and a dismiss button on each one
  • The images a toast brings with it: the app logo override (round when the toast asks for it), the hero image and inline images, placed the way the Notification Center places them
  • Clicking a notification does what clicking it in the Notification Center does, so a screenshot notification opens the screenshot and a website notification opens the page
  • A Do Not Disturb toggle using the shared DndService, and a footer link to the Notification Center
  • Everything is configurable under a new menu block and styleable through .notification-menu

How it works

The list comes from UserNotificationListener, on the same QThread the widget already started for the count, so there is no second listener or event loop. Each toast is turned into a small NotificationItem dataclass before it goes to the GUI thread, since WinRT objects belong to the thread that made them. The widget and the listener talk over EventService: WindowsNotificationsChanged goes to the widgets, WindowsNotificationRefresh and WindowsNotificationRemove go to the listener. The widget never edits the list itself, it asks the listener and redraws from what comes back.

The listener only exposes text, so the images and the launch string are read from the toast payload Windows keeps in wpndatabase.db. Image sources are resolved to local files only (absolute paths, file://, ms-appdata://, ms-appx:// against the sending package). YASB never downloads anything: a remote picture is shown only if Windows already downloaded it for the toast, and a path on a network share is refused before it is touched. The file a toast points at usually belongs to the sender and gets deleted seconds after the banner, so a copy is kept for as long as the notification is in the list.

Clicking hands the toast's launch string to the sender: a URI for protocol activation, otherwise the toast activator the app registered (INotificationActivationCallback). If the app registered neither, it is just brought to the front and keeps its notification.

App icons come from IconService. For apps that aren't in the Apps folder, like Firefox, I added a fallback to the icon they register under Software\Classes\AppUserModelId, which is what the Notification Center uses too. It lives in icons/sources.py, so every AppIcon gets it, not only this widget.

Behaviour change

callbacks.on_left now defaults to toggle_menu instead of toggle_label. toggle_notification still opens the Windows Notification Center as before, and configs that set on_left themselves are not affected.

The count on the bar now comes from the same list the menu shows. The WNF number drifts (activating a notification from the Notification Center leaves it one too high), so it is only used when notification access is off and there is no list to count.

Limitations

  • wpndatabase.db is undocumented. Everything read from it is optional: if Windows changes it, the menu loses the images and the website headers, and a click opens the app instead of the notification, but the list and the count keep working.
  • Toast input fields (quick replies and so on) are not shown, a click activates the notification without input.
  • A sender Windows can't name at all ends up under an Other header.

Testing

Running it daily on Windows 11 25H2 with WhatsApp (contact photos), Edge and Firefox website notifications, Snipping Tool screenshots, Store apps and PowerShell toasts. Checked dismiss, clear all, clicking through, the DND toggle and live updates while the menu is open.

ruff check, ruff format, pyright and pytest pass. The SDK comparison tests don't run on my machine (they can't read cl.exe output on a Turkish system, same on plain main), so the check for the new INotificationActivationCallback binding will first run here in CI. I added its header to tests/win32/probe.py. README.md and schema.json are untouched.

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

@esershnr
esershnr force-pushed the feat/notifications-menu branch from 497cb6b to 68dfecc Compare August 25, 2026 15:24
@esershnr

Copy link
Copy Markdown
Contributor 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
Contributor 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

@esershnr

Copy link
Copy Markdown
Contributor 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
Contributor Author
image

@amnweb

amnweb commented Sep 21, 2026

Copy link
Copy Markdown
Owner

@esershnr sorry for the delay, things have been really busy on my end. I plan to dive into this next week so we can review the approach and figure out the best way forward for this feature...

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
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.
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.
@esershnr
esershnr force-pushed the feat/notifications-menu branch from bbbc73a to ddc15e6 Compare September 21, 2026 22:34
The menu shows a notification's text and the icon of the app that sent it, but not the
images the toast itself brought, so a message with a photo in it looks like one without.
The listener cannot hand them over: NotificationBinding exposes text elements and nothing
else. Windows does keep the raw toast payload in a database of its own, and the images it
draws from it are local files, so they can be read from there instead.

- Read the payload from wpndatabase.db and resolve its <image> elements to file paths, one
  read per refresh rather than one per notification
- Draw them the way the Notification Center does: an app logo override in the app icon's
  place, cropped to a circle when the toast asks for it, the hero image above the item and
  the inline images below the text
- Resolve absolute paths, file://, ms-appdata:// and ms-appx:// sources, the last two
  against the sending package, and check that a package relative path stays inside it
- Never fetch a remote image: Windows only downloads those for apps installed from the
  Store, and asking for one would put YASB on the network on behalf of the sender
- Match a database row to a notification by id, and drop it when the sender disagrees,
  since the id is the only thing linking the two
- Add menu.show_images and menu.image_max_height, on by default, and document both
- Cache the decoded pixmaps with a bound: image paths churn where the app icons keyed by
  AUMID do not
Messaging apps do not ship the sender's picture with the toast, they point at it:
WhatsApp sends its contact photos as an https address on its own CDN, and so the menu
fell back to the WhatsApp icon for every message while the Notification Center showed
the person who wrote. Windows fetches those pictures itself to draw the toast, and notes
in the registry where it put each one, so the file is already on disk under a name
derived from the address.

- Look a remote source up in that record and use the copy Windows already downloaded
- Keep YASB off the network: a picture Windows did not keep is simply not shown, since
  requesting one would tell the sender when the menu was opened
- Re-read the record only after Windows has touched it, because it grows to thousands of
  entries on a machine that has been running for a while and has to be enumerated
- A record can outlive the file it points at, so the path is still checked before use
The icon beside a notification was fixed at 20px, which reads small next to the three
lines of text an item can carry, and a stylesheet cannot correct it: Qt draws a label's
pixmap at whatever size it was built at, so width and height only grow the box around it.

- Add menu.app_icon_size, between 8 and 64, following the option the other widgets with
  icons already expose
- Default to 36, which sits better against the text and closer to what the Notification
  Center itself shows
- Apply it to the picture a toast supplies in the icon's place as well, so an override
  does not change the size of the row
…hannel

Every image was converted to RGBA and only then scaled down, so the expensive part of the
work ran at four bytes per pixel over the full size of the original. A photo from a
messaging app took 49ms to prepare that way and 11ms the other way round, which is what a
menu full of pictures was spending before it could be shown.

- Scale first and convert afterwards, in both the app logo and the hero and inline images
- Convert up front only for palette and bilevel images, which cannot be resampled
- Keep the whole of it inside the open file: an image that already fits its box is not
  resized at all, so its pixels are still waiting to be read

A menu of 30 notifications, each from a different app and carrying its own picture, opens
in 812ms instead of 1.9s.
…r own way

Two toasts that carry a picture were losing it before it reached the menu.

A file: url is written differently by different senders. The screenshot tool writes the
separators as backslashes and percent encodes them along with the colon after the drive
letter, and the standard conversion does not recognise that as a drive at all: it produced a
path with a leading separator that no file matches, so the screenshot never appeared.

The sender check was also too strict. A browser files a website's notifications under an
identifier of its own for that site, while the listener reports them as coming from the
browser, so the two disagree and every website notification was dropped as if the row
belonged to someone else. They still agree on the package, which is what is now compared.

Verified by sending the same image both ways: previously only the plain form arrived, now
both do.
…rops its copy

A website notification lost its icon after a while. The picture in the payload is a
temporary file the browser writes to have the toast drawn and deletes straight afterwards,
so the icon was there when the notification arrived and gone by the time the menu was opened
again, leaving the browser's own icon in its place.

Windows keeps its own copy for exactly this reason: a browser registers an icon per website
alongside the notification handler, which is what the Notification Center goes on showing.
Read that when the payload has nothing left to point at. It resolves through the package
relative path we already handle, so this is one more column on the query rather than a new
way of finding files.
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 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.
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.
A sender deleting the picture it drew a toast with does not always do it in one
step, and a file caught halfway is there but empty. That passed for a usable
image, so the fallbacks that exist for a missing one were skipped and the item
was left with no picture at all rather than the app's own icon.
The file a payload points at belongs to the sender and rarely outlives the toast
banner. A browser writes the icon a website asked for into a temporary file and
clears it within seconds, while the notification itself sits in the Notification
Center until somebody dismisses it. Reading the payload again then found nothing,
so the item quietly dropped to the sender's registered icon while Windows carried
on showing the picture the website chose.

The first read now takes a copy and every read after it is served from that copy.
The copies are dropped as soon as their notification is, so this holds no more
than the Notification Center does.
UserNotification.app_info raises for a sender whose AUMID is not registered, so
those notifications arrived with no name and no icon and were collected under a
generic Other header. Windows knows perfectly well who sent them: it files the
toast under a handler of its own, with the name it heads the group with in the
Notification Center, and the payload read for the images already carries both.

They are used only where the listener gave nothing. What it does report stays
untouched, because that is the sender the shell will activate on a click, while
the one in the database is how Windows files it, which for a browser is one entry
per website and activates nothing.
The number on the bar was the one Windows publishes for the shell, and it drifts.
Dismissing a notification takes it off that count, but activating one from the
Notification Center does not, so every notification the user clicked there left a
unit behind: four on the bar against three in the Notification Center and three
in the menu, and nothing to bring it back down short of emptying the list.

It is now counted from the list the menu itself is drawn from, so the two cannot
disagree. The published number is still read, for the one case where there is no
list to count: with notification access turned off the listener returns nothing
while Windows goes on counting.

The write to that state is still what says the list has changed, but a write is
now taken at its change stamp rather than at its value, so one that leaves the
number where it was no longer passes for nothing having happened.
…with

A browser sends every website's notifications under one AUMID of its own, so the
menu collected them all under the browser: a YouTube notification was headed
Microsoft Edge and carried the site's icon where the channel's picture belongs.
The Notification Center heads that same notification with www.youtube.com and the
site's own icon, because it groups by the sender the database files the toast
under, which for a browser is one entry per website, complete with the name and
icon it registered for it.

The menu now groups by that sender too, and heads the section with the name and
icon the database holds for it. An app registers neither, so an app is headed by
the name the listener reports, exactly as before; only websites gain a header
icon. The AUMID the listener reports is untouched, since that is still the one
the shell activates.

That frees the item beneath the header to show the picture the toast itself
carries, the channel's rather than the site's. The site's icon is still what an
item falls back to when the menu is not grouped and there is no header to carry
it.

The header icon is drawn into the header label rather than placed beside it in a
layout, because a stylesheet reaches a widget and not its children: split in two,
the font and colour written for .section-header would stop reaching the name. Its
size is menu.section_icon_size.
Clicking a notification in the menu only raised the app it came from, which for
anything that says something specific is not what the notification was for: a
screenshot notification opened the Snipping Tool rather than the screenshot, and
a website notification opened the browser rather than the page.

Windows hands the sender the launch string of that particular toast, and the
payload the menu already reads for its images carries it. A toast that asks for
protocol activation names a URI, which is opened like any other link. Everything
else goes to the sender's toast activator, the COM class an app registers for
this call, in the registry for a plain desktop app and in the manifest for a
packaged one. A sender that registered neither is still only raised, and keeps
its notification.

An activated notification is removed, the way Windows removes one, which also
keeps the count honest. The handover is done off the GUI thread: it can mean
starting an app that is not running, and the bar should not be waiting on that.
Raising a window still happens on the thread that owns them.

The package lookup toast images already had is now shared, since finding a
packaged sender's activator means finding where it is installed.
@esershnr

Copy link
Copy Markdown
Contributor Author

@esershnr sorry for the delay, things have been really busy on my end. I plan to dive into this next week so we can review the approach and figure out the best way forward for this feature...

No problem at all, there's no rush.

For what it's worth, I've been running this branch daily on Windows 11 25H2 for a while now, together with feat/notifications-images on top of it, and haven't hit any issues in normal use. As I mentioned before, I haven't opened a PR for that branch yet: the plan is to sort out whatever is left on the menu side first, and once this lands, open the images branch, which is where the widget really comes into its own. If you want to try it in the meantime, it's on my fork: https://github.com/esershnr/yasb/tree/feat/notifications-images

One note: I've just rebased this branch onto current main (2.0.8 dev) so you can review it against what's there now. No conflicts, and nothing in the PR itself changed.

A toast names its images itself, and a source written as a file: URL with a host,
or as a plain UNC path, was turned into a path on that host and resolved to see
whether it was there. Resolving a network path is already a request to the host
behind it, and one that offers it the user's credentials, while the documentation
promises that YASB never asks the network for an image.

Refuse a path on a network share before it is touched. What Windows downloads or
keeps for a toast is always on a local drive, so nothing it shows is lost: on this
machine all 48 downloaded images and every sender icon resolve as before.
…indings

The toast activation module declared ShellExecuteW, AllowSetForegroundWindow and
CoInitializeEx for itself, while the bindings package already carries the first
two and aumid.py already initialises COM the same way for the icon extraction.
Use those instead. CoCreateInstance stays local, the way dnd_api and the keyboard
service declare it.
…l activation

The launch string of a toast is written by its sender, and ShellExecute opens a
program or a file just as readily as a URI. The shell only ever launches a URI for
protocol activation, so do the same: a launch string needs a URI scheme of at least
two letters, which keeps a drive letter from passing for one, and a file: URI is
turned away as the path it is. A toast whose launch string is not a URI is treated
like one that cannot be handed over, so its app is only brought to the front.
… missing

Both lookups were cached whatever they returned, so an app installed while YASB was
running stayed without an activator or an install folder until the next restart, and
clicking its notifications only raised it. Remember what was found and nothing else.
A missing activator costs a registry read and a manifest on a click, and a missing
package is one a notification still names after it was uninstalled, so neither
happens often enough to be felt.
The copy of a toast image is all that is left of the picture once the sender has
cleaned up after itself, and it was deleted the first time its notification was
missing from a read. A read that came back short once would have taken it for good,
leaving the notification without its picture when the next read brought it back.

Delete a copy once its notification has been missing from two reads in a row. A
notification that has really gone is still missing the next time, which is soon,
since opening the menu is a read of its own.
Hero, inline and app logo images were decoded and scaled while the menu was being
built, on the thread that then had to draw it. A photo takes around ten
milliseconds, so a menu full of them paid for it on the click, the same cost that
had already been taken off the app icons.

Decode them in the icon pool as soon as the list arrives, with the cache keys the
menu looks them up by, so the menu finds them ready. One that is not ready yet is
still decoded on the spot as before, so nothing is drawn differently: 256 images
compared across four scale factors come out identical to the pixel, and building
the menu for twelve of them goes from 15 ms on the GUI thread to 0.1 ms.

Activating a notification shares the pool, and is now queued ahead of pictures
nobody is waiting on.
…r's icon

A website that sends a notification without a picture of its own was shown with a
blank document for an icon. The listener names the browser as the sender, and the
name Edge gives itself there is not one the Apps folder lists, so the shell hands
back its generic file icon. The Notification Center shows no icon on such a
notification at all, and heads its group with the site's own icon instead.

Never fall back to the browser for a website's notification. A website is told
apart by the notification database filing it as a sender of its own while the
listener names the browser, which is not the case for any app. Without a picture
of its own the item has no icon and the text takes the space, or, with the new
show_site_icons option, the site's icon. It is off by default, and the site's icon
heads its section either way.
Upstream moved app icons to IconService, Windows declarations into the
bindings and the codebase to Pyright strict, so the notifications menu
follows:

- Take app icons from IconService instead of a loader and cache of its own
- Move the registered IconUri fallback for senders the Apps folder does not
  list from the removed aumid_icons.py into icons/sources.py, where every
  AppIcon gets it
- Declare INotificationActivationCallback in the shell32 bindings and create
  it with create_com_object, and add its header to the SDK probe
- Keep the popup event filter's early return for events other than a click
- Type the notification code for Pyright strict
@esershnr
esershnr force-pushed the feat/notifications-menu branch from ddc15e6 to 0200f61 Compare October 22, 2026 12:37
@esershnr esershnr changed the title feat(notifications): show notifications in a YASB menu feat(notifications): show notifications and their images in a YASB menu Oct 22, 2026
@esershnr

Copy link
Copy Markdown
Contributor Author

Small change of plan. I said I'd open the images branch as a separate PR after this one, but since the review hasn't started yet, I've folded it in here so you only have to go through it once. You also mentioned images in your first comment, and honestly the menu makes a lot more sense with them. The description is updated to cover everything.

Main moved quite a bit in the meantime (IconService, the bindings move, strict Pyright), so I brought the branch up to date and moved it onto those:

  • app icons now come from IconService, the widget's own icon loader and cache are gone
  • the Firefox icon fallback I mentioned earlier is now in icons/sources.py, so other widgets using AppIcon get it too
  • the toast activation COM interface is declared in the shell32 bindings
  • pyright and pytest are clean

If you'd still rather review the menu and the images separately, let me know and I'll split them again.

@amnweb

amnweb commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

It's fine, the image can be in the same PR, but I want to check why these icons are messed up first. Also, image caching is no longer needed inside individual widgets. The IconService should handle that on its own and keep the images in memory.

Edit: I just checked the files, and it's an image cache, not an icon cache, so I think it's fine.

@esershnr

Copy link
Copy Markdown
Contributor Author

Awesome, thanks for the review! I've been running this for a long time on Windows 11 25H2 and haven't hit a single issue. I haven't done any extreme stress testing, but in day-to-day use it's been solid. I'd really love to see this merged so I can use the official upstream .exe with this feature. :D

@amnweb

amnweb commented Oct 22, 2026

Copy link
Copy Markdown
Owner

But I think this layout could be better, and you can get rid of most of these options. For example, width and max_height should be controlled through CSS. I think some of the other options could be moved there too. I'm testing a few different layouts now to see how everything works.

@esershnr

Copy link
Copy Markdown
Contributor Author

But I think this layout could be better, and you can get rid of most of these options. For example, width and max_height should be controlled through CSS. I think some of the other options could be moved there too. I'm testing a few different layouts now to see how everything works.

If that's how you feel, just tell me what you'd improve and I'll update it however you see fit

@amnweb

amnweb commented Oct 23, 2026

Copy link
Copy Markdown
Owner

@esershnr You don't need wpndatabase.db. ToastNotificationManager.history.get_history_with_id(aumid) returns the full toast XML for other apps too, including launch, activationType, images and actions. I tested it with Firefox website notifications, Snipping Tool, WinUI Gallery and PowerShell, and all of them came back complete.

There's no shared id, so you have to match the items to the listener by order (history is newest first, the listener oldest first) and check that the text is the same, but that's still far better than relying on an undocumented database. Can you switch to that and drop the sqlite part?

Keep the AppUserModelId icon fallback, it's needed for Firefox and the Windows system toasts. Please revert the eventFilter change, though. The geometry check already only ran on mouse press, so it doesn't fix anything.

In general there are a lot of changes here and it's hard to follow. To be fair, it all looks overcomplicated to me, and the layout has quite a few issues. I'd like to see this simplified before going further.

I’ll start on a simpler version based on this one, so we can compare the two. You can test it and tell me if anything is missing. I think we can do this better anyway, and I’ll keep you as co-author since a lot of this is based on your work.

@esershnr

Copy link
Copy Markdown
Contributor Author

@esershnr You don't need wpndatabase.db. ToastNotificationManager.history.get_history_with_id(aumid) returns the full toast XML for other apps too, including launch, activationType, images and actions. I tested it with Firefox website notifications, Snipping Tool, WinUI Gallery and PowerShell, and all of them came back complete.

There's no shared id, so you have to match the items to the listener by order (history is newest first, the listener oldest first) and check that the text is the same, but that's still far better than relying on an undocumented database. Can you switch to that and drop the sqlite part?

Keep the AppUserModelId icon fallback, it's needed for Firefox and the Windows system toasts. Please revert the eventFilter change, though. The geometry check already only ran on mouse press, so it doesn't fix anything.

In general there are a lot of changes here and it's hard to follow. To be fair, it all looks overcomplicated to me, and the layout has quite a few issues. I'd like to see this simplified before going further.

I’ll start on a simpler version based on this one, so we can compare the two. You can test it and tell me if anything is missing. I think we can do this better anyway, and I’ll keep you as co-author since a lot of this is based on your work.

@amnweb Thanks for taking the time to review this PR so thoroughly.

Of course, go ahead with your simpler version, and I'll be glad to test it and let you know if anything is missing. That said, if your time is limited, I'm happy to apply your points to this PR myself (switch to get_history_with_id, drop the sqlite part, keep the AppUserModelId fallback and revert the eventFilter change). I probably have more free time than you do at the moment, so just let me know whichever works better for you.

About wpndatabase.db: upstream had moved quite a bit since I last synced, and while I was rushing to adapt the branch to it, I missed that some parts had become unnecessary, like reading the database when the history API already gives the full toast XML. Thanks for pointing it out.

And thank you for keeping me as co-author, I really appreciate it. This is a repo I genuinely like, and having contributed to it means a lot to me. It will also help me while I'm looking for a job.

@amnweb

amnweb commented Oct 23, 2026

Copy link
Copy Markdown
Owner

It's fine, I just have a little more work to do to stabilize this layout, and everything has been moved to CSS. Tomorrow I'll finish up the API and draft a PR.

2026-10-23.03-33-35.mp4

@amnweb

amnweb commented Oct 23, 2026

Copy link
Copy Markdown
Owner

@esershnr Let's test how this works. I'm really disappointed with some of the Windows limitations we're dealing with here, like Firefox not handling clicks and some chat apps not opening the actual chat. I've looked into it but couldn't find anything that could help us.

@esershnr

Copy link
Copy Markdown
Contributor Author

@esershnr Let's test how this works.

Thanks, I've installed it and I'll be testing it for a while. It looks good so far.

1

@amnweb

amnweb commented Oct 25, 2026

Copy link
Copy Markdown
Owner

@esershnr The other PR has been merged, so we can close this one.

@amnweb amnweb closed this Oct 25, 2026
Video-Nomad pushed a commit to Video-Nomad/yasb that referenced this pull request Oct 25, 2026
The widget opens a menu with the notifications Windows holds, grouped
by app, instead of only showing a count. Based on amnweb#1070 by Eser Şahiner.

- The list comes from UserNotificationListener. Launch, activation type,
  pictures, attribution and displayTimestamp come from each app's toast
  history, matched to the list by text since the history has no ids.
- Pictures follow the toast schema: appLogoOverride (48 px, round with
  hint-crop="circle"), hero (364:180) and inline. The image of the
  Windows 8 ToastImageAndText templates is the logo. Only local files
  are read (file:///, ms-appx, ms-appdata local), nothing is downloaded.
- A click opens protocol links, hands other toasts to the app's toast
  activator (registry CustomActivator or the Start menu shortcut's
  ToastActivatorCLSID) out of process, or brings the app forward.
- Clear uses ToastNotificationHistory.Clear(appId) per app, then removes
  what is left with RemoveNotification. Clear(appId) is documented only
  for apps in the caller's own package but works across apps, and
  ClearNotifications() fails without package identity.
- {count} shows 9+ above nine: Windows counts 21 notifications per app
  but hands other apps 20, so an exact count can exceed the menu.
- App icons fall back to the IconUri an AUMID registers, for senders
  shell:AppsFolder doesn't list (Firefox, Windows system toasts).
- callbacks.on_left now defaults to toggle_menu.

Known limitation: while Firefox runs, it ignores an activator call for a
notification from its own session and only reacts to the click Windows
raises inside its process, so those clicks open nothing.

Co-authored-by: Eser Şahiner <7672024+esershnr@users.noreply.github.com>
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