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) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 22:01:38 +08:00
parent 1fab7b1e5f
commit 5c3f2c7822
5 changed files with 193 additions and 13 deletions
+5 -1
View File
@@ -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()
@@ -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 {
@@ -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()
})
})
@@ -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<string, number>()
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
}
}
+31 -4
View File
@@ -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