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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user