From 000ef006661675256a4e7d5fcc455b3410483024 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 30 Oct 2026 03:18:38 +0800 Subject: [PATCH 1/4] fix: use native browser and desktop notifications --- .../credential-expiry/test/route.ts | 7 + app/page-shell.tsx | 7 +- app/settings/settings.tsx | 144 ++++++++++++------ lib/server/domain/config.ts | 6 +- messages/en-US.json | 14 +- messages/ja-JP.json | 14 +- messages/zh-CN.json | 14 +- 7 files changed, 130 insertions(+), 76 deletions(-) diff --git a/app/admin-api/notifications/credential-expiry/test/route.ts b/app/admin-api/notifications/credential-expiry/test/route.ts index eff13df..309015f 100644 --- a/app/admin-api/notifications/credential-expiry/test/route.ts +++ b/app/admin-api/notifications/credential-expiry/test/route.ts @@ -30,5 +30,12 @@ export const POST = async (request: Request): Promise => { title: 'CodeBuddy2API 凭据提醒测试', }); + if (delivered === 0) { + return Response.json( + { error: `No ${channel} notification client is connected` }, + { status: 503 }, + ); + } + return Response.json({ delivered, message: 'Test notification sent.' }); }; diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 3eb2586..50178f2 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -422,6 +422,11 @@ const AdminPageLayoutContent = ({ } ).desktop; const desktopApp = desktop || Boolean(desktopBridge?.notify); + + if (!desktopApp && !('Notification' in window)) { + return; + } + const source = new EventSource( `/admin-api/notifications/credential-expiry?channel=${desktopApp ? 'desktop' : 'web'}`, ); @@ -451,8 +456,6 @@ const AdminPageLayoutContent = ({ Notification.permission === 'granted' ) { new Notification(title, { body }); - } else { - showNotification('warning', body); } }; diff --git a/app/settings/settings.tsx b/app/settings/settings.tsx index 9cf1d83..6a42bf3 100644 --- a/app/settings/settings.tsx +++ b/app/settings/settings.tsx @@ -275,11 +275,13 @@ const SettingField = ({ const CredentialExpiryNotificationSettings = ({ desktop, + onSave, onChange, settings, translations, }: { desktop: boolean; + onSave: () => void; onChange: (key: string, value: string | boolean) => void; settings: SettingsState; translations: (key: string) => string; @@ -293,6 +295,11 @@ const CredentialExpiryNotificationSettings = ({ >(null); const [webTesting, setWebTesting] = useState(false); const [desktopTesting, setDesktopTesting] = useState(false); + const browserNotificationsSupported = useSyncExternalStore( + () => () => undefined, + () => 'Notification' in window, + () => false, + ); const token = String(settings.values.CODEBUDDY_BARK_TOKEN ?? ''); const enabled = settings.values.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED === @@ -333,8 +340,17 @@ const CredentialExpiryNotificationSettings = ({ setWebTestResult(null); try { - if ('Notification' in window && Notification.permission !== 'granted') { - await Notification.requestPermission(); + if (!browserNotificationsSupported) { + setWebTestResult('error'); + return; + } + + if (Notification.permission !== 'granted') { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + setWebTestResult('error'); + return; + } } const response = await fetch( @@ -419,7 +435,7 @@ const CredentialExpiryNotificationSettings = ({ {translations('settingsPanel.desktopNotificationsDescription')}

- ) : ( + ) : browserNotificationsSupported ? ( <> @@ -446,7 +462,7 @@ const CredentialExpiryNotificationSettings = ({ {translations('settingsPanel.webNotificationsDescription')}

- )} + ) : null}
- - - {testResult === 'success' ? ( - - {translations('settingsPanel.testBarkSuccess')} - - ) : null} - {testResult === 'error' ? ( - - {translations('settingsPanel.testBarkFailed')} - - ) : null} + {enabled ? ( + <> + + + {testResult === 'success' ? ( + + {translations('settingsPanel.testBarkSuccess')} + + ) : null} + {testResult === 'error' ? ( + + {translations('settingsPanel.testBarkFailed')} + + ) : null} + + + ) : null} + {desktop || !browserNotificationsSupported ? null : ( + + + {webTestResult === 'success' ? ( + + {translations('settingsPanel.testWebNotificationSuccess')} + + ) : null} + {webTestResult === 'error' ? ( + + {translations('settingsPanel.testWebNotificationFailed')} + + ) : null} + + )} + {desktop ? ( + + + {webTestResult === 'success' ? ( + + {translations('settingsPanel.testDesktopNotificationSuccess')} + + ) : null} + {webTestResult === 'error' ? ( + + {translations('settingsPanel.testDesktopNotificationFailed')} + + ) : null} + + ) : null} + - {webTestResult === 'success' ? ( - - {translations( - desktop - ? 'settingsPanel.testDesktopNotificationSuccess' - : 'settingsPanel.testWebNotificationSuccess', - )} - - ) : null} - {webTestResult === 'error' ? ( - - {translations( - desktop - ? 'settingsPanel.testDesktopNotificationFailed' - : 'settingsPanel.testWebNotificationFailed', - )} - - ) : null} ); @@ -1048,6 +1091,7 @@ const Settings = ({ Date: Fri, 30 Oct 2026 16:39:31 +0800 Subject: [PATCH 2/4] fix: support Safari PWA notifications --- app/page-shell.tsx | 3 +- app/settings/settings.tsx | 3 +- lib/client/web-notifications.ts | 52 +++++++++++++++++++++ messages/en-US.json | 2 +- messages/ja-JP.json | 2 +- messages/zh-CN.json | 2 +- tests/admin/web-notifications.test.ts | 66 +++++++++++++++++++++++++++ 7 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 lib/client/web-notifications.ts create mode 100644 tests/admin/web-notifications.test.ts diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 50178f2..6e56639 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -65,6 +65,7 @@ import { AdminHeader } from '@/app/header'; import { type LocalePreference } from '@/lib/i18n/routing'; import { saveLocalePreference } from '@/lib/client/preferences'; import { useThemeAppearance } from '@/lib/client/theme'; +import { showWebNotification } from '@/lib/client/web-notifications'; const tabs: Array<{ icon: typeof LayoutDashboard; @@ -455,7 +456,7 @@ const AdminPageLayoutContent = ({ 'Notification' in window && Notification.permission === 'granted' ) { - new Notification(title, { body }); + void showWebNotification(title, body); } }; diff --git a/app/settings/settings.tsx b/app/settings/settings.tsx index 6a42bf3..98b0205 100644 --- a/app/settings/settings.tsx +++ b/app/settings/settings.tsx @@ -39,6 +39,7 @@ import { import Security from './security'; import Desktop from './desktop'; +import { isWebNotificationSupported } from '@/lib/client/web-notifications'; export type SettingsValue = string | number | boolean | null; @@ -297,7 +298,7 @@ const CredentialExpiryNotificationSettings = ({ const [desktopTesting, setDesktopTesting] = useState(false); const browserNotificationsSupported = useSyncExternalStore( () => () => undefined, - () => 'Notification' in window, + isWebNotificationSupported, () => false, ); const token = String(settings.values.CODEBUDDY_BARK_TOKEN ?? ''); diff --git a/lib/client/web-notifications.ts b/lib/client/web-notifications.ts new file mode 100644 index 0000000..2c40f6d --- /dev/null +++ b/lib/client/web-notifications.ts @@ -0,0 +1,52 @@ +export const isWebNotificationSupported = (): boolean => { + if (typeof window === 'undefined' || !('Notification' in window)) { + return false; + } + + const registration = globalThis.ServiceWorkerRegistration; + + return Boolean( + ('serviceWorker' in navigator && + registration && + typeof registration.prototype.showNotification === 'function') || + typeof Notification === 'function', + ); +}; + +/** + * Displays a native browser or installed-PWA notification. + * + * Mobile Safari does not support constructing `Notification` objects from a + * page. Its Home Screen web apps use the Service Worker notification API + * instead, so prefer that path whenever a registration is available. + */ +export const showWebNotification = async ( + title: string, + body: string, +): Promise => { + if (!isWebNotificationSupported() || Notification.permission !== 'granted') { + return false; + } + + if ('serviceWorker' in navigator) { + try { + const registration = await navigator.serviceWorker.ready; + + if (typeof registration.showNotification === 'function') { + await registration.showNotification(title, { body }); + return true; + } + } catch { + // Fall through to the page notification API when the worker is not + // available, for example in a browser that supports Notification but + // blocks service workers. + } + } + + try { + new Notification(title, { body }); + return true; + } catch { + return false; + } +}; diff --git a/messages/en-US.json b/messages/en-US.json index 1b3c37e..a98335d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -464,7 +464,7 @@ "credentialExpiryNotificationsDescription": "Notify when a credential has 3 days left, 1 day left, or has expired. Bark, web browser, and desktop notifications are configured separately.", "credentialExpiryNotificationsEnabled": "Enable Bark credential expiry notifications", "webNotificationsEnabled": "Enable web credential expiry notifications", - "webNotificationsDescription": "Show a browser notification at the same thresholds. Allow notifications when prompted.", + "webNotificationsDescription": "Show a browser or installed PWA notification at the same thresholds. Allow notifications when prompted.", "desktopNotificationsEnabled": "Enable desktop credential expiry notifications", "desktopNotificationsDescription": "Show a native system notification in the desktop app at the same thresholds.", "barkToken": "Bark key", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 01cfc8c..51ab149 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -464,7 +464,7 @@ "credentialExpiryNotificationsDescription": "認証情報の有効期限が 3 日前、1 日前、または期限切れになったときに通知します。Bark、Web ブラウザ、デスクトップ通知は個別に設定できます。サービスの実行中に確認します。", "credentialExpiryNotificationsEnabled": "Bark の認証情報有効期限通知を有効にする", "webNotificationsEnabled": "Web の認証情報有効期限通知を有効にする", - "webNotificationsDescription": "同じタイミングでブラウザ通知を表示します。表示された権限を許可してください。", + "webNotificationsDescription": "同じタイミングでブラウザまたはインストール済み PWA の通知を表示します。表示された権限を許可してください。", "desktopNotificationsEnabled": "デスクトップの認証情報有効期限通知を有効にする", "desktopNotificationsDescription": "同じタイミングでデスクトップアプリのシステム通知を表示します。", "barkToken": "Bark キー", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index dd32f49..ec81230 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -464,7 +464,7 @@ "credentialExpiryNotificationsDescription": "凭据将在还有 3 天、还有 1 天和已过期时通知。Bark、Web 浏览器和桌面端通知可以分别配置,服务运行时才会检查。", "credentialExpiryNotificationsEnabled": "启用 Bark 凭据过期通知", "webNotificationsEnabled": "启用 Web 端凭据过期通知", - "webNotificationsDescription": "在相同的时间点通过浏览器通知提醒。出现权限提示时请允许通知。", + "webNotificationsDescription": "在相同的时间点通过浏览器或已安装的 PWA 通知提醒。出现权限提示时请允许通知。", "desktopNotificationsEnabled": "启用桌面端凭据过期通知", "desktopNotificationsDescription": "在相同的时间点通过桌面应用的系统通知提醒。", "barkToken": "Bark Key", diff --git a/tests/admin/web-notifications.test.ts b/tests/admin/web-notifications.test.ts new file mode 100644 index 0000000..da634a9 --- /dev/null +++ b/tests/admin/web-notifications.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom + +import { + isWebNotificationSupported, + showWebNotification, +} from '@/lib/client/web-notifications'; + +const setNotification = (value: unknown): void => { + Object.defineProperty(window, 'Notification', { + configurable: true, + value, + }); +}; + +const setServiceWorker = (value: unknown): void => { + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value, + }); +}; + +describe('web notifications', () => { + afterEach(() => { + Reflect.deleteProperty(window, 'Notification'); + Reflect.deleteProperty(navigator, 'serviceWorker'); + Reflect.deleteProperty(globalThis, 'ServiceWorkerRegistration'); + }); + + it('uses the service worker notification API for installed PWAs', async () => { + const showNotification = vi.fn().mockResolvedValue(undefined); + class Registration { + showNotification(...args: [string, NotificationOptions]): Promise { + return showNotification(...args); + } + } + const registration = new Registration(); + + Object.defineProperty(globalThis, 'ServiceWorkerRegistration', { + configurable: true, + value: Registration, + }); + setNotification({ permission: 'granted' }); + setServiceWorker({ ready: Promise.resolve(registration) }); + + expect(isWebNotificationSupported()).toBe(true); + await expect(showWebNotification('Title', 'Body')).resolves.toBe(true); + expect(showNotification).toHaveBeenCalledWith('Title', { body: 'Body' }); + }); + + it('falls back to the page notification API when no worker is available', async () => { + const constructor = vi.fn(); + setNotification(Object.assign(constructor, { permission: 'granted' })); + + expect(isWebNotificationSupported()).toBe(true); + await expect(showWebNotification('Title', 'Body')).resolves.toBe(true); + expect(constructor).toHaveBeenCalledWith('Title', { body: 'Body' }); + }); + + it('does not notify without permission', async () => { + const constructor = vi.fn(); + setNotification(Object.assign(constructor, { permission: 'default' })); + + await expect(showWebNotification('Title', 'Body')).resolves.toBe(false); + expect(constructor).not.toHaveBeenCalled(); + }); +}); From e3efe7fcd89c2c9d547fb35a20af551dd41e584b Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 30 Oct 2026 16:42:25 +0800 Subject: [PATCH 3/4] fix: avoid waiting for unavailable service workers --- lib/client/web-notifications.ts | 4 ++-- tests/admin/web-notifications.test.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/client/web-notifications.ts b/lib/client/web-notifications.ts index 2c40f6d..e67c8b6 100644 --- a/lib/client/web-notifications.ts +++ b/lib/client/web-notifications.ts @@ -30,9 +30,9 @@ export const showWebNotification = async ( if ('serviceWorker' in navigator) { try { - const registration = await navigator.serviceWorker.ready; + const registration = await navigator.serviceWorker.getRegistration('/'); - if (typeof registration.showNotification === 'function') { + if (registration && typeof registration.showNotification === 'function') { await registration.showNotification(title, { body }); return true; } diff --git a/tests/admin/web-notifications.test.ts b/tests/admin/web-notifications.test.ts index da634a9..6354fdc 100644 --- a/tests/admin/web-notifications.test.ts +++ b/tests/admin/web-notifications.test.ts @@ -40,7 +40,9 @@ describe('web notifications', () => { value: Registration, }); setNotification({ permission: 'granted' }); - setServiceWorker({ ready: Promise.resolve(registration) }); + setServiceWorker({ + getRegistration: vi.fn().mockResolvedValue(registration), + }); expect(isWebNotificationSupported()).toBe(true); await expect(showWebNotification('Title', 'Body')).resolves.toBe(true); From 524b186bdaac518dbe687489fa844b8bea042c94 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 30 Oct 2026 16:56:36 +0800 Subject: [PATCH 4/4] fix: avoid consuming denied web notifications --- app/page-shell.tsx | 28 ++++++++++++++++++---- app/settings/settings.tsx | 10 ++++++-- lib/client/web-notifications.ts | 42 +++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 6e56639..2993562 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -1,6 +1,13 @@ 'use client'; -import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from 'react'; import { createStore, Provider, useAtom } from 'jotai'; import { useHydrateAtoms } from 'jotai/utils'; import { useLocale, useTranslations } from 'next-intl'; @@ -65,7 +72,12 @@ import { AdminHeader } from '@/app/header'; import { type LocalePreference } from '@/lib/i18n/routing'; import { saveLocalePreference } from '@/lib/client/preferences'; import { useThemeAppearance } from '@/lib/client/theme'; -import { showWebNotification } from '@/lib/client/web-notifications'; +import { + getWebNotificationPermission, + isWebNotificationSupported, + showWebNotification, + subscribeWebNotificationPermission, +} from '@/lib/client/web-notifications'; const tabs: Array<{ icon: typeof LayoutDashboard; @@ -409,6 +421,11 @@ const AdminPageLayoutContent = ({ }, [], ); + const webNotificationPermission = useSyncExternalStore( + subscribeWebNotificationPermission, + getWebNotificationPermission, + () => 'denied', + ); useEffect(() => { if (typeof window === 'undefined' || !('EventSource' in window)) { @@ -424,7 +441,10 @@ const AdminPageLayoutContent = ({ ).desktop; const desktopApp = desktop || Boolean(desktopBridge?.notify); - if (!desktopApp && !('Notification' in window)) { + if ( + !desktopApp && + (!isWebNotificationSupported() || webNotificationPermission !== 'granted') + ) { return; } @@ -466,7 +486,7 @@ const AdminPageLayoutContent = ({ source.removeEventListener('credential-expiry', handleEvent); source.close(); }; - }, [desktop, showNotification]); + }, [desktop, showNotification, webNotificationPermission]); /* Nothing is dropped into the console. A file or an image dropped on the diff --git a/app/settings/settings.tsx b/app/settings/settings.tsx index 98b0205..a1872fc 100644 --- a/app/settings/settings.tsx +++ b/app/settings/settings.tsx @@ -39,7 +39,10 @@ import { import Security from './security'; import Desktop from './desktop'; -import { isWebNotificationSupported } from '@/lib/client/web-notifications'; +import { + isWebNotificationSupported, + notifyWebNotificationPermissionChanged, +} from '@/lib/client/web-notifications'; export type SettingsValue = string | number | boolean | null; @@ -348,6 +351,7 @@ const CredentialExpiryNotificationSettings = ({ if (Notification.permission !== 'granted') { const permission = await Notification.requestPermission(); + notifyWebNotificationPermissionChanged(); if (permission !== 'granted') { setWebTestResult('error'); return; @@ -450,7 +454,9 @@ const CredentialExpiryNotificationSettings = ({ 'Notification' in window && Notification.permission === 'default' ) { - void Notification.requestPermission(); + void Notification.requestPermission().then(() => { + notifyWebNotificationPermissionChanged(); + }); } onChange( 'CODEBUDDY_CREDENTIAL_EXPIRY_WEB_NOTIFICATIONS_ENABLED', diff --git a/lib/client/web-notifications.ts b/lib/client/web-notifications.ts index e67c8b6..7dfe72c 100644 --- a/lib/client/web-notifications.ts +++ b/lib/client/web-notifications.ts @@ -1,3 +1,45 @@ +export const WEB_NOTIFICATION_PERMISSION_CHANGE_EVENT = + 'codebuddy2api-web-notification-permission-change'; + +export const getWebNotificationPermission = (): NotificationPermission => { + if (typeof window === 'undefined' || !('Notification' in window)) { + return 'denied'; + } + + return Notification.permission; +}; + +export const notifyWebNotificationPermissionChanged = (): void => { + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event(WEB_NOTIFICATION_PERMISSION_CHANGE_EVENT)); + } +}; + +export const subscribeWebNotificationPermission = ( + onChange: () => void, +): (() => void) => { + if (typeof window === 'undefined') { + return () => undefined; + } + + const handleChange = (): void => onChange(); + window.addEventListener( + WEB_NOTIFICATION_PERMISSION_CHANGE_EVENT, + handleChange, + ); + window.addEventListener('focus', handleChange); + document.addEventListener('visibilitychange', handleChange); + + return () => { + window.removeEventListener( + WEB_NOTIFICATION_PERMISSION_CHANGE_EVENT, + handleChange, + ); + window.removeEventListener('focus', handleChange); + document.removeEventListener('visibilitychange', handleChange); + }; +}; + export const isWebNotificationSupported = (): boolean => { if (typeof window === 'undefined' || !('Notification' in window)) { return false;