Skip to content

fix(notifications): show Windows system notifications and keep their permission - #1109

Merged
pascalandr merged 3 commits into
devfrom
fix/system-notifications-focus
Oct 23, 2026
Merged

pascalandr merged 3 commits into
devfrom
fix/system-notifications-focus

Conversation

@pascalandr

@pascalandr pascalandr commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

System notifications on Windows: everything about them in one PR.

  1. Windows data profiles never showed system notifications (fix(windows): show system notifications from every data profile). Tauri runs each data profile, such as dev, under its own AppUserModelID (…client.scope.s<hash>), and its notifications carry that ID; Windows shows a desktop app's notifications only under an ID it knows, and the installer's Start menu shortcut carries the stable ID alone. Windows recorded every notification (100 on the affected machine) and displayed none. Every launch now registers the running ID under HKCU\Software\Classes\AppUserModelId\<identifier> (DisplayName CodeNomad, IconUri the bundled web app icon resources\server\public\pwa-192x192.png, which the packaged-resource smoke check requires; windows_notification_identity.rs). Electron always uses the stable ID.
  2. The permission was lost at every restart on Windows. The notification plugin's page script reports denied there until the page requests permission, though Windows never asks, so Settings asked again after each restart. The check now asks the native plugin command, which answers granted on desktop: granted once, kept.
  3. Notifications were held back whenever the window was visible (fix(notifications): hold system notifications back only while focused). With Notify when the app is focused off (the default), an open window stays visible behind other applications or on another screen, so nothing was sent unless CodeNomad was minimized. Delivery is now skipped only while the window is visible and focused (document.hasFocus()). The setting's subtitle and the Settings help say focused in all ten languages; dev-docs/SETTINGS.md records the rule.

Out of scope by decision: several CodeNomad windows open on the same project may each send the event's notification (as they already do when the setting is on), and focus inside a browser preview counts as leaving the page.

Validation

  • Affected machine: no profile notification showed before; after registering its ID as above, a notification sent under it showed in the notification centre under CodeNomad.
  • cargo test --bins -- windows_notification_identity (writes the key, reads back both values).
  • tests/browser/system-notification-focus.test.ts: real routed notification from a visible page; nothing while focused, one once focus is lost, one while focused with the setting on; red on the previous visibility check.
  • npm run typecheck --workspace @codenomad/ui, npm run test:help.

@pascalandr

Copy link
Copy Markdown
Contributor Author

Findings

  1. [P2] Coordinate system delivery across windows — packages/ui/src/lib/notifications.tsx:371–373

    Two visible CodeNomad windows observing the same root session now each send an OS notification when another application has focus. Every renderer receives the event (server/routes/events.ts:120–128, lib/sse-manager.ts:79–82) and independently calls notifySessionAttention (stores/session-events.ts:329–335,651–662); delivery has no cross-window claim or notification replacement identity. Replaying the same permission.asked event through the real dispatcher in two visible, unfocused pages recorded one Notification constructor call in each page, versus zero with the base visibility guard. A focused first window also leaves the second window sending. This newly exposes duplicate delivery in the ordinary visible-window case; hidden-window duplication already existed. Fix: claim system delivery once per native event and receiving desktop/browser client, preserving per-window screen/history routing, and add a two-window regression.

  2. [P2] Use containing-window focus for Tauri native previews — packages/ui/src/lib/notifications.tsx:371–372

    Windows Tauri creates its preview as a separate child webview (packages/tauri-app/src-tauri/src/browser_controller.rs:141–170), so focus inside it does not count as focus inside the application document. An isolated two-control WebView2 test on runtime 155.0.4283.62 returned application-document {focus:false, visibility:"visible"} while the preview had focus and the containing native window still contained keyboard focus. The new condition therefore sends notifications while the user is actively using CodeNomad's preview, even with Notify when the app is focused off. Fix: use the containing native window's focus state for desktop notification suppression, including its preview children; retain document focus for web hosts and add a native-preview regression without stubbing hasFocus.

Validation

  • UI typecheck passed.
  • Requested browser tests passed: system-notification-focus, compaction-notification, notification-icon-badge — 8 tests total.
  • The exact new focus test, with only the old guard substituted in memory, failed at line 44 waiting for the visible/unfocused notification, confirming it detects the original bug. Checkout files were unchanged.
  • Real sandboxed browser iframe and isolated Electron <webview> checks both retained parent document.hasFocus() === true when their embedded content was focused; the second finding is specific to the separate Windows Tauri preview webview.
  • All ten locale subtitles/help labels were reviewed; no additional translation or current-state documentation findings. Help validation passed all 9 checks (25 pages × 10 locales).
  • WKWebView/WebKitGTK and remote-window paths were reviewed statically; macOS/Linux native hosts were not executed.

@pascalandr

Copy link
Copy Markdown
Contributor Author

Findings

  1. [P2] Claim the event identity, not its rendered text — packages/ui/src/lib/system-notification-delivery.ts:78

    Distinct events with identical text suppress each other for 10 seconds, including in a single window. notifySessionAttention() supplies the project basename and session title, not the permission/request identity (stores/session-events.ts:329–335,651–662), so two different permission requests in the same session get the same claim key. I dispatched permission.asked twice through the real event dispatcher, with different event and request IDs, in one page with focused notifications allowed: HEAD constructed one Notification; substituting dev's notification implementation in memory constructed two. This also conflates different sessions/projects whose displayed names match. Fix: pass a stable, source-scoped native event/request identity to the delivery claim; deduplicate replays/windows of that event, while allowing distinct events with identical text. Add a single-window regression with two distinct request IDs and the same title/body.

  2. [P2] Share desktop delivery claims across remote-window storage partitions — packages/ui/src/lib/system-notification-delivery.ts:55–66

    Web Locks and localStorage only coordinate one origin in one browser storage partition. Native CodeNomad windows do not all share that boundary: Electron's local windows use the default session, while remote windows explicitly use session.fromPartition(...) (electron/main/main.ts:334–342,569–572); Tauri uses local for local windows but a profile-specific remote/<hash> data directory for remote windows (src-tauri/src/local_windows.rs:356, src-tauri/src/main.rs:1023–1040). A local window and a remote connection to the same server/session therefore each win their own claim and both notify when unfocused; different remote connection profiles have the same problem, even when their origin matches. Replaying the same native permission event through the real dispatcher produced one notification per receiver both across different origins and across same-origin isolated browser contexts. The new test uses one shared browser context, so it misses these actual native storage boundaries. Fix: coordinate desktop claims at the owning native process, keyed by the source/event identity, while retaining origin-scoped coordination for browser-only clients; add a local-plus-remote/isolated-partition regression.

Validation

  • Browser tests passed: system-notification-focus, compaction-notification, notification-icon-badge — 8 tests. Running the exact new two-window test with only dev's notification implementation substituted in memory failed its Without focus assertion ([] instead of one notification), confirming the original-regression check.
  • Electron npm run test:native: 284 passed, 4 skipped, zero failures. Tauri focused capability test and notification-filtered tests: 8 passed. UI typecheck passed. Help validation: all 9 tests passed, covering 25 pages × 10 locales.
  • Windows native reproduction used the actual vendored Tao 0.34.6 plus the checkout's locked Wry 0.54.4 (the lockfile does not select 0.57). With the native window foreground, is_focused() was true with the main webview focused and stayed true with the preview child focused; the main document then reported false and the preview true. After a separate process became foreground, the containing window reported false. The pinned Tauri runtime delegates its focus getter to Tao. This supports the Tauri focus fix, including the existing fullscreen-shortcut use.
  • On macOS, Tao reads NSWindow.isKeyWindow(); WKWebView descendants retain that containing window. On Linux, it reads GtkWindow.is_active(), likewise independent of the focused WebKitGTK descendant. These platforms were reviewed statically, not executed. An isolated Electron WebContentsView test retained the containing getFocusedWindow() while the preview had focus; its external-application focus-loss probe was inconclusive, so I am not claiming native verification of that transition.
  • Reviewed release-before-grant: the release resolver exists before requesting the shared focus lock, so an early blur resolves the promise even before grant. Reviewed visibility restoration/destruction, absent Web Locks, insecure-origin fallback and unavailable storage; bfcache restoration was not executed. The documented no-lock/no-storage behavior delivers independently per window. Same-origin local Tauri windows share local, and local Electron windows share their session; the remote boundaries are finding 2.
  • The new focus IPC/command is read-only. Electron retains main-frame/origin validation; Tauri grants it to the intended local, remote and Preferences webviews without granting preview guests additional authority. No additional security finding.
  • All ten locale subtitles and help entries retain their locale's existing UI label and accurately describe app-focus suppression. No current-state/history documentation violation found. Build-generated line-ending-only changes were restored; the worktree is clean.
  • File-length note: touched packages/tauri-app/src-tauri/src/main.rs is about 2,992 lines, notification_badge.rs about 517, and the ten packages/ui/src/lib/i18n/messages/<locale>/settings.ts files about 732–746 each.

System notifications never appeared on Windows while CodeNomad was open.
The setting reads "Notify when the app is focused", but delivery was
skipped whenever the window was visible: an open window stays visible
behind other applications, on another screen or partly covered, so
notifications only arrived once it was minimized or fully hidden. The
Windows side was fine: the Start menu shortcut carries CodeNomad's
AppUserModelID and a toast sent under it shows.

Delivery is now skipped only while the window is visible and has focus
(document.hasFocus()), unless the setting is on. The setting's subtitle
says "focused" in all ten languages, the Settings help explains that
notifications appear whenever the window is not focused, even while it
stays open on screen, and dev-docs/SETTINGS.md records the rule.

A new browser test sends a real routed notification through the
Notification API from a visible page: nothing while it has focus, a
notification once it loses focus, and one while focused with the
setting on. It fails on the previous visibility check and passes three
times in a row with the fix; the UI typecheck passes.
On Windows, the Tauri app's system notifications never appeared when it
ran a data profile such as dev. Tauri runs each profile under its own
AppUserModelID (ai.neuralnomads.codenomad.client.scope.s<hash>) and its
notifications carry that ID, but Windows shows a desktop app's
notifications only under an ID it knows, and the installer's Start menu
shortcut carries the stable ID alone. Windows recorded every
notification (100 in the notification database of an affected machine)
and displayed none.

Every launch now registers the running ID under
HKCU\Software\Classes\AppUserModelId\<identifier> with DisplayName
CodeNomad and IconUri the bundled resources\ui-loading\pwa-192x192.png,
the per-user registration Windows documents for unpackaged apps
(windows_notification_identity.rs, called where the process sets its
AppUserModelID). Electron always uses the stable ID, so only Tauri needs
it.

Settings also asked for notification permission again after every
restart on Windows: the notification plugin's page script reports
"denied" there until the page requests permission, though Windows never
asks. The permission check now asks the native plugin command, which
answers granted on desktop.

Validated on the affected machine: none of the profile's notifications
showed before; after registering its ID by hand as above, a notification
sent under it showed in the notification centre under CodeNomad. A Windows unit
test writes the key and reads back both values; the UI typecheck passes.
dev-docs/DESKTOP_DATA_PROFILES.md describes the registration.
@pascalandr
pascalandr force-pushed the fix/system-notifications-focus branch from 543c7cb to 0fef55f Compare October 23, 2026 01:50
@pascalandr pascalandr changed the title fix(notifications): hold system notifications back only while focused fix(notifications): show Windows system notifications and keep their permission Oct 23, 2026
@pascalandr

Copy link
Copy Markdown
Contributor Author

Findings

  1. P2 — Bundle the image that toast_icon() looks up — packages/tauri-app/src-tauri/src/windows_notification_identity.rs:29–34.

    resources\ui-loading\pwa-192x192.png is never staged by the current packaging path. copyUiLoadingAssets() in packages/tauri-app/scripts/prebuild.js:142–159 copies only loading.html and assets/; scripts/dev-prep.js:45–58 does the same. The PWA PNG lives at the UI build's root, outside assets/. I executed both actual staging functions against a temporary build containing that PNG: both reported sourceIcon: true, stagedIcon: false, with only assets and loading.html in the destination. Consequently, toast_icon() returns None and a fresh scoped AUMID never receives the intended IconUri. This is a missing app-logo bug, not evidence that name-only registration prevents toast delivery. The registry unit test supplies a fictional icon path directly to write(), so it does not detect this packaging gap.

    Fix: explicitly copy this PNG into ui-loading in both preparation paths, or resolve an image already guaranteed to ship. Add a staging/resource assertion for the selected image and keep the missing-icon fallback for genuinely incomplete/portable layouts. Update the icon-path documentation if the selected location changes.

Validation

  • UI typecheck: passed.
  • system-notification-focus, compaction-notification, notification-icon-badge: all 8 browser tests passed.
  • npm run test:help: all 9 checks passed, covering 25 pages × 10 locales.
  • cargo test --bins -- windows_notification_identity: passed on Windows (1 test). The first attempt encountered a stale absolute vendor path in the shared warm build; refreshing its build-script metadata resolved it. Generated line-ending changes were restored; the worktree is clean.

No additional findings in the registry API use (windows-sys 0.59, NUL-terminated UTF-16 and byte lengths, key access/closure and logged errors), startup wiring, preservation of other default-ID values, permission capabilities for local/remote/Preferences windows, native desktop permission/request consistency, focus routing, or the ten-locale wording. The registry display-identity approach is consistent with Microsoft's Toolkit implementation and the minimal winrt-toast registration implementation; a COM CustomActivator concerns activation handling and is not needed for this display-only fix. The installed notification plugin's existing target/debug/target/release development fallback remains unchanged.

Size note per repository rules: packages/tauri-app/src-tauri/src/main.rs is approximately 3,000 lines; packages/ui/src/lib/i18n/messages/{de,en,es,fr,he,ja,ne,ru,tr,zh-Hans}/settings.ts are approximately 745 lines each.

The notification identity pointed IconUri at
resources\ui-loading\pwa-192x192.png, but the Tauri staging scripts copy
only loading.html and assets/ into ui-loading: the file existed on the
affected machine only as a leftover of an older install, carrying the
previous logo, so a fresh install would register no icon.

IconUri now names resources\server\public\pwa-192x192.png, the web app
icon the bundled server's UI build generates from the current logo and
its manifest references. The packaged-resource smoke check requires that
file for every desktop build, so a missing icon fails packaging instead
of silently dropping the notification icon.

The smoke check passes on the installed Windows layout, and the Windows
registry test still writes and reads back both values.
@pascalandr

Copy link
Copy Markdown
Contributor Author

Findings

zero findings on a36df48c0. The previous P2 is addressed.

Evidence

  • Generation and staging: packages/ui/vite.config.ts:50–68 generates PWA assets from the current app logo with minimal-2023; the installed generator preset includes size 192 and its filename resolver emits pwa-192x192.png. The server build runs the UI build and prepare-ui (packages/server/package.json:21–27); scripts/copy-ui-dist.mjs:17–19 copies the entire UI output into server/public. stagePackagedServer() copies public without filtering (scripts/desktop-server-resources.cjs:108–110), and copyPackagedServerResources() copies it recursively (:124–138). Both desktop hosts use those helpers. Electron's extraResources includes the server tree outside ASAR (packages/electron-app/package.json:76–84).
  • Windows resolution: windows_notification_identity.rs:30–35 resolves from the executable's parent, independent of the working directory. tauri.conf.json:58–62 includes resources/server. I checked the installed Tauri CLI 2.11.5's NSIS and MSI resource handling/templates: both retain resource-relative paths under the installation directory containing the executable. Current CI ships NSIS; selecting MSI retains the same icon location. The installed Windows layout contains codenomad-tauri.exe beside resources/server/public/pwa-192x192.png; its manifest references that file, which I checked is a valid 192×192 PNG. Missing resources in incomplete/dev layouts still take the existing optional-icon fallback.
  • Shared smoke/CI: the PNG check runs before the cross-target execution skip (scripts/smoke-packaged-resources.cjs:66–88). Electron's build loop invokes that script for each target (packages/electron-app/scripts/build.js:136–156); Tauri prebuild invokes it after staging (packages/tauri-app/scripts/prebuild.js:161–190). The shared PR/release workflow runs those build/prebuild paths for Windows, both macOS architectures and Linux, and additionally checks extracted Electron deb/Tauri AppImage resources. No platform-specific staging step drops server/public, so this requirement introduces no missing-file regression for Electron/macOS/Linux. dev-docs/DESKTOP_DATA_PROFILES.md:32 matches the new runtime path.

Validation

  • Full resource smoke against C:/Users/Admin/AppData/Local/CodeNomad/resources: passed, including bundled Node, server version and dependency imports.
  • Windows windows_notification_identity test: 1 passed.
  • node --test scripts/desktop-server-resources.test.cjs: 6 passed.
  • Temporary checks using the actual copy helper and smoke static checks: icon retained and accepted, then deliberately missing icon rejected, for win32-x64, win32-arm64, darwin-x64, darwin-arm64, linux-x64, linux-arm64. These were staging/static checks, not native cross-platform executable tests.
  • Smoke script syntax check passed. Build-generated line-ending changes were restored; worktree clean. Native MSI/macOS/Linux installers were reviewed through their build/configuration paths, not rebuilt locally.

@pascalandr
pascalandr merged commit c82dd8f into dev Oct 23, 2026
17 checks passed
@pascalandr
pascalandr deleted the fix/system-notifications-focus branch October 23, 2026 02:03
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.

1 participant