cc-haha/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts
程序员阿江(Relakkes) fa5fda24d0 feat: notify users when desktop attention is needed
Add native system notifications as a desktop-wide attention channel for permission prompts and scheduled task completion. The implementation keeps notification presentation owned by the OS, adds a user-facing enable switch with permission handling, and lets scheduled tasks choose desktop notifications without routing that channel through IM adapters.

Constraint: Notifications must use OS-native APIs without custom sound playback.
Constraint: Desktop channel is local-only and must not be sent through IM adapter delivery.
Rejected: Browser Notification API | not reliable inside the packaged Tauri desktop runtime.
Rejected: Treat desktop as an IM channel | would leak a local-only channel into server-side adapter sending.
Confidence: high
Scope-risk: moderate
Directive: Keep notification styling at the OS layer; business code should only provide title, body, dedupe, and routing decisions.
Tested: bun run check:desktop
Tested: bun run quality:pr
Tested: Computer Use macOS debug app verification for settings toggle, permission prompt, scheduled task desktop channel, and task-run polling dedupe
Not-tested: Windows and Linux native runtime smoke tests on physical hosts
2026-05-03 16:45:32 +08:00

118 lines
3.5 KiB
TypeScript

import { useEffect } from 'react'
import { tasksApi } from '../api/tasks'
import { notifyDesktop } from '../lib/desktopNotifications'
import type { CronTask, TaskRun } from '../types/task'
const POLL_INTERVAL_MS = 30_000
const NOTIFIED_RUNS_STORAGE_KEY = 'cc-haha.notifiedDesktopTaskRuns.v1'
const MAX_STORED_RUN_IDS = 200
function isTerminalRun(run: TaskRun): boolean {
return run.status === 'completed' || run.status === 'failed' || run.status === 'timeout'
}
function hasDesktopNotification(task: CronTask | undefined): boolean {
return !!task?.notification?.enabled && task.notification.channels.includes('desktop')
}
function readNotifiedRunIds(): Set<string> {
try {
const raw = localStorage.getItem(NOTIFIED_RUNS_STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : []
return new Set(Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [])
} catch {
return new Set()
}
}
function writeNotifiedRunIds(runIds: Set<string>): void {
try {
const trimmed = [...runIds].slice(-MAX_STORED_RUN_IDS)
localStorage.setItem(NOTIFIED_RUNS_STORAGE_KEY, JSON.stringify(trimmed))
} catch {
// Notification dedupe is best-effort; storage failures should not break the app.
}
}
function formatTaskRunNotification(run: TaskRun): { title: string; body: string } {
const status = run.status === 'completed'
? '完成'
: run.status === 'failed'
? '失败'
: '超时'
const detail = run.error || run.output || run.prompt
const body = detail
? `${status}: ${detail.slice(0, 160)}`
: `状态: ${status}`
return {
title: `定时任务 ${run.taskName || run.taskId}`,
body,
}
}
export function collectDesktopNotifiableRuns(
tasks: CronTask[],
runs: TaskRun[],
notifiedRunIds: Set<string>,
): TaskRun[] {
const taskById = new Map(tasks.map((task) => [task.id, task]))
return runs
.filter((run) => isTerminalRun(run))
.filter((run) => hasDesktopNotification(taskById.get(run.taskId)))
.filter((run) => !notifiedRunIds.has(run.id))
.sort((a, b) => Date.parse(a.completedAt ?? a.startedAt) - Date.parse(b.completedAt ?? b.startedAt))
}
export function useScheduledTaskDesktopNotifications(): void {
useEffect(() => {
let stopped = false
let initialized = false
const poll = async () => {
try {
const [{ tasks }, { runs }] = await Promise.all([
tasksApi.list(),
tasksApi.getRecentRuns(50),
])
if (stopped) return
const notifiedRunIds = readNotifiedRunIds()
const pendingRuns = collectDesktopNotifiableRuns(tasks, runs, notifiedRunIds)
if (!initialized) {
for (const run of pendingRuns) notifiedRunIds.add(run.id)
writeNotifiedRunIds(notifiedRunIds)
initialized = true
return
}
for (const run of pendingRuns) {
const notification = formatTaskRunNotification(run)
notifyDesktop({
dedupeKey: `scheduled-task:${run.id}`,
title: notification.title,
body: notification.body,
})
notifiedRunIds.add(run.id)
}
writeNotifiedRunIds(notifiedRunIds)
} catch (err) {
if (typeof console !== 'undefined') {
console.warn('[scheduledTaskNotifications] failed to poll task runs:', err)
}
}
}
void poll()
const interval = window.setInterval(() => {
void poll()
}, POLL_INTERVAL_MS)
return () => {
stopped = true
window.clearInterval(interval)
}
}, [])
}