From 5c3f2c782217e5f798b5be1809456b161384a2d2 Mon Sep 17 00:00:00 2001 From: liangxu Date: Tue, 14 Jul 2026 22:01:38 +0800 Subject: [PATCH] feat(desktop): surface runtime alerts as system notifications Add a runtime system notifier that pushes danger-severity activity (and forced account-access alerts) to the OS via the tray balloon on Windows, falling back to Electron Notification elsewhere, with a 30s per-key dedupe window. Route risk-control / human-verification account alerts through it so users are notified when an account needs attention. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop-client/src/main/bootstrap.ts | 6 +- .../src/main/runtime-controller.ts | 26 +++++-- .../main/runtime-system-notification.test.ts | 75 +++++++++++++++++++ .../src/main/runtime-system-notification.ts | 64 ++++++++++++++++ apps/desktop-client/src/main/tray.ts | 35 ++++++++- 5 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 apps/desktop-client/src/main/runtime-system-notification.test.ts create mode 100644 apps/desktop-client/src/main/runtime-system-notification.ts diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 950f615..9a596bc 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -67,6 +67,7 @@ import { } from './runtime-controller' import { onRuntimeInvalidated } from './runtime-events' import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from './runtime-snapshot' +import { configureRuntimeSystemNotifier } from './runtime-system-notification' import { initScheduler } from './scheduler' import { initSessionRegistry } from './session-registry' import { initSingleInstance } from './single-instance' @@ -86,7 +87,7 @@ import { rotateDesktopClient, setDesktopUnauthorizedRecoveryHandler, } from './transport/api-client' -import { initTray, showTrayBalloon } from './tray' +import { initTray, showSystemNotification, showTrayBalloon } from './tray' import { STANDARD_USER_AGENT } from './user-agent' import { startHotViewReaper } from './view-pool' @@ -1374,6 +1375,9 @@ if (!hasSingleInstanceLock) { await initSessionRegistry() initAccountHealth() initTransport() + configureRuntimeSystemNotifier(({ title, body }) => { + showSystemNotification(title, body) + }) setDesktopUnauthorizedRecoveryHandler(requestDesktopAuthRecovery) initScheduler() startStorageCleanupScheduler() diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index d12d992..b3b3c39 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -106,6 +106,7 @@ import { selectNextPublishTask, } from './publish-scheduler' import { emitRuntimeInvalidated, onRuntimeInvalidated } from './runtime-events' +import { publishRuntimeSystemNotification } from './runtime-system-notification' import { initScheduler, noteSchedulerAccountsSync, @@ -638,13 +639,23 @@ function maybeRecordAccountAccessAlert( return } + const recordAccountAlert = (title: string, detail: string): void => { + recordActivity('warn', title, detail) + publishRuntimeSystemNotification({ + severity: 'warn', + title, + detail, + force: true, + dedupeKey: `account-access:${accountIdentity.id}:${next.authState}:${next.authReason ?? ''}`, + }) + } + if (next.authState === 'challenge_required' && next.authReason === 'risk_control') { if (accountIdentity.platform === 'baijiahao') { - recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT) + recordAccountAlert('百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT) return } - recordActivity( - 'warn', + recordAccountAlert( `${runtimePlatformLabel(accountIdentity.platform)} 触发风控`, `${accountIdentity.displayName || task.accountName || '当前账号'} 已被平台限制,请打开平台处理验证或等待限制解除后再刷新状态。`, ) @@ -653,19 +664,17 @@ function maybeRecordAccountAccessAlert( if (next.authState === 'challenge_required') { if (accountIdentity.platform === 'baijiahao') { - recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT) + recordAccountAlert('百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT) return } if (accountIdentity.platform === 'doubao') { - recordActivity( - 'warn', + recordAccountAlert( '豆包需要人工验证', `${accountIdentity.displayName || task.accountName || '当前账号'} 触发豆包人机验证,请打开豆包完成验证码后再刷新状态。`, ) return } - recordActivity( - 'warn', + recordAccountAlert( `${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`, `${accountIdentity.displayName || task.accountName || '当前账号'} 需要打开平台完成人工验证后再继续执行任务。`, ) @@ -3793,6 +3802,7 @@ function recordActivity(severity: RuntimeTone, title: string, detail: string): v if (state.activity.length > maxActivityItems) { state.activity.length = maxActivityItems } + publishRuntimeSystemNotification({ severity, title, detail }) } function syncSchedulerSurface(): void { diff --git a/apps/desktop-client/src/main/runtime-system-notification.test.ts b/apps/desktop-client/src/main/runtime-system-notification.test.ts new file mode 100644 index 0000000..b26009d --- /dev/null +++ b/apps/desktop-client/src/main/runtime-system-notification.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + configureRuntimeSystemNotifier, + publishRuntimeSystemNotification, + resetRuntimeSystemNotificationState, +} from './runtime-system-notification' + +describe('runtime system notifications', () => { + beforeEach(() => { + resetRuntimeSystemNotificationState() + }) + + it('notifies danger activities while leaving ordinary warnings in the activity feed', () => { + const notifier = vi.fn() + configureRuntimeSystemNotifier(notifier) + + expect( + publishRuntimeSystemNotification({ + severity: 'warn', + title: '任务分发通道异常', + detail: '正在自动重连。', + now: 1_000, + }), + ).toBe(false) + expect( + publishRuntimeSystemNotification({ + severity: 'danger', + title: '监控任务执行失败', + detail: '豆包采集失败。', + now: 1_001, + }), + ).toBe(true) + expect(notifier).toHaveBeenCalledOnce() + expect(notifier).toHaveBeenCalledWith({ + title: '监控任务执行失败', + body: '豆包采集失败。', + }) + }) + + it('always notifies challenged accounts, deduping each account independently', () => { + const notifier = vi.fn() + configureRuntimeSystemNotifier(notifier) + + const challenge = (accountId: string, now: number) => + publishRuntimeSystemNotification({ + severity: 'warn', + title: '豆包需要人工验证', + detail: `${accountId} 触发豆包人机验证。`, + force: true, + dedupeKey: `account-challenge:${accountId}`, + now, + }) + + expect(challenge('doubao-a', 1_000)).toBe(true) + expect(challenge('doubao-a', 1_001)).toBe(false) + expect(challenge('doubao-b', 1_002)).toBe(true) + expect(notifier).toHaveBeenCalledTimes(2) + }) + + it('does not let a native notification failure interrupt runtime execution', () => { + configureRuntimeSystemNotifier(() => { + throw new Error('native notification unavailable') + }) + + expect(() => + publishRuntimeSystemNotification({ + severity: 'danger', + title: '系统错误', + detail: '通知通道异常。', + now: 1_000, + }), + ).not.toThrow() + }) +}) diff --git a/apps/desktop-client/src/main/runtime-system-notification.ts b/apps/desktop-client/src/main/runtime-system-notification.ts new file mode 100644 index 0000000..da5663c --- /dev/null +++ b/apps/desktop-client/src/main/runtime-system-notification.ts @@ -0,0 +1,64 @@ +export type RuntimeSystemNotification = { + title: string + body: string +} + +export type RuntimeSystemNotificationSeverity = 'info' | 'success' | 'warn' | 'danger' + +type RuntimeSystemNotifier = (notification: RuntimeSystemNotification) => void + +type PublishRuntimeSystemNotificationOptions = { + severity: RuntimeSystemNotificationSeverity + title: string + detail: string + force?: boolean + dedupeKey?: string + now?: number +} + +const notificationDedupeWindowMs = 30_000 +const lastNotificationAt = new Map() +let runtimeSystemNotifier: RuntimeSystemNotifier | null = null + +export function configureRuntimeSystemNotifier(notifier: RuntimeSystemNotifier | null): void { + runtimeSystemNotifier = notifier +} + +export function resetRuntimeSystemNotificationState(): void { + runtimeSystemNotifier = null + lastNotificationAt.clear() +} + +export function publishRuntimeSystemNotification( + options: PublishRuntimeSystemNotificationOptions, +): boolean { + if (!options.force && options.severity !== 'danger') { + return false + } + + const title = options.title.trim() + const body = options.detail.trim() + if (!runtimeSystemNotifier || !title || !body) { + return false + } + + const now = options.now ?? Date.now() + const dedupeKey = options.dedupeKey?.trim() || `${title}\n${body}` + const previousNotificationAt = lastNotificationAt.get(dedupeKey) ?? 0 + if (previousNotificationAt > 0 && now - previousNotificationAt < notificationDedupeWindowMs) { + return false + } + + lastNotificationAt.set(dedupeKey, now) + try { + runtimeSystemNotifier({ title, body }) + return true + } catch (error) { + lastNotificationAt.delete(dedupeKey) + console.warn('[desktop-runtime] system notification failed', { + title, + message: error instanceof Error ? error.message : String(error), + }) + return false + } +} diff --git a/apps/desktop-client/src/main/tray.ts b/apps/desktop-client/src/main/tray.ts index d75aeff..6903320 100644 --- a/apps/desktop-client/src/main/tray.ts +++ b/apps/desktop-client/src/main/tray.ts @@ -1,6 +1,6 @@ import { nativeImage } from 'electron/common' import type { Tray as ElectronTray } from 'electron/main' -import { Menu, Tray } from 'electron/main' +import { Menu, Notification, Tray } from 'electron/main' import { TRAY_DANGER_ICON_BASE64, TRAY_ICON_BASE64, TRAY_WINDOWS_ICON_BASE64 } from './brand-icons' @@ -52,12 +52,12 @@ function formatIssueCount(count: number): string { // Windows-only: tray.displayBalloon shows a system toast next to the tray // icon. macOS and Linux ignore this call (Electron returns silently), so the // guard here is just to avoid the no-op log noise on non-Windows. -export function showTrayBalloon(title: string, content: string): void { +function displayTrayBalloon(title: string, content: string): boolean { if (process.platform !== 'win32') { - return + return false } if (!tray || tray.isDestroyed()) { - return + return false } try { tray.displayBalloon({ @@ -66,11 +66,38 @@ export function showTrayBalloon(title: string, content: string): void { icon: createTrayIcon('normal'), iconType: 'custom', }) + return true } catch (error) { console.warn('[desktop-tray] displayBalloon failed', error) + return false } } +export function showTrayBalloon(title: string, content: string): void { + displayTrayBalloon(title, content) +} + +export function showSystemNotification(title: string, body: string): void { + const normalizedTitle = title.trim() + const normalizedBody = body.trim() + if (!normalizedTitle || !normalizedBody) { + return + } + if (displayTrayBalloon(normalizedTitle, normalizedBody)) { + return + } + if (!Notification.isSupported()) { + return + } + + const notification = new Notification({ + title: normalizedTitle, + body: normalizedBody, + silent: false, + }) + notification.show() +} + export function updateTrayIssueIndicator(issueCount: number): void { if (!tray || tray.isDestroyed()) { return