mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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
118 lines
3.5 KiB
TypeScript
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)
|
|
}
|
|
}, [])
|
|
}
|