From ba8abcb9f0378c456407486fe93ebef8aaf81032 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Tue, 27 Oct 2026 02:19:46 +0800 Subject: [PATCH] feat: add separate web and desktop expiry notifications --- .../notifications/credential-expiry/route.ts | 77 ++++++++ .../credential-expiry/test/route.ts | 34 ++++ app/page-shell.tsx | 60 ++++++ app/settings/settings.tsx | 167 +++++++++++++++- electron/main.ts | 16 ++ electron/preload.ts | 2 + lib/server/domain/config.ts | 26 +++ .../domain/credential-expiry-notifications.ts | 183 +++++++++++++++--- messages/en-US.json | 12 +- messages/ja-JP.json | 12 +- messages/zh-CN.json | 12 +- .../credential-expiry-notifications.test.ts | 94 +++++++++ 12 files changed, 669 insertions(+), 26 deletions(-) create mode 100644 app/admin-api/notifications/credential-expiry/route.ts create mode 100644 app/admin-api/notifications/credential-expiry/test/route.ts diff --git a/app/admin-api/notifications/credential-expiry/route.ts b/app/admin-api/notifications/credential-expiry/route.ts new file mode 100644 index 00000000..85fd9ad1 --- /dev/null +++ b/app/admin-api/notifications/credential-expiry/route.ts @@ -0,0 +1,77 @@ +import { getAdminSessionErrorResponse } from '@/lib/server/admin/session'; +import { + type CredentialExpiryNotificationChannel, + subscribeCredentialExpiryNotifications, + type CredentialExpiryNotificationEvent, +} from '@/lib/server/domain/credential-expiry-notifications'; +import { createSseHeaders } from '@/lib/server/shared/sse'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const encoder = new TextEncoder(); + +const encodeEvent = (event: CredentialExpiryNotificationEvent): Uint8Array => + encoder.encode( + `event: credential-expiry\ndata: ${JSON.stringify(event)}\n\n`, + ); + +export const GET = async (request: Request): Promise => { + const authError = await getAdminSessionErrorResponse(request); + + if (authError) return authError; + + const channel = new URL(request.url).searchParams.get('channel'); + if (channel !== 'web' && channel !== 'desktop') { + return Response.json( + { error: 'Notification channel is required' }, + { status: 400 }, + ); + } + + let close = (): void => undefined; + const stream = new ReadableStream({ + start(controller) { + let closed = false; + const finish = (): void => { + if (closed) return; + closed = true; + clearInterval(heartbeat); + unsubscribe(); + request.signal.removeEventListener('abort', finish); + try { + controller.close(); + } catch { + return; + } + }; + const unsubscribe = subscribeCredentialExpiryNotifications( + channel as CredentialExpiryNotificationChannel, + (event) => { + if (closed) return; + try { + controller.enqueue(encodeEvent(event)); + } catch { + finish(); + } + }, + ); + const heartbeat = setInterval(() => { + if (closed) return; + try { + controller.enqueue(encoder.encode(': keep-alive\n\n')); + } catch { + finish(); + } + }, 25_000); + close = finish; + request.signal.addEventListener('abort', finish); + controller.enqueue(encoder.encode(': connected\n\n')); + }, + cancel() { + close(); + }, + }); + + return new Response(stream, { headers: createSseHeaders() }); +}; diff --git a/app/admin-api/notifications/credential-expiry/test/route.ts b/app/admin-api/notifications/credential-expiry/test/route.ts new file mode 100644 index 00000000..eff13dfc --- /dev/null +++ b/app/admin-api/notifications/credential-expiry/test/route.ts @@ -0,0 +1,34 @@ +import { getAdminSessionErrorResponse } from '@/lib/server/admin/session'; +import { publishCredentialExpiryNotification } from '@/lib/server/domain/credential-expiry-notifications'; +import { getJsonBody } from '@/lib/server/shared/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const POST = async (request: Request): Promise => { + const authError = await getAdminSessionErrorResponse(request); + + if (authError) return authError; + + const body = await getJsonBody<{ channel?: unknown }>(request); + const channel = body.channel; + if (channel !== 'web' && channel !== 'desktop') { + return Response.json( + { error: 'Notification channel is required' }, + { status: 400 }, + ); + } + + const delivered = publishCredentialExpiryNotification(channel, { + body: + channel === 'web' + ? '这是一条 CodeBuddy2API Web 端测试通知。' + : '这是一条 CodeBuddy2API 桌面端测试通知。', + credential: 'test', + expiresAt: Date.now() / 1000 + 3 * 24 * 60 * 60, + key: 'three_days', + title: 'CodeBuddy2API 凭据提醒测试', + }); + + return Response.json({ delivered, message: 'Test notification sent.' }); +}; diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 95f484ef..3eb25865 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -199,6 +199,11 @@ interface JsonResult { status: number; } +interface CredentialExpiryNotificationEvent { + body?: string; + title?: string; +} + const requestJson = async ( input: RequestInfo | URL, init?: RequestInit, @@ -404,6 +409,61 @@ const AdminPageLayoutContent = ({ [], ); + useEffect(() => { + if (typeof window === 'undefined' || !('EventSource' in window)) { + return; + } + + const desktopBridge = ( + window as unknown as { + desktop?: { + notify?: (nextTitle: string, nextBody: string) => Promise; + }; + } + ).desktop; + const desktopApp = desktop || Boolean(desktopBridge?.notify); + const source = new EventSource( + `/admin-api/notifications/credential-expiry?channel=${desktopApp ? 'desktop' : 'web'}`, + ); + const handleEvent = (event: Event): void => { + const messageEvent = event as MessageEvent; + let payload: CredentialExpiryNotificationEvent; + + try { + payload = JSON.parse( + messageEvent.data, + ) as CredentialExpiryNotificationEvent; + } catch { + return; + } + + const body = payload.body?.trim(); + const title = payload.title?.trim(); + + if (!body || !title) return; + + if (desktopApp) { + if (desktopBridge?.notify) { + void desktopBridge.notify(title, body); + } + } else if ( + 'Notification' in window && + Notification.permission === 'granted' + ) { + new Notification(title, { body }); + } else { + showNotification('warning', body); + } + }; + + source.addEventListener('credential-expiry', handleEvent); + + return () => { + source.removeEventListener('credential-expiry', handleEvent); + source.close(); + }; + }, [desktop, showNotification]); + /* Nothing is dropped into the console. A file or an image dropped on the window navigates it to that file, or lands in one of the fields it is diff --git a/app/settings/settings.tsx b/app/settings/settings.tsx index a55c15dc..9cf1d838 100644 --- a/app/settings/settings.tsx +++ b/app/settings/settings.tsx @@ -15,7 +15,14 @@ import { Trash2, } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { createContext, useContext, useEffect, useRef, useState } from 'react'; +import { + createContext, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react'; import { BACKEND_CONFIG_KEYS, @@ -190,6 +197,8 @@ const DESKTOP_HIDDEN_CONFIG_KEYS = new Set([ const CUSTOM_CONFIG_KEYS = new Set([ 'CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED', + 'CODEBUDDY_CREDENTIAL_EXPIRY_WEB_NOTIFICATIONS_ENABLED', + 'CODEBUDDY_CREDENTIAL_EXPIRY_DESKTOP_NOTIFICATIONS_ENABLED', 'CODEBUDDY_BARK_TOKEN', ]); @@ -265,10 +274,12 @@ const SettingField = ({ }; const CredentialExpiryNotificationSettings = ({ + desktop, onChange, settings, translations, }: { + desktop: boolean; onChange: (key: string, value: string | boolean) => void; settings: SettingsState; translations: (key: string) => string; @@ -277,12 +288,27 @@ const CredentialExpiryNotificationSettings = ({ const [testResult, setTestResult] = useState<'success' | 'error' | null>( null, ); + const [webTestResult, setWebTestResult] = useState< + 'success' | 'error' | null + >(null); + const [webTesting, setWebTesting] = useState(false); + const [desktopTesting, setDesktopTesting] = useState(false); const token = String(settings.values.CODEBUDDY_BARK_TOKEN ?? ''); const enabled = settings.values.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED === true || settings.values.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED === 'true'; + const webEnabled = + settings.values.CODEBUDDY_CREDENTIAL_EXPIRY_WEB_NOTIFICATIONS_ENABLED === + true || + settings.values.CODEBUDDY_CREDENTIAL_EXPIRY_WEB_NOTIFICATIONS_ENABLED === + 'true'; + const desktopEnabled = + settings.values + .CODEBUDDY_CREDENTIAL_EXPIRY_DESKTOP_NOTIFICATIONS_ENABLED === true || + settings.values + .CODEBUDDY_CREDENTIAL_EXPIRY_DESKTOP_NOTIFICATIONS_ENABLED === 'true'; const testNotification = async () => { setTesting(true); @@ -302,6 +328,52 @@ const CredentialExpiryNotificationSettings = ({ } }; + const testWebNotification = async () => { + setWebTesting(true); + setWebTestResult(null); + + try { + if ('Notification' in window && Notification.permission !== 'granted') { + await Notification.requestPermission(); + } + + const response = await fetch( + '/admin-api/notifications/credential-expiry/test', + { + body: JSON.stringify({ channel: 'web' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }, + ); + setWebTestResult(response.ok ? 'success' : 'error'); + } catch { + setWebTestResult('error'); + } finally { + setWebTesting(false); + } + }; + + const testDesktopNotification = async () => { + setDesktopTesting(true); + setWebTestResult(null); + + try { + const response = await fetch( + '/admin-api/notifications/credential-expiry/test', + { + body: JSON.stringify({ channel: 'desktop' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }, + ); + setWebTestResult(response.ok ? 'success' : 'error'); + } catch { + setWebTestResult('error'); + } finally { + setDesktopTesting(false); + } + }; + return ( @@ -327,6 +399,54 @@ const CredentialExpiryNotificationSettings = ({ } /> + {desktop ? ( + <> + + + {translations('settingsPanel.desktopNotificationsEnabled')} + + + onChange( + 'CODEBUDDY_CREDENTIAL_EXPIRY_DESKTOP_NOTIFICATIONS_ENABLED', + checked, + ) + } + /> + +

+ {translations('settingsPanel.desktopNotificationsDescription')} +

+ + ) : ( + <> + + + {translations('settingsPanel.webNotificationsEnabled')} + + { + if ( + checked && + 'Notification' in window && + Notification.permission === 'default' + ) { + void Notification.requestPermission(); + } + onChange( + 'CODEBUDDY_CREDENTIAL_EXPIRY_WEB_NOTIFICATIONS_ENABLED', + checked, + ); + }} + /> + +

+ {translations('settingsPanel.webNotificationsDescription')} +

+ + )}