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'
|
} from './runtime-controller'
|
||||||
import { onRuntimeInvalidated } from './runtime-events'
|
import { onRuntimeInvalidated } from './runtime-events'
|
||||||
import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from './runtime-snapshot'
|
import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from './runtime-snapshot'
|
||||||
|
import { configureRuntimeSystemNotifier } from './runtime-system-notification'
|
||||||
import { initScheduler } from './scheduler'
|
import { initScheduler } from './scheduler'
|
||||||
import { initSessionRegistry } from './session-registry'
|
import { initSessionRegistry } from './session-registry'
|
||||||
import { initSingleInstance } from './single-instance'
|
import { initSingleInstance } from './single-instance'
|
||||||
@@ -86,7 +87,7 @@ import {
|
|||||||
rotateDesktopClient,
|
rotateDesktopClient,
|
||||||
setDesktopUnauthorizedRecoveryHandler,
|
setDesktopUnauthorizedRecoveryHandler,
|
||||||
} from './transport/api-client'
|
} from './transport/api-client'
|
||||||
import { initTray, showTrayBalloon } from './tray'
|
import { initTray, showSystemNotification, showTrayBalloon } from './tray'
|
||||||
import { STANDARD_USER_AGENT } from './user-agent'
|
import { STANDARD_USER_AGENT } from './user-agent'
|
||||||
import { startHotViewReaper } from './view-pool'
|
import { startHotViewReaper } from './view-pool'
|
||||||
|
|
||||||
@@ -1374,6 +1375,9 @@ if (!hasSingleInstanceLock) {
|
|||||||
await initSessionRegistry()
|
await initSessionRegistry()
|
||||||
initAccountHealth()
|
initAccountHealth()
|
||||||
initTransport()
|
initTransport()
|
||||||
|
configureRuntimeSystemNotifier(({ title, body }) => {
|
||||||
|
showSystemNotification(title, body)
|
||||||
|
})
|
||||||
setDesktopUnauthorizedRecoveryHandler(requestDesktopAuthRecovery)
|
setDesktopUnauthorizedRecoveryHandler(requestDesktopAuthRecovery)
|
||||||
initScheduler()
|
initScheduler()
|
||||||
startStorageCleanupScheduler()
|
startStorageCleanupScheduler()
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ import {
|
|||||||
selectNextPublishTask,
|
selectNextPublishTask,
|
||||||
} from './publish-scheduler'
|
} from './publish-scheduler'
|
||||||
import { emitRuntimeInvalidated, onRuntimeInvalidated } from './runtime-events'
|
import { emitRuntimeInvalidated, onRuntimeInvalidated } from './runtime-events'
|
||||||
|
import { publishRuntimeSystemNotification } from './runtime-system-notification'
|
||||||
import {
|
import {
|
||||||
initScheduler,
|
initScheduler,
|
||||||
noteSchedulerAccountsSync,
|
noteSchedulerAccountsSync,
|
||||||
@@ -638,13 +639,23 @@ function maybeRecordAccountAccessAlert(
|
|||||||
return
|
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 (next.authState === 'challenge_required' && next.authReason === 'risk_control') {
|
||||||
if (accountIdentity.platform === 'baijiahao') {
|
if (accountIdentity.platform === 'baijiahao') {
|
||||||
recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT)
|
recordAccountAlert('百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordActivity(
|
recordAccountAlert(
|
||||||
'warn',
|
|
||||||
`${runtimePlatformLabel(accountIdentity.platform)} 触发风控`,
|
`${runtimePlatformLabel(accountIdentity.platform)} 触发风控`,
|
||||||
`${accountIdentity.displayName || task.accountName || '当前账号'} 已被平台限制,请打开平台处理验证或等待限制解除后再刷新状态。`,
|
`${accountIdentity.displayName || task.accountName || '当前账号'} 已被平台限制,请打开平台处理验证或等待限制解除后再刷新状态。`,
|
||||||
)
|
)
|
||||||
@@ -653,19 +664,17 @@ function maybeRecordAccountAccessAlert(
|
|||||||
|
|
||||||
if (next.authState === 'challenge_required') {
|
if (next.authState === 'challenge_required') {
|
||||||
if (accountIdentity.platform === 'baijiahao') {
|
if (accountIdentity.platform === 'baijiahao') {
|
||||||
recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT)
|
recordAccountAlert('百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (accountIdentity.platform === 'doubao') {
|
if (accountIdentity.platform === 'doubao') {
|
||||||
recordActivity(
|
recordAccountAlert(
|
||||||
'warn',
|
|
||||||
'豆包需要人工验证',
|
'豆包需要人工验证',
|
||||||
`${accountIdentity.displayName || task.accountName || '当前账号'} 触发豆包人机验证,请打开豆包完成验证码后再刷新状态。`,
|
`${accountIdentity.displayName || task.accountName || '当前账号'} 触发豆包人机验证,请打开豆包完成验证码后再刷新状态。`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordActivity(
|
recordAccountAlert(
|
||||||
'warn',
|
|
||||||
`${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`,
|
`${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`,
|
||||||
`${accountIdentity.displayName || task.accountName || '当前账号'} 需要打开平台完成人工验证后再继续执行任务。`,
|
`${accountIdentity.displayName || task.accountName || '当前账号'} 需要打开平台完成人工验证后再继续执行任务。`,
|
||||||
)
|
)
|
||||||
@@ -3793,6 +3802,7 @@ function recordActivity(severity: RuntimeTone, title: string, detail: string): v
|
|||||||
if (state.activity.length > maxActivityItems) {
|
if (state.activity.length > maxActivityItems) {
|
||||||
state.activity.length = maxActivityItems
|
state.activity.length = maxActivityItems
|
||||||
}
|
}
|
||||||
|
publishRuntimeSystemNotification({ severity, title, detail })
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSchedulerSurface(): void {
|
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 { nativeImage } from 'electron/common'
|
||||||
import type { Tray as ElectronTray } from 'electron/main'
|
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'
|
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
|
// Windows-only: tray.displayBalloon shows a system toast next to the tray
|
||||||
// icon. macOS and Linux ignore this call (Electron returns silently), so the
|
// 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.
|
// 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') {
|
if (process.platform !== 'win32') {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (!tray || tray.isDestroyed()) {
|
if (!tray || tray.isDestroyed()) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
tray.displayBalloon({
|
tray.displayBalloon({
|
||||||
@@ -66,11 +66,38 @@ export function showTrayBalloon(title: string, content: string): void {
|
|||||||
icon: createTrayIcon('normal'),
|
icon: createTrayIcon('normal'),
|
||||||
iconType: 'custom',
|
iconType: 'custom',
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[desktop-tray] displayBalloon failed', 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 {
|
export function updateTrayIssueIndicator(issueCount: number): void {
|
||||||
if (!tray || tray.isDestroyed()) {
|
if (!tray || tray.isDestroyed()) {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user