From 96ec215a6a2cc33b1d7a46d9e5bbf2521dd7142b Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 2 Oct 2026 02:51:34 +0800 Subject: [PATCH 1/4] fix: open remote desktop backends directly --- app/login/page.tsx | 7 +- app/page.tsx | 7 +- electron/main.ts | 157 ++++++++++++++---------------- lib/server/electron/deployment.ts | 2 +- lib/server/electron/settings.ts | 4 +- next.config.ts | 3 + scripts/build-desktop.ts | 27 +++-- scripts/dev-desktop.ts | 31 +++--- 8 files changed, 131 insertions(+), 107 deletions(-) diff --git a/app/login/page.tsx b/app/login/page.tsx index 7f54fdf8..2e053e91 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -67,7 +67,12 @@ const LoginPage = async ({ }); const cookieHeader = headerStore.get('cookie') ?? ''; const request = new Request(`${protocol}://${host}/login`, { - headers: cookieHeader ? { cookie: cookieHeader } : {}, + headers: { + ...(cookieHeader ? { cookie: cookieHeader } : {}), + ...(headerStore.get('authorization') + ? { authorization: headerStore.get('authorization') as string } + : {}), + }, }); // The password belongs to whichever console this is: the deployment's, when // this build is only rendering it. diff --git a/app/page.tsx b/app/page.tsx index 50ac9444..e7fb4e5f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -40,7 +40,12 @@ export const AdminPage = async ({ }); const cookieHeader = headerStore.get('cookie') ?? ''; const request = new Request(`${protocol}://${host}/`, { - headers: cookieHeader ? { cookie: cookieHeader } : {}, + headers: { + ...(cookieHeader ? { cookie: cookieHeader } : {}), + ...(headerStore.get('authorization') + ? { authorization: headerStore.get('authorization') as string } + : {}), + }, }); // Whose password applies: a desktop install serving its own data has none, // but one showing a deployment's data is that deployment's console, and the diff --git a/electron/main.ts b/electron/main.ts index b32aff25..d1819d3f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -15,6 +15,7 @@ import { ipcMain, nativeImage, nativeTheme, + net, session, shell, } from 'electron'; @@ -457,14 +458,11 @@ const sameSettings = (a: DesktopSettings, b: DesktopSettings): boolean => * The address the console is served from, or an empty string while there is * none — a gateway that has not started yet. * - * Always the bundled gateway's, a deployment configured or not: what the window - * shows is this app's own console, and only the data behind `/admin-api` comes - * from the deployment. + * A deployment's own address: the console it serves is the deployment's, and + * so is the data behind it. Nothing of this machine's sits in between, and no + * port of this machine's is given up to forward one. */ const consoleBaseUrl = (): string => - // A deployment's own address: the console it serves is the deployment's, and - // so is the data behind it. Nothing of this machine's sits in between, and no - // port of this machine's is given up to forward one. backend.mode === 'remote' ? backend.url.trim().replace(/\/+$/, '') : (gateway?.url ?? ''); @@ -586,6 +584,29 @@ const setConsoleCookie = async (origin: string): Promise => { } }; +/** Adds the device grant to requests made directly to the remote console. */ +const installRemoteAuthHeaders = (): void => { + session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { + if (backend.mode === 'remote' && deviceToken) { + try { + if (new URL(details.url).origin === new URL(backend.url).origin) { + const hasAuthorization = Object.keys(details.requestHeaders).some( + (name) => name.toLowerCase() === 'authorization', + ); + + if (!hasAuthorization) { + details.requestHeaders.Authorization = `Bearer ${deviceToken}`; + } + } + } catch { + // Invalid navigation targets are handled by the normal allow-list. + } + } + + callback({ requestHeaders: details.requestHeaders }); + }); +}; + /** * Sends a window to the console, keeping the navigation allow-list in step with * where it is going. @@ -596,13 +617,6 @@ const loadConsole = async ( ): Promise => { consoleOrigin = new URL(url).origin; - /* - The token is only for a console this machine serves: the gateway the app - starts answers 404 without it, so nothing but its own window gets a page. A - deployment's console is the deployment's, and it has no such guard — a - cookie handed to that origin would be this app's console token sent to - somebody else's server, for a door nobody there is keeping. - */ if (backend.mode !== 'remote') { await setConsoleCookie(consoleOrigin); } @@ -992,9 +1006,7 @@ const writeConsolePreferences = async (cookies: Cookie[]): Promise => { refreshMenus(); - // Only a console that is being served can be asked for another page: a paused - // gateway has no window to reload, and the choice is there next time. - if (mainWindow && !mainWindow.isDestroyed() && gateway) { + if (mainWindow && !mainWindow.isDestroyed() && consoleOrigin) { mainWindow.reload(); } }; @@ -1480,7 +1492,11 @@ const devConsoleSpawn = (port: number): GatewaySpawn | undefined => { } return ({ env }) => { - const childEnv: NodeJS.ProcessEnv = { ...env, NODE_ENV: 'development' }; + const childEnv: NodeJS.ProcessEnv = { + ...env, + CODEBUDDY_DESKTOP_DEV: '1', + NODE_ENV: 'development', + }; // The built gateway runs on this app's own binary, switched into Node by // this; the one in the repository is an ordinary runtime, to which the @@ -1643,12 +1659,10 @@ const scheduleRestart = (): void => { * The port the next gateway should bind, or null when the app cannot have one * without asking. * - * A port the app promised — one the settings file holds, or - * `CODEBUDDY_DESKTOP_PORT` — is not its to give up: a client config, a firewall - * rule or a bookmark points at that number, so the app asks instead of answering - * on another one. Only a port nobody has settled walks upwards, which is what - * keeps an install that has never been asked usable next to a deployment already - * serving 8001. + * A local backend may promise a fixed port through settings or + * `CODEBUDDY_DESKTOP_PORT`. A remote backend has no such promise: its port is + * only an internal loopback detail used to carry the authenticated console, so + * it always walks upwards when the preferred port is occupied. * * Probed with the gateway stopped: the one that was running still holds its * port, and would otherwise reject the number the app is already on — the one @@ -1675,12 +1689,18 @@ const resolveStartPort = async (): Promise => { * sends the open window to its new address. Reached when the console saves a * port, and reused for the first launch. * - * The gateway runs either way: it is what serves the console. A deployment only - * decides where the data behind it comes from, so it is asked first — a console - * pointed at a deployment that is not there would otherwise come up quiet and - * empty, and look like the app's own failure. + * Only the local backend runs a gateway. A remote backend is opened directly at + * its own address and never consumes a loopback port. */ const restartGateway = async (): Promise => { + if (backend.mode === 'remote') { + await stopGatewayAndWait(); + status = 'running'; + refreshMenus(); + + return; + } + if (quitting) { // Nothing after this starts a gateway, so nothing can answer a click that // was waiting for one. @@ -1714,32 +1734,7 @@ const restartGateway = async (): Promise => { refreshMenus(); const startedFor = backend; - const upstream = backend.mode === 'remote' ? backend.url : null; - - if (upstream) { - lastProbe = await probeDeployment({ url: upstream }); - - if (lastProbe.kind !== 'ready') { - restarting = false; - await stopGatewayAndWait(); - status = 'unreachable'; - refreshMenus(); - // Dropped here rather than in the `finally` below, which this return is - // above: a click waiting for a console is waiting for one that is not - // coming, and left set it is a window that opens on its own the next - // time anything starts successfully. - consoleRequestPending = false; - // The console has nothing to show yet, so what the user is asked is how to - // get to a deployment that answers. Raised rather than asked: a start can - // be under way because an answer is being applied, and this question came - // out of that answer. - raiseBackendQuestion('unreachable'); - - return; - } - } else { - lastProbe = null; - } + lastProbe = null; try { // The gateway that is running still holds its port, so probing before @@ -1763,7 +1758,7 @@ const restartGateway = async (): Promise => { return; } - pendingStart = launchGateway(port, upstream); + pendingStart = launchGateway(port, null); gateway = await pendingStart; pendingStart = null; pendingChild = null; @@ -2160,12 +2155,8 @@ const needsDeploymentSignIn = async (): Promise => * Opens the console of a deployment, in this app's own window, on the * deployment's own address. * - * No gateway of this machine's is started for it. The window is this app's, and - * what is in it is the deployment's console, asked for on the address it is - * served on — which is also where its data and its API are. A gateway on - * loopback forwarding the two is a port this machine gave up and an API nobody - * asked this app to run: the console is for controlling a backend, not for - * being one. + * No gateway of this machine's is started for it. The device token is injected + * into requests to the remote origin by the Electron session. */ const openDeploymentConsole = async ({ authenticate = false, @@ -2176,7 +2167,10 @@ const openDeploymentConsole = async ({ return; } - lastProbe = await probeDeployment({ url: target.url }); + lastProbe = await probeDeployment({ + fetchImpl: net.fetch, + url: target.url, + }); if (lastProbe.kind !== 'ready') { status = 'unreachable'; @@ -2227,9 +2221,8 @@ const openDeploymentConsole = async ({ * alone. `port` is the port that window settled when it settled one, and is * saved along with the backend; without it, the port already on disk stands. * - * Every switch goes through the bundled gateway, a deployment named or not: the - * console is this app's own build, and the deployment only supplies the data - * behind it. + * Local switches start the bundled gateway; remote switches open the deployment + * directly without starting anything on loopback. */ const applyBackend = async ( next: DesktopBackend, @@ -2274,17 +2267,8 @@ const applyBackend = async ( serverVersion = null; refreshMenus(); - /* - A deployment is served on its own address, and this machine starts nothing - for it: the console in this window is the deployment's, and so is the data - and the API behind it. A gateway on loopback forwarding the two is a port - this machine gave up and an API nobody asked this app to run — the console - is for controlling a backend, not for being one. - */ if (backend.mode === 'remote') { - // Stopped, including one still coming up: a start in flight is a child - // holding the port, and `stopGatewayAndWait` only knows about a gateway - // that has already answered. + // Stop a previous local backend before opening the remote deployment. await stopGatewayAndWait(); if (pendingChild) { @@ -2882,7 +2866,9 @@ const setPaused = async (next: boolean): Promise => { return; } - await restartGateway(); + if (backend.mode === 'local') { + await restartGateway(); + } }; /** @@ -2977,7 +2963,7 @@ const signInToDeployment = async (quiet = false): Promise => { was never going to sign anything in, and saying so is the whole point of asking. */ - const probe = await probeDeployment({ url: issuedBy }); + const probe = await probeDeployment({ fetchImpl: net.fetch, url: issuedBy }); if (probe.kind !== 'ready') { say( @@ -3177,7 +3163,7 @@ const signOutOfDeployment = async (): Promise => { } } - await restartGateway(); + await openDeploymentConsole(); }; /** @@ -3203,7 +3189,11 @@ const askAboutUnreachable = async (): Promise => { }); if (choice.response === 0) { - await restartGateway(); + if (backend.mode === 'remote') { + await openDeploymentConsole(); + } else { + await restartGateway(); + } // Answered this time, and the console comes up: a deployment that was // merely cold is running now, and an app that says so only in the menu bar @@ -3373,15 +3363,13 @@ const warmConsole = async (): Promise => { }; const startBackend = async (): Promise => { - // A deployment is served on its own address, and this machine starts nothing - // for it; only this machine's own data needs a gateway of this machine's. if (backend.mode === 'remote') { await openDeploymentConsole(); return; } - // This machine's own gateway serves the console; its data is this machine's. + // This machine's own gateway serves both the console and its data. await restartGateway(); if (status === 'running') { @@ -3434,6 +3422,7 @@ const bootstrap = async (): Promise => { session.defaultSession.getUserAgent(), [app.getLocale(), 'en-US'].join(','), ); + installRemoteAuthHeaders(); // Made up here and nowhere else: the gateway gets it through the environment, // the window gets it as a cookie, and it dies with this run — a token written // down would be one a next run could be made to honour. @@ -3675,7 +3664,11 @@ ipcMain.handle( ipcMain.handle('desktop:retry-backend', async () => { const window = backendWindow; - await restartGateway(); + if (backend.mode === 'remote') { + await openDeploymentConsole(); + } else { + await restartGateway(); + } // Answered this time: the question goes away and the console comes up. The // window is on its way out, and `showMainWindow` asks whether there is one diff --git a/lib/server/electron/deployment.ts b/lib/server/electron/deployment.ts index 4cc6bb2c..3e6ec4c6 100644 --- a/lib/server/electron/deployment.ts +++ b/lib/server/electron/deployment.ts @@ -73,7 +73,7 @@ export type DeploymentProbe = | { kind: 'foreign' }; export interface ProbeOptions { - fetchImpl?: typeof fetch; + fetchImpl?: (input: string, init?: RequestInit) => Promise; timeoutMs?: number; url: string; } diff --git a/lib/server/electron/settings.ts b/lib/server/electron/settings.ts index fb42b13a..2aea16cd 100644 --- a/lib/server/electron/settings.ts +++ b/lib/server/electron/settings.ts @@ -22,8 +22,8 @@ export const DESKTOP_SETTINGS_FILENAME = 'desktop-settings.json'; * * `local` is the gateway bundled into the app — started by the main process on * a loopback port, with its database inside `userData`. `remote` is a - * deployment the user already runs: the console stays this app's own, and the - * gateway forwards `/admin-api` and `/v1` there for the data. + * deployment the user already runs: the desktop window opens that deployment + * directly and does not start a local gateway. */ export type DesktopBackend = { mode: 'local' } | { mode: 'remote'; url: string }; diff --git a/next.config.ts b/next.config.ts index 5c3fdd2f..cf34edd3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,10 @@ import createNextIntlPlugin from 'next-intl/plugin'; import type { NextConfig } from 'next'; +const desktopDevelopment = process.env.CODEBUDDY_DESKTOP_DEV?.trim() === '1'; + const nextConfig: NextConfig = { + ...(desktopDevelopment ? { distDir: '.next-desktop' } : {}), output: 'standalone', outputFileTracingIncludes: { '/*': ['./lib/server/storage/migrations/**'], diff --git a/scripts/build-desktop.ts b/scripts/build-desktop.ts index 75d4faab..4166c023 100644 --- a/scripts/build-desktop.ts +++ b/scripts/build-desktop.ts @@ -53,9 +53,14 @@ export const bunBinary = () => { * shim directly. On Windows that shim is a `.cmd`, which `execFile` cannot run * without a shell, and bun resolves the shim itself on every platform. */ -const runBin = (binary: string, args: string[]): void => { +const runBin = ( + binary: string, + args: string[], + env: Record = {}, +): void => { execFileSync(bunBinary(), ['run', binary, '--', ...args], { cwd: root, + env: { ...process.env, ...env }, stdio: 'inherit', }); }; @@ -279,14 +284,18 @@ const packageDesktop = (forwarded: string[], devIcon: boolean) => { ] : []; - runBin('electron-builder', [ - '--config', - builderConfig, - ...iconArguments, - '--publish', - 'never', - ...forwarded, - ]); + runBin( + 'electron-builder', + [ + '--config', + builderConfig, + ...iconArguments, + '--publish', + 'never', + ...forwarded, + ], + devIcon ? { CSC_IDENTITY_AUTO_DISCOVERY: 'false' } : undefined, + ); }; /** diff --git a/scripts/dev-desktop.ts b/scripts/dev-desktop.ts index 871670ca..57b73283 100644 --- a/scripts/dev-desktop.ts +++ b/scripts/dev-desktop.ts @@ -174,9 +174,14 @@ const appIsStale = (): boolean => { return newest > packaged; }; -const runBin = (binary: string, args: string[]): void => { +const runBin = ( + binary: string, + args: string[], + env: Record = {}, +): void => { const { status } = spawnSync(bunBinary(), ['run', binary, '--', ...args], { cwd: root, + env: { ...process.env, ...env }, stdio: 'inherit', }); @@ -196,16 +201,20 @@ const buildDevApp = (): void => { ensureGatewayBuild(); prepareDesktop(true); - runBin('electron-builder', [ - '--config', - builderConfig, - '--dir', - `-c.directories.output=${devOutput}`, - '-c.asar=false', - `-c.mac.icon=${path.join(appBundleDir, 'icon-dev.png')}`, - '--publish', - 'never', - ]); + runBin( + 'electron-builder', + [ + '--config', + builderConfig, + '--dir', + `-c.directories.output=${devOutput}`, + '-c.asar=false', + `-c.mac.icon=${path.join(appBundleDir, 'icon-dev.png')}`, + '--publish', + 'never', + ], + { CSC_IDENTITY_AUTO_DISCOVERY: 'false' }, + ); }; /** From 2bd5b7258152781c24a20a9f1b927bb678676791 Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 2 Oct 2026 13:55:42 +0800 Subject: [PATCH 2/4] chore: ignore desktop development output --- .gitignore | 1 + .prettierignore | 1 + eslint.config.ts | 1 + tsconfig.json | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b6594a59..bb9b7cba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules # Next.js / TypeScript build outputs .next/ +.next-desktop/ docs/.vitepress/cache/ docs/.vitepress/dist/ docs/node_modules diff --git a/.prettierignore b/.prettierignore index d9ae5f5e..2afd460e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ .next +.next-desktop next-env.d.ts coverage node_modules diff --git a/eslint.config.ts b/eslint.config.ts index bf45b4bc..1fdbb332 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -10,6 +10,7 @@ import reactHooksPlugin from 'eslint-plugin-react-hooks'; const eslintConfig = [ globalIgnores([ '.next/**', + '.next-desktop/**', 'coverage/**', 'docs/.vitepress/**', '**/node_modules/**', diff --git a/tsconfig.json b/tsconfig.json index 2986fe8a..839b7171 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules", "docs"] + "exclude": ["node_modules", "docs", ".next-desktop"] } From 15f482ac65a51de2e8cf3dffd9aaa45941914e77 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Mon, 26 Oct 2026 21:22:02 +0800 Subject: [PATCH 3/4] feat: add Bark credential expiry notifications --- .../notifications/bark/test/route.ts | 36 +++ app/page-shell.tsx | 4 +- app/settings/settings.tsx | 128 +++++++- instrumentation.ts | 3 + lib/server/domain/config.ts | 20 ++ .../domain/credential-expiry-notifications.ts | 239 +++++++++++++++ lib/server/domain/credentials.ts | 4 +- messages/en-US.json | 9 + messages/ja-JP.json | 9 + messages/zh-CN.json | 9 + .../credential-expiry-notifications.test.ts | 278 ++++++++++++++++++ 11 files changed, 734 insertions(+), 5 deletions(-) create mode 100644 app/admin-api/notifications/bark/test/route.ts create mode 100644 lib/server/domain/credential-expiry-notifications.ts create mode 100644 tests/server/credential-expiry-notifications.test.ts diff --git a/app/admin-api/notifications/bark/test/route.ts b/app/admin-api/notifications/bark/test/route.ts new file mode 100644 index 00000000..c322d4d3 --- /dev/null +++ b/app/admin-api/notifications/bark/test/route.ts @@ -0,0 +1,36 @@ +import { getAdminSessionErrorResponse } from '@/lib/server/admin/session'; +import { sendBarkNotification } 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<{ token?: unknown }>(request); + const token = typeof body.token === 'string' ? body.token.trim() : ''; + + if (!token) { + return Response.json({ error: 'Bark token is required' }, { status: 400 }); + } + + try { + await sendBarkNotification({ + body: '这是一条 CodeBuddy2API Bark 测试通知。', + title: 'CodeBuddy2API Bark 测试', + token, + }); + return Response.json({ message: 'Test notification sent.' }); + } catch (error) { + return Response.json( + { + error: + error instanceof Error ? error.message : 'Bark notification failed', + }, + { status: 502 }, + ); + } +}; diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 4190c8b9..95f484ef 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -166,7 +166,7 @@ interface PollAuthResponse { interface SettingsResponse { labels?: Record; - settings?: Record; + settings?: Record; } interface DebugResponse { @@ -1414,7 +1414,7 @@ const AdminPageLayoutContent = ({ const result = await requestJson<{ message?: string; - settings?: Record; + settings?: Record; }>('/admin-api/settings', { body: JSON.stringify({ settings: settings.values, diff --git a/app/settings/settings.tsx b/app/settings/settings.tsx index 1497b05c..a55c15dc 100644 --- a/app/settings/settings.tsx +++ b/app/settings/settings.tsx @@ -1,12 +1,13 @@ 'use client'; import { Block, Flexbox, Input, TextArea } from '@lobehub/ui'; -import { Button, Select } from '@lobehub/ui/base-ui'; +import { Button, Select, Switch } from '@lobehub/ui/base-ui'; import { Table } from 'antd'; import type { TableColumnsType } from 'antd'; import { atom } from 'jotai'; import { Layers3, + BellRing, LoaderCircle, RefreshCw, Save, @@ -67,7 +68,7 @@ export const createSettingsState = ({ }; export interface SettingsController { - onChange: (key: string, value: string) => void; + onChange: (key: string, value: string | boolean) => void; onSave: () => void; settings: SettingsState; } @@ -107,6 +108,10 @@ const settingsSelectOptions: Record< { label: 'true', value: 'true' }, { label: 'false', value: 'false' }, ], + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: [ + { label: 'true', value: 'true' }, + { label: 'false', value: 'false' }, + ], }; /** @@ -183,6 +188,11 @@ const DESKTOP_HIDDEN_CONFIG_KEYS = new Set([ 'CODEBUDDY_ADMIN_PASSKEY_RP_ID', ]); +const CUSTOM_CONFIG_KEYS = new Set([ + 'CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED', + 'CODEBUDDY_BARK_TOKEN', +]); + /** * Maps a setting key to its helper text. * @@ -254,6 +264,114 @@ const SettingField = ({ ); }; +const CredentialExpiryNotificationSettings = ({ + onChange, + settings, + translations, +}: { + onChange: (key: string, value: string | boolean) => void; + settings: SettingsState; + translations: (key: string) => string; +}) => { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<'success' | 'error' | null>( + null, + ); + 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 testNotification = async () => { + setTesting(true); + setTestResult(null); + + try { + const response = await fetch('/admin-api/notifications/bark/test', { + body: JSON.stringify({ token }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + setTestResult(response.ok ? 'success' : 'error'); + } catch { + setTestResult('error'); + } finally { + setTesting(false); + } + }; + + return ( + + + +

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

+
+

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

+ + + {translations('settingsPanel.credentialExpiryNotificationsEnabled')} + + + onChange( + 'CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED', + checked, + ) + } + /> + +
+ + + onChange('CODEBUDDY_BARK_TOKEN', event.target.value) + } + placeholder={translations('settingsPanel.barkTokenPlaceholder')} + type="password" + value={token} + /> +

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

+
+ + + {testResult === 'success' ? ( + + {translations('settingsPanel.testBarkSuccess')} + + ) : null} + {testResult === 'error' ? ( + + {translations('settingsPanel.testBarkFailed')} + + ) : null} + +
+ ); +}; + /** * The settings one backend needs, rendered under the picker that selected it. * @@ -709,6 +827,7 @@ const Settings = ({ // A backend's own settings are rendered by its picker, which // knows which backend they belong to. .filter(([settingKey]) => !BACKEND_CONFIG_KEYS.has(settingKey)) + .filter(([settingKey]) => !CUSTOM_CONFIG_KEYS.has(settingKey)) .filter(([settingKey]) => desktop ? !DESKTOP_HIDDEN_CONFIG_KEYS.has(settingKey) : true, ) @@ -763,6 +882,11 @@ const Settings = ({ + diff --git a/instrumentation.ts b/instrumentation.ts index a0e72557..a965cf39 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -5,10 +5,13 @@ export const register = async (): Promise => { await import('@/lib/server/domain/credential-models'); const { startAutoCheckinScheduler } = await import('@/lib/server/domain/auto-checkin-scheduler'); + const { startCredentialExpiryNotificationScheduler } = + await import('@/lib/server/domain/credential-expiry-notifications'); void refreshMissingCredentialModels(); // Only in the server process: a build or an edge runtime must not start a // timer that would outlive the request it belongs to. startAutoCheckinScheduler(); + startCredentialExpiryNotificationScheduler(); }; diff --git a/lib/server/domain/config.ts b/lib/server/domain/config.ts index a6f8bd40..c7504ddc 100644 --- a/lib/server/domain/config.ts +++ b/lib/server/domain/config.ts @@ -31,6 +31,8 @@ export interface RuntimeConfig { CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa' | 'internal' | 'public'; CODEBUDDY_LOG_LEVEL: string; CODEBUDDY_API_TIMEOUT_MINUTES: number; + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: boolean; + CODEBUDDY_BARK_TOKEN: string; CODEBUDDY_WEB_SEARCH_BACKEND: string; CODEBUDDY_WEB_FETCH_BACKEND: string; CODEBUDDY_SEARXNG_URL: string; @@ -68,6 +70,8 @@ const DEFAULT_CONFIG: RuntimeConfig = { CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa', CODEBUDDY_LOG_LEVEL: 'INFO', CODEBUDDY_API_TIMEOUT_MINUTES: DEFAULT_API_TIMEOUT_MINUTES, + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: false, + CODEBUDDY_BARK_TOKEN: '', CODEBUDDY_WEB_SEARCH_BACKEND: 'searxng', CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy', CODEBUDDY_SEARXNG_URL: '', @@ -96,6 +100,9 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_INTERNET_ENVIRONMENT: 'Network environment (internal/ioa/public)', CODEBUDDY_LOG_LEVEL: 'Log level', CODEBUDDY_API_TIMEOUT_MINUTES: 'API timeout, first token (minutes)', + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: + 'Credential expiry notifications', + CODEBUDDY_BARK_TOKEN: 'Bark device key', CODEBUDDY_WEB_SEARCH_BACKEND: 'Web search backend', CODEBUDDY_WEB_FETCH_BACKEND: 'Web fetch backend', CODEBUDDY_SEARXNG_URL: 'SearXNG instance address (with port)', @@ -119,6 +126,8 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_INTERNET_ENVIRONMENT: 'ネットワーク環境 (internal/ioa/public)', CODEBUDDY_LOG_LEVEL: 'ログレベル', CODEBUDDY_API_TIMEOUT_MINUTES: 'API タイムアウト・最初のトークン (分)', + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: '認証情報の有効期限通知', + CODEBUDDY_BARK_TOKEN: 'Bark デバイスキー', CODEBUDDY_WEB_SEARCH_BACKEND: 'Web 検索バックエンド', CODEBUDDY_WEB_FETCH_BACKEND: 'Web フェッチバックエンド', CODEBUDDY_SEARXNG_URL: 'SearXNG インスタンスのアドレス(ポートを含む)', @@ -141,6 +150,8 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_INTERNET_ENVIRONMENT: '网络环境 (internal/ioa/public)', CODEBUDDY_LOG_LEVEL: '日志级别', CODEBUDDY_API_TIMEOUT_MINUTES: 'API 超时时间,首个 token(分钟)', + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: '凭据过期通知', + CODEBUDDY_BARK_TOKEN: 'Bark Token', CODEBUDDY_WEB_SEARCH_BACKEND: 'WebSearch 后端', CODEBUDDY_WEB_FETCH_BACKEND: 'WebFetch 后端', CODEBUDDY_SEARXNG_URL: 'SearXNG 实例地址(含端口)', @@ -287,6 +298,15 @@ export const getActiveConfig = async (): Promise => { persisted.CODEBUDDY_API_TIMEOUT_MINUTES ?? process.env.CODEBUDDY_API_TIMEOUT_MINUTES, ), + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: normalizeValue( + 'CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED', + persisted.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED ?? + process.env.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED, + ), + CODEBUDDY_BARK_TOKEN: normalizeValue( + 'CODEBUDDY_BARK_TOKEN', + persisted.CODEBUDDY_BARK_TOKEN ?? process.env.CODEBUDDY_BARK_TOKEN, + ), CODEBUDDY_WEB_SEARCH_BACKEND: normalizeValue( 'CODEBUDDY_WEB_SEARCH_BACKEND', persisted.CODEBUDDY_WEB_SEARCH_BACKEND ?? diff --git a/lib/server/domain/credential-expiry-notifications.ts b/lib/server/domain/credential-expiry-notifications.ts new file mode 100644 index 00000000..097b4c52 --- /dev/null +++ b/lib/server/domain/credential-expiry-notifications.ts @@ -0,0 +1,239 @@ +import { getActiveConfig } from './config'; +import { getCredentialExpiry, readCredentialRecords } from './credentials'; +import { readStorageJson, writeStorageJson } from '../storage'; + +export const BARK_URL = 'https://api.day.app/push'; + +export const CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS = [ + { key: 'three_days', seconds: 3 * 24 * 60 * 60, label: '还有 3 天' }, + { key: 'one_day', seconds: 24 * 60 * 60, label: '还有 1 天' }, + { key: 'expired', seconds: 0, label: '已过期' }, +] as const; + +type ExpiryNotificationKey = + (typeof CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS)[number]['key']; + +interface ExpiryNotificationState { + expiresAt: number; + sent: Partial>; +} + +type ExpiryNotificationStates = Record; + +const STATE_FILENAME = 'expiry_notifications.json'; +const TICK_INTERVAL_MS = 30 * 60 * 1000; +const START_DELAY_MS = 10 * 1000; + +const globalSchedulerState = globalThis as typeof globalThis & { + __codebuddy2apiCredentialExpiryNotifications__?: { + timer: ReturnType; + }; +}; + +let stateMutationQueue: Promise = Promise.resolve(); + +const loadNotificationStates = async (): Promise => { + const saved = await readStorageJson('credentials', STATE_FILENAME); + + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return {}; + + return Object.entries( + saved as Record, + ).reduce((result, [filename, value]) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return result; + } + + const expiresAt = Number((value as Record).expiresAt); + const sent = (value as Record).sent; + + if (!Number.isFinite(expiresAt) || !sent || typeof sent !== 'object') { + return result; + } + + result[filename] = { + expiresAt, + sent: Object.fromEntries( + CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS.filter(({ key }) => + Boolean((sent as Record)[key]), + ).map(({ key }) => [key, true]), + ), + }; + return result; + }, {}); +}; + +const saveNotificationStates = async ( + states: ExpiryNotificationStates, +): Promise => { + await writeStorageJson('credentials', STATE_FILENAME, states); +}; + +const updateNotificationStates = async ( + mutation: (states: ExpiryNotificationStates) => void, +): Promise => { + const operation = stateMutationQueue.then(async () => { + const states = await loadNotificationStates(); + mutation(states); + await saveNotificationStates(states); + return states; + }); + stateMutationQueue = operation.then( + () => undefined, + () => undefined, + ); + return operation; +}; + +export const sendBarkNotification = async ({ + body, + token, + title, +}: { + body: string; + token: string; + title: string; +}): Promise => { + const normalizedToken = token.trim(); + + if (!normalizedToken) { + throw new Error('Bark token is empty'); + } + + const response = await fetch(BARK_URL, { + body: JSON.stringify({ + body, + device_key: normalizedToken, + group: 'CodeBuddy2API', + title, + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new Error(`Bark returned ${response.status}`); + } +}; + +const credentialName = (credential: { + data: Record; + filename: string; +}): string => { + const userInfo = credential.data.user_info; + const email = + userInfo && typeof userInfo === 'object' + ? (userInfo as Record).email + : undefined; + + return String(email ?? credential.data.user_id ?? credential.filename); +}; + +export const runCredentialExpiryNotificationTick = async ( + now: Date = new Date(), +): Promise => { + const config = await getActiveConfig(); + const token = config.CODEBUDDY_BARK_TOKEN.trim(); + + if (!config.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED || !token) { + return []; + } + + const states = await loadNotificationStates(); + const nowSeconds = Math.floor(now.getTime() / 1000); + const sentNotifications: string[] = []; + + for (const credential of await readCredentialRecords()) { + const expiresAt = getCredentialExpiry(credential.data); + + if (expiresAt === null) continue; + + const currentState = states[credential.filename]; + const state: ExpiryNotificationState = + currentState?.expiresAt === expiresAt + ? currentState + : { expiresAt, sent: {} }; + const remaining = expiresAt - nowSeconds; + + const threshold = + remaining <= 0 + ? CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS.find( + ({ key }) => key === 'expired', + ) + : remaining <= 24 * 60 * 60 + ? CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS.find( + ({ key }) => key === 'one_day', + ) + : remaining <= 3 * 24 * 60 * 60 + ? CREDENTIAL_EXPIRY_NOTIFICATION_THRESHOLDS.find( + ({ key }) => key === 'three_days', + ) + : undefined; + + if (!threshold || state.sent[threshold.key]) continue; + + const name = credentialName(credential); + + try { + await sendBarkNotification({ + body: `凭据 ${name} ${threshold.label}。过期时间:${new Date( + expiresAt * 1000, + ).toLocaleString()}`, + title: 'CodeBuddy2API 凭据提醒', + token, + }); + state.sent[threshold.key] = true; + states[credential.filename] = state; + await updateNotificationStates((saved) => { + saved[credential.filename] = state; + }); + sentNotifications.push(`${credential.filename}:${threshold.key}`); + } catch (error) { + console.warn( + `[CodeBuddy2API] Bark credential expiry notification failed for ${credential.filename}`, + error, + ); + } + } + + return sentNotifications; +}; + +export const startCredentialExpiryNotificationScheduler = (): void => { + if (globalSchedulerState.__codebuddy2apiCredentialExpiryNotifications__) { + return; + } + + const timer = setInterval(() => { + void runCredentialExpiryNotificationTick().catch((error) => { + console.warn( + '[CodeBuddy2API] Credential expiry notification tick failed', + error, + ); + }); + }, TICK_INTERVAL_MS); + timer.unref?.(); + globalSchedulerState.__codebuddy2apiCredentialExpiryNotifications__ = { + timer, + }; + + setTimeout(() => { + void runCredentialExpiryNotificationTick().catch((error) => { + console.warn( + '[CodeBuddy2API] Credential expiry notification tick failed', + error, + ); + }); + }, START_DELAY_MS).unref?.(); +}; + +export const stopCredentialExpiryNotificationScheduler = (): void => { + const state = + globalSchedulerState.__codebuddy2apiCredentialExpiryNotifications__; + + if (!state) return; + + clearInterval(state.timer); + delete globalSchedulerState.__codebuddy2apiCredentialExpiryNotifications__; +}; diff --git a/lib/server/domain/credentials.ts b/lib/server/domain/credentials.ts index 50eecb78..a0dcbef7 100644 --- a/lib/server/domain/credentials.ts +++ b/lib/server/domain/credentials.ts @@ -399,7 +399,9 @@ export const findEligibleCredentialRecordByFilename = async ( ); }; -const getCredentialExpiry = (credential: CredentialData): number | null => { +export const getCredentialExpiry = ( + credential: CredentialData, +): number | null => { if ( typeof credential.created_at !== 'number' || typeof credential.expires_in !== 'number' diff --git a/messages/en-US.json b/messages/en-US.json index c67562c7..8fa3e431 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -460,6 +460,15 @@ }, "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.", + "credentialExpiryNotificationsEnabled": "Enable credential expiry notifications", + "barkToken": "Bark token", + "barkTokenPlaceholder": "Enter your Bark token", + "barkLink": "Open the Bark website to get a token", + "testBark": "Send test notification", + "testBarkSuccess": "Test notification sent.", + "testBarkFailed": "Test notification failed.", "clearUsageEvents": "Clear usage event cache", "confirmClearUsageEvents": "Clear all stored usage events?", "helper": "The passkey RP ID should usually match the final site domain. HTTPS works directly; localhost is commonly allowed for local development, while other non-HTTPS domains usually cannot use passkeys.", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 58b0ceae..418f5227 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -460,6 +460,15 @@ }, "settingsPanel": { "apiTimeoutHint": "リクエスト送信からアップストリームが最初の delta を返すまでの時間です。出力が始まった後は、長い応答でも完了まで許可されます。0.1〜1440 の範囲で小数分を指定できます。", + "credentialExpiryNotificationsTitle": "認証情報の有効期限通知", + "credentialExpiryNotificationsDescription": "認証情報の有効期限が 3 日前、1 日前、または期限切れになったときに Bark で通知します。サービスの実行中に確認します。", + "credentialExpiryNotificationsEnabled": "認証情報の有効期限通知を有効にする", + "barkToken": "Bark トークン", + "barkTokenPlaceholder": "Bark トークンを入力", + "barkLink": "Bark のサイトでトークンを取得", + "testBark": "テスト通知を送信", + "testBarkSuccess": "テスト通知を送信しました。", + "testBarkFailed": "テスト通知の送信に失敗しました。", "clearUsageEvents": "使用量イベントキャッシュを削除", "confirmClearUsageEvents": "保存済みの使用量イベントをすべて削除しますか?", "helper": "Passkey の RP ID は通常、最終的にアクセスするドメインと一致させます。HTTPS ではそのまま利用でき、localhost はローカル開発で使えることが多い一方、それ以外の非 HTTPS ドメインでは通常 passkey を利用できません。", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index bc0dc505..03b3a4c6 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -460,6 +460,15 @@ }, "settingsPanel": { "apiTimeoutHint": "从发起请求开始计时,直到上游返回第一个 delta。一旦开始输出,即使回答较长也会允许其完成。支持小数分钟,取值范围 0.1~1440。", + "credentialExpiryNotificationsTitle": "凭据过期通知", + "credentialExpiryNotificationsDescription": "凭据将在还有 3 天、还有 1 天和已过期时通过 Bark 通知。通知服务只会在服务运行时检查。", + "credentialExpiryNotificationsEnabled": "启用凭据过期通知", + "barkToken": "Bark Token", + "barkTokenPlaceholder": "输入 Bark Token", + "barkLink": "打开 Bark 官网获取 Token", + "testBark": "发送测试通知", + "testBarkSuccess": "测试通知已发送。", + "testBarkFailed": "测试通知发送失败。", "clearUsageEvents": "清空用量统计缓存", "confirmClearUsageEvents": "确认清空所有已保存的用量统计记录吗?", "helper": "Passkey RP ID 通常应与最终访问域名一致。HTTPS 站点可直接使用;localhost 在现代浏览器中通常可用于本地调试,其它非 HTTPS 域名通常无法使用 passkey。", diff --git a/tests/server/credential-expiry-notifications.test.ts b/tests/server/credential-expiry-notifications.test.ts new file mode 100644 index 00000000..b71fbf26 --- /dev/null +++ b/tests/server/credential-expiry-notifications.test.ts @@ -0,0 +1,278 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + addCredential, + listCredentials, + resetCredentialRuntimeState, +} from '@/lib/server/domain/credentials'; +import { getActiveConfig, updateSettings } from '@/lib/server/domain/config'; +import { resetStorageRuntime, writeStorageJson } from '@/lib/server/storage'; + +const tempRootDir = path.join(process.cwd(), '.tmp-test-expiry-notifications'); +const now = new Date(Math.floor(Date.now() / 1000) * 1000); +const nowSeconds = Math.floor(now.getTime() / 1000); +const DAY = 24 * 60 * 60; + +const send = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise +>(async () => new Response('{}', { status: 200 })); +const authError = vi.hoisted(() => + vi.fn<(request: Request) => Promise>(async () => null), +); + +vi.mock('@/lib/server/admin/session', () => ({ + getAdminSessionErrorResponse: authError, +})); + +vi.stubGlobal('fetch', send); + +const { + runCredentialExpiryNotificationTick, + sendBarkNotification, + startCredentialExpiryNotificationScheduler, + stopCredentialExpiryNotificationScheduler, +} = await import('@/lib/server/domain/credential-expiry-notifications'); +const BarkTestRoute = + await import('@/app/admin-api/notifications/bark/test/route'); + +const cleanup = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true, maxRetries: 5 }); +}; + +const seed = async ( + filename: string, + expiresIn: number, + data: Record = {}, +): Promise => { + await addCredential( + { + bearer_token: 'token', + created_at: nowSeconds, + expires_in: expiresIn, + user_id: 'tester@example.com', + ...data, + }, + filename, + ); +}; + +describe('credential expiry notifications', () => { + beforeEach(async () => { + cleanup(); + resetCredentialRuntimeState(); + resetStorageRuntime(); + send.mockReset(); + send.mockResolvedValue(new Response('{}', { status: 200 })); + authError.mockReset(); + authError.mockResolvedValue(null); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + delete process.env.CODEBUDDY_BARK_TOKEN; + delete process.env.CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED; + await updateSettings({ + CODEBUDDY_BARK_TOKEN: 'bark-token', + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: true, + }); + }); + + afterEach(() => { + stopCredentialExpiryNotificationScheduler(); + cleanup(); + resetCredentialRuntimeState(); + resetStorageRuntime(); + }); + + it('does nothing when notifications are disabled or unconfigured', async () => { + await seed('disabled.json', 3 * DAY); + await updateSettings({ + CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED: false, + }); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([]); + expect(send).not.toHaveBeenCalled(); + }); + + it('sends each threshold once and persists the sent state', async () => { + await seed('three-days.json', 3 * DAY - 5); + expect((await getActiveConfig()).CODEBUDDY_BARK_TOKEN).toBe('bark-token'); + expect( + (await getActiveConfig()) + .CODEBUDDY_CREDENTIAL_EXPIRY_NOTIFICATIONS_ENABLED, + ).toBe(true); + expect((await listCredentials()).credentials).toMatchObject([ + { expires_in: 3 * DAY - 5, time_remaining: 3 * DAY - 5 }, + ]); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([ + 'three-days.json:three_days', + ]); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0]?.[0]).toBe('https://api.day.app/push'); + expect(JSON.parse(String(send.mock.calls[0]?.[1]?.body))).toMatchObject({ + device_key: 'bark-token', + title: 'CodeBuddy2API 凭据提醒', + }); + console.log( + 'STATE1', + fs.readFileSync( + path.join(tempRootDir, '.codebuddy_creds', 'expiry_notifications.json'), + 'utf8', + ), + ); + + send.mockClear(); + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([]); + expect(send).not.toHaveBeenCalled(); + + const oneDay = new Date(now.getTime() + (2 * DAY + 60) * 1000); + await expect(runCredentialExpiryNotificationTick(oneDay)).resolves.toEqual([ + 'three-days.json:one_day', + ]); + const expired = new Date(now.getTime() + (3 * DAY + 60) * 1000); + await expect(runCredentialExpiryNotificationTick(expired)).resolves.toEqual( + ['three-days.json:expired'], + ); + }); + + it('uses the nearest threshold after a missed check', async () => { + await seed('missed.json', DAY - 1); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([ + 'missed.json:one_day', + ]); + }); + + it('ignores malformed state and credentials without expiry data', async () => { + await writeStorageJson('credentials', 'expiry_notifications.json', { + invalid: { expiresAt: 'not-a-number', sent: {} }, + malformed: null, + }); + await writeStorageJson('credentials', 'missing-expiry.json', { + bearer_token: 'token', + }); + await writeStorageJson('credentials', 'filename-fallback.json', { + bearer_token: 'token', + created_at: nowSeconds, + expires_in: DAY - 1, + }); + await seed('email.json', 3 * DAY - 5, { + user_info: { email: 'email@example.com' }, + }); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([ + 'email.json:three_days', + 'filename-fallback.json:one_day', + ]); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('retries a failed Bark request on the next tick', async () => { + await seed('retry.json', 3 * DAY - 5); + send.mockRejectedValueOnce(new Error('network down')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([]); + expect(warn).toHaveBeenCalled(); + + send.mockResolvedValueOnce(new Response('{}', { status: 200 })); + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([ + 'retry.json:three_days', + ]); + }); + + it('recovers when persisting notification state fails', async () => { + await seed('state-error.json', 3 * DAY - 5); + const storage = await import('@/lib/server/storage'); + const write = vi + .spyOn(storage, 'writeStorageJson') + .mockRejectedValueOnce(new Error('storage down')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await expect(runCredentialExpiryNotificationTick(now)).resolves.toEqual([]); + expect(write).toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); + + it('sends a test notification and rejects an empty token', async () => { + await expect( + sendBarkNotification({ body: 'hello', title: 'test', token: ' ' }), + ).rejects.toThrow('Bark token is empty'); + + await expect( + sendBarkNotification({ body: 'hello', title: 'test', token: ' token ' }), + ).resolves.toBeUndefined(); + }); + + it('serves the Bark test notification endpoint', async () => { + const missing = await BarkTestRoute.POST( + new Request('http://localhost/admin-api/notifications/bark/test', { + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + ); + expect(missing.status).toBe(400); + + const success = await BarkTestRoute.POST( + new Request('http://localhost/admin-api/notifications/bark/test', { + body: JSON.stringify({ token: 'token' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + ); + expect(success.status).toBe(200); + + send.mockResolvedValueOnce(new Response('{}', { status: 500 })); + const failed = await BarkTestRoute.POST( + new Request('http://localhost/admin-api/notifications/bark/test', { + body: JSON.stringify({ token: 'token' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + ); + expect(failed.status).toBe(502); + + authError.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }), + ); + const unauthorized = await BarkTestRoute.POST( + new Request('http://localhost/admin-api/notifications/bark/test', { + body: JSON.stringify({ token: 'token' }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }), + ); + expect(unauthorized.status).toBe(401); + }); + + it('starts only one scheduler and can be stopped', () => { + const interval = vi.spyOn(globalThis, 'setInterval'); + const clear = vi.spyOn(globalThis, 'clearInterval'); + + startCredentialExpiryNotificationScheduler(); + startCredentialExpiryNotificationScheduler(); + + expect(interval).toHaveBeenCalledTimes(1); + stopCredentialExpiryNotificationScheduler(); + expect(clear).toHaveBeenCalledTimes(1); + stopCredentialExpiryNotificationScheduler(); + }); + + it('runs the scheduled sweep after startup and on the interval', async () => { + vi.useFakeTimers(); + const config = await import('@/lib/server/domain/config'); + const getConfig = vi + .spyOn(config, 'getActiveConfig') + .mockRejectedValue(new Error('config unavailable')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + startCredentialExpiryNotificationScheduler(); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 10_000); + expect(warn).toHaveBeenCalled(); + } finally { + stopCredentialExpiryNotificationScheduler(); + getConfig.mockRestore(); + vi.useRealTimers(); + } + }); +}); From 84ea1556496546ccc775add35f5e08c8ac32643d Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Mon, 26 Oct 2026 23:08:50 +0800 Subject: [PATCH 4/4] fix: avoid reloading paused local console --- electron/main.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/electron/main.ts b/electron/main.ts index d1819d3f..538c7390 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1006,7 +1006,12 @@ const writeConsolePreferences = async (cookies: Cookie[]): Promise => { refreshMenus(); - if (mainWindow && !mainWindow.isDestroyed() && consoleOrigin) { + if ( + mainWindow && + !mainWindow.isDestroyed() && + consoleOrigin && + (backend.mode === 'remote' || gateway !== null) + ) { mainWindow.reload(); } };