diff --git a/app/admin-api/notifications/credential-expiry/test/route.ts b/app/admin-api/notifications/credential-expiry/test/route.ts index eff13dfc..309015f2 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 3eb25865..2993562b 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,6 +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 { + getWebNotificationPermission, + isWebNotificationSupported, + showWebNotification, + subscribeWebNotificationPermission, +} from '@/lib/client/web-notifications'; const tabs: Array<{ icon: typeof LayoutDashboard; @@ -408,6 +421,11 @@ const AdminPageLayoutContent = ({ }, [], ); + const webNotificationPermission = useSyncExternalStore( + subscribeWebNotificationPermission, + getWebNotificationPermission, + () => 'denied', + ); useEffect(() => { if (typeof window === 'undefined' || !('EventSource' in window)) { @@ -422,6 +440,14 @@ const AdminPageLayoutContent = ({ } ).desktop; const desktopApp = desktop || Boolean(desktopBridge?.notify); + + if ( + !desktopApp && + (!isWebNotificationSupported() || webNotificationPermission !== 'granted') + ) { + return; + } + const source = new EventSource( `/admin-api/notifications/credential-expiry?channel=${desktopApp ? 'desktop' : 'web'}`, ); @@ -450,9 +476,7 @@ const AdminPageLayoutContent = ({ 'Notification' in window && Notification.permission === 'granted' ) { - new Notification(title, { body }); - } else { - showNotification('warning', body); + void showWebNotification(title, body); } }; @@ -462,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 9cf1d838..a1872fca 100644 --- a/app/settings/settings.tsx +++ b/app/settings/settings.tsx @@ -39,6 +39,10 @@ import { import Security from './security'; import Desktop from './desktop'; +import { + isWebNotificationSupported, + notifyWebNotificationPermissionChanged, +} from '@/lib/client/web-notifications'; export type SettingsValue = string | number | boolean | null; @@ -275,11 +279,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 +299,11 @@ const CredentialExpiryNotificationSettings = ({ >(null); const [webTesting, setWebTesting] = useState(false); const [desktopTesting, setDesktopTesting] = useState(false); + const browserNotificationsSupported = useSyncExternalStore( + () => () => undefined, + isWebNotificationSupported, + () => false, + ); const token = String(settings.values.CODEBUDDY_BARK_TOKEN ?? ''); const enabled = settings.values.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED === @@ -333,8 +344,18 @@ 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(); + notifyWebNotificationPermissionChanged(); + if (permission !== 'granted') { + setWebTestResult('error'); + return; + } } const response = await fetch( @@ -419,7 +440,7 @@ const CredentialExpiryNotificationSettings = ({ {translations('settingsPanel.desktopNotificationsDescription')}

- ) : ( + ) : browserNotificationsSupported ? ( <> @@ -433,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', @@ -446,7 +469,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 +1098,7 @@ const Settings = ({ { + 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; + } + + 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.getRegistration('/'); + + if (registration && 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/lib/server/domain/config.ts b/lib/server/domain/config.ts index 7e5e6731..47861d8d 100644 --- a/lib/server/domain/config.ts +++ b/lib/server/domain/config.ts @@ -110,7 +110,7 @@ const SETTING_LABELS_BY_LOCALE: Record< 'Web/desktop credential expiry notifications', CODEBUDDY_CREDENTIAL_EXPIRY_DESKTOP_NOTIFICATIONS_ENABLED: 'Desktop credential expiry notifications', - CODEBUDDY_BARK_TOKEN: 'Bark device key', + CODEBUDDY_BARK_TOKEN: 'Bark key', CODEBUDDY_WEB_SEARCH_BACKEND: 'Web search backend', CODEBUDDY_WEB_FETCH_BACKEND: 'Web fetch backend', CODEBUDDY_SEARXNG_URL: 'SearXNG instance address (with port)', @@ -139,7 +139,7 @@ const SETTING_LABELS_BY_LOCALE: Record< 'Web/デスクトップの認証情報有効期限通知', CODEBUDDY_CREDENTIAL_EXPIRY_DESKTOP_NOTIFICATIONS_ENABLED: 'デスクトップの認証情報有効期限通知', - CODEBUDDY_BARK_TOKEN: 'Bark デバイスキー', + CODEBUDDY_BARK_TOKEN: 'Bark キー', CODEBUDDY_WEB_SEARCH_BACKEND: 'Web 検索バックエンド', CODEBUDDY_WEB_FETCH_BACKEND: 'Web フェッチバックエンド', CODEBUDDY_SEARXNG_URL: 'SearXNG インスタンスのアドレス(ポートを含む)', @@ -167,7 +167,7 @@ const SETTING_LABELS_BY_LOCALE: Record< 'Web/桌面端凭据过期通知', CODEBUDDY_CREDENTIAL_EXPIRY_DESKTOP_NOTIFICATIONS_ENABLED: '桌面端凭据过期通知', - CODEBUDDY_BARK_TOKEN: 'Bark Token', + CODEBUDDY_BARK_TOKEN: 'Bark Key', CODEBUDDY_WEB_SEARCH_BACKEND: 'WebSearch 后端', CODEBUDDY_WEB_FETCH_BACKEND: 'WebFetch 后端', CODEBUDDY_SEARXNG_URL: 'SearXNG 实例地址(含端口)', diff --git a/messages/en-US.json b/messages/en-US.json index 2024d112..a98335d7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -461,21 +461,21 @@ "settingsPanel": { "apiTimeoutHint": "Measured from when a request is sent until the upstream produces its first delta. Once output has started, a long answer is allowed to finish. Accepts fractional minutes, from 0.1 to 1440.", "credentialExpiryNotificationsTitle": "Credential expiry notifications", - "credentialExpiryNotificationsDescription": "Send a Bark notification when a credential has 3 days left, 1 day left, or has expired. Checks run while the service is running.", + "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 token", - "barkTokenPlaceholder": "Enter your Bark token", - "barkLink": "Open the Bark website to get a token", + "barkToken": "Bark key", + "barkTokenPlaceholder": "Enter your Bark key", + "barkLink": "Open the Bark website to get a key", "testBark": "Send test notification", "testBarkSuccess": "Test notification sent.", "testBarkFailed": "Test notification failed.", - "testWebNotification": "Send web/desktop test notification", - "testWebNotificationSuccess": "Web/desktop test notification sent.", - "testWebNotificationFailed": "Web/desktop test notification failed.", + "testWebNotification": "Send web test notification", + "testWebNotificationSuccess": "Web test notification sent.", + "testWebNotificationFailed": "Web test notification failed.", "testDesktopNotification": "Send desktop test notification", "testDesktopNotificationSuccess": "Desktop test notification sent.", "testDesktopNotificationFailed": "Desktop test notification failed.", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 358a243a..51ab1495 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -461,21 +461,21 @@ "settingsPanel": { "apiTimeoutHint": "リクエスト送信からアップストリームが最初の delta を返すまでの時間です。出力が始まった後は、長い応答でも完了まで許可されます。0.1〜1440 の範囲で小数分を指定できます。", "credentialExpiryNotificationsTitle": "認証情報の有効期限通知", - "credentialExpiryNotificationsDescription": "認証情報の有効期限が 3 日前、1 日前、または期限切れになったときに Bark で通知します。サービスの実行中に確認します。", + "credentialExpiryNotificationsDescription": "認証情報の有効期限が 3 日前、1 日前、または期限切れになったときに通知します。Bark、Web ブラウザ、デスクトップ通知は個別に設定できます。サービスの実行中に確認します。", "credentialExpiryNotificationsEnabled": "Bark の認証情報有効期限通知を有効にする", "webNotificationsEnabled": "Web の認証情報有効期限通知を有効にする", - "webNotificationsDescription": "同じタイミングでブラウザ通知を表示します。表示された権限を許可してください。", + "webNotificationsDescription": "同じタイミングでブラウザまたはインストール済み PWA の通知を表示します。表示された権限を許可してください。", "desktopNotificationsEnabled": "デスクトップの認証情報有効期限通知を有効にする", "desktopNotificationsDescription": "同じタイミングでデスクトップアプリのシステム通知を表示します。", - "barkToken": "Bark トークン", - "barkTokenPlaceholder": "Bark トークンを入力", - "barkLink": "Bark のサイトでトークンを取得", + "barkToken": "Bark キー", + "barkTokenPlaceholder": "Bark キーを入力", + "barkLink": "Bark のサイトでキーを取得", "testBark": "テスト通知を送信", "testBarkSuccess": "テスト通知を送信しました。", "testBarkFailed": "テスト通知の送信に失敗しました。", - "testWebNotification": "Web/デスクトップのテスト通知を送信", - "testWebNotificationSuccess": "Web/デスクトップのテスト通知を送信しました。", - "testWebNotificationFailed": "Web/デスクトップのテスト通知の送信に失敗しました。", + "testWebNotification": "Web のテスト通知を送信", + "testWebNotificationSuccess": "Web のテスト通知を送信しました。", + "testWebNotificationFailed": "Web のテスト通知の送信に失敗しました。", "testDesktopNotification": "デスクトップのテスト通知を送信", "testDesktopNotificationSuccess": "デスクトップのテスト通知を送信しました。", "testDesktopNotificationFailed": "デスクトップのテスト通知の送信に失敗しました。", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 4ba49720..ec812301 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -461,21 +461,21 @@ "settingsPanel": { "apiTimeoutHint": "从发起请求开始计时,直到上游返回第一个 delta。一旦开始输出,即使回答较长也会允许其完成。支持小数分钟,取值范围 0.1~1440。", "credentialExpiryNotificationsTitle": "凭据过期通知", - "credentialExpiryNotificationsDescription": "凭据将在还有 3 天、还有 1 天和已过期时通过 Bark 通知。通知服务只会在服务运行时检查。", + "credentialExpiryNotificationsDescription": "凭据将在还有 3 天、还有 1 天和已过期时通知。Bark、Web 浏览器和桌面端通知可以分别配置,服务运行时才会检查。", "credentialExpiryNotificationsEnabled": "启用 Bark 凭据过期通知", "webNotificationsEnabled": "启用 Web 端凭据过期通知", - "webNotificationsDescription": "在相同的时间点通过浏览器通知提醒。出现权限提示时请允许通知。", + "webNotificationsDescription": "在相同的时间点通过浏览器或已安装的 PWA 通知提醒。出现权限提示时请允许通知。", "desktopNotificationsEnabled": "启用桌面端凭据过期通知", "desktopNotificationsDescription": "在相同的时间点通过桌面应用的系统通知提醒。", - "barkToken": "Bark Token", - "barkTokenPlaceholder": "输入 Bark Token", - "barkLink": "打开 Bark 官网获取 Token", + "barkToken": "Bark Key", + "barkTokenPlaceholder": "输入 Bark Key", + "barkLink": "打开 Bark 官网获取 Key", "testBark": "发送测试通知", "testBarkSuccess": "测试通知已发送。", "testBarkFailed": "测试通知发送失败。", - "testWebNotification": "发送 Web/桌面端测试通知", - "testWebNotificationSuccess": "Web/桌面端测试通知已发送。", - "testWebNotificationFailed": "Web/桌面端测试通知发送失败。", + "testWebNotification": "发送 Web 端测试通知", + "testWebNotificationSuccess": "Web 端测试通知已发送。", + "testWebNotificationFailed": "Web 端测试通知发送失败。", "testDesktopNotification": "发送桌面端测试通知", "testDesktopNotificationSuccess": "桌面端测试通知已发送。", "testDesktopNotificationFailed": "桌面端测试通知发送失败。", diff --git a/tests/admin/web-notifications.test.ts b/tests/admin/web-notifications.test.ts new file mode 100644 index 00000000..6354fdcd --- /dev/null +++ b/tests/admin/web-notifications.test.ts @@ -0,0 +1,68 @@ +// @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({ + getRegistration: vi.fn().mockResolvedValue(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(); + }); +});