Repository navigation
fix(app): show web notifications through the service worker on Android - #62750
dennyschwender wants to merge 1 commit into
Conversation
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 72 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
|
Thanks for the thorough write-up and the CDP evidence! I think this duplicates #58399, which is still open and fixes the same issue (#49961) the same way:
This PR has two pieces #58399 doesn't: asking for notification permission when notifications are turned on in settings, and making the plugin |
Fixes #49961
Root cause
The web platform showed every notification through the
Notificationconstructor. Chrome for Android has deliberately never supported that constructor (since Chrome 42); its only notification path isServiceWorkerRegistration.showNotification(). On Android the constructor throws and nothing has ever been displayed:The generated service worker (
strategies: "generateSW") also had nonotificationclickhandler, andNotification.requestPermission()was only ever called from insidenotify, i.e. never from a user gesture, so the permission prompt could not be answered in-app either.Evidence from a raw-CDP loop running the deployed code verbatim in Chromium with
--disable-blink-features=NotificationConstructor(real Blink Android behavior, zero monkeypatching):What changed
packages/app/src/runtime/platform/web.ts—notifyis now service-worker-first: show viaregistration.showNotification(title, { ...opts, tag, data: { key } }), falling back tonew Notification(...)where the worker cannot show one (no registration / non-secure context / first install window). The path never rejects: a notification failure can't propagate to the Web-Locks coordinator. Click semantics are preserved: the callback closure cannot cross into the worker, so a worker notification's key is posted back to the window clients (notificationclick→postMessage) and the window that created the notification runswindow.focus()+ the click callback, exactly like the oldnotification.onclick. If no window survives (frozen-and-killed PWA), the click focuses/opens the app at its restored route — the session callback itself is lost with the page; that is the one behavior that cannot cross the worker boundary. Extracted ascreateWebNotify(), mirroring the testedcreateDesktopNotifyseam inpackages/desktop.packages/app/notification-click.js(new) +packages/app/vite.pwa.ts— thenotificationclickhandler for the generated worker (generateSW can't host custom handlers, so it ships next tosw.jsvia workboximportScripts, emitted by a tiny plugin and precached with a revision so it always matches the worker).packages/app/src/runtime/plugin/host-apis.tsx— the pluginattention.notifywrapper settles on failure too (.then(resolve, skip)), so an unsettled notification promise can never holdopencode:notification-delivery/-focusforever (the old deadlock).packages/app/src/settings/notifications/notifications.tsx— enabling any notification channel now asks for the notification permission from that user gesture while it is stilldefault(web only). This makes the permission prompt reachable in-app; Chrome for Android cannot grant notifications from a background request.packages/app/src/runtime/platform/web.test.ts(new) — 8 unit tests at thecreateWebNotifyseam: Android shape shows through the worker and settles, constructor fallback keeps desktop click semantics (focus + callback + close), constructor-throw with no worker resolves silently, the foreground guard is preserved,showNotificationfailure settles, permission gating, and the worker click round-trip (callback runs once, foreign keys ignored).Desktop behavior is unchanged in outcome: a notification is still displayed with the same body/icon, click still focuses the window, runs the callback and closes it. The desktop platform file was not touched; on desktop the worker now displays instead of the constructor when one is active, and the constructor path remains where it doesn't.
Test plan
bun test src/runtime/platform/web.test.ts(packages/app): RED on the old logic (6/8 fail with the exact Android TypeError) → GREEN after (8/8).bun run test:unit(packages/app): 2591 pass / 0 fail.bun run typecheck(bun check) andbun run typecheck:tsc(tsc is CI's source of truth): clean.bun run build: clean;dist/sw.jscarriesimportScripts("notification-click.js")and the script is in the precache manifest.bun run test:service-worker(build + Playwright, real generated worker): 14 passed, incl. "the production build precaches every deployable file except on-demand Office assets".bun run lint(oxlint + ast-grep + sdk-docs): clean.dist/sw.js+ the fix verbatim) in two headless Chromiums — desktop shape and Android shape (--disable-blink-features=NotificationConstructor, real Blink throws). After this PR, both:Visual
<style> .banner { animation: slide 3.2s ease-in-out infinite } @Keyframes slide { 0%, 12% { transform: translateY(4px); opacity: 0 } 28%, 68% { transform: translateY(0); opacity: 1 } 84%, 100% { transform: translateY(4px); opacity: 0 } } @media (prefers-reduced-motion: reduce) { .banner { animation: none; opacity: 1; transform: none } } </style>Out of scope / follow-ups
/server/:server/session/:idwould need session identity inPlatform.notify's signature — happy to follow up if wanted.AGENTS.md→ Feature Flags).