- Drop all desktop/Electron branches, scripts, and UI (title bar, pets, desktop bridge); LAN endpoint kind is now web/custom only - Replace the bundled hermes CLI with user-side installation: add GET /api/hermes/cli-status plus a HermesMissingView onboarding guide - Remove the version-management UI (snapshots handled by git) and prune dead website/mcu/claude scripts and lockfile entries
104 lines
3.2 KiB
TypeScript
104 lines
3.2 KiB
TypeScript
interface CompletionNotificationPayload {
|
|
title: string
|
|
body?: string
|
|
icon?: string
|
|
tag?: string
|
|
}
|
|
|
|
export interface CompletionNotificationPermissionResult {
|
|
granted: boolean
|
|
reason?: 'unsupported' | 'insecure' | 'denied'
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, fallback: T): Promise<T> {
|
|
return new Promise<T>((resolve) => {
|
|
const timer = window.setTimeout(() => resolve(fallback), timeoutMs)
|
|
promise.then(
|
|
value => {
|
|
window.clearTimeout(timer)
|
|
resolve(value)
|
|
},
|
|
() => {
|
|
window.clearTimeout(timer)
|
|
resolve(fallback)
|
|
},
|
|
)
|
|
})
|
|
}
|
|
|
|
function supportsBrowserNotification(): boolean {
|
|
return typeof window !== 'undefined' && 'Notification' in window
|
|
}
|
|
|
|
function isBrowserNotificationSecureContext(): boolean {
|
|
if (typeof window === 'undefined') return false
|
|
return window.isSecureContext
|
|
}
|
|
|
|
function browserNotificationOptions(payload: CompletionNotificationPayload): NotificationOptions {
|
|
return {
|
|
body: payload.body,
|
|
icon: payload.icon ? new URL(payload.icon, window.location.origin).href : undefined,
|
|
tag: payload.tag,
|
|
}
|
|
}
|
|
|
|
async function showServiceWorkerNotification(payload: CompletionNotificationPayload): Promise<boolean> {
|
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return false
|
|
|
|
try {
|
|
const registration = await withTimeout(
|
|
navigator.serviceWorker.register('/notification-sw.js'),
|
|
3000,
|
|
null,
|
|
)
|
|
if (!registration) return false
|
|
await withTimeout(navigator.serviceWorker.ready, 3000, registration)
|
|
await registration.showNotification(payload.title, browserNotificationOptions(payload))
|
|
return true
|
|
} catch (err) {
|
|
console.warn('Failed to show service worker notification:', err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function requestCompletionNotificationPermission(): Promise<CompletionNotificationPermissionResult> {
|
|
if (!supportsBrowserNotification()) return { granted: false, reason: 'unsupported' }
|
|
if (!isBrowserNotificationSecureContext()) return { granted: false, reason: 'insecure' }
|
|
if (Notification.permission === 'granted') return { granted: true }
|
|
if (Notification.permission === 'denied') return { granted: false, reason: 'denied' }
|
|
|
|
try {
|
|
const permission = await withTimeout(
|
|
Notification.requestPermission(),
|
|
5000,
|
|
'default' as NotificationPermission,
|
|
)
|
|
return permission === 'granted'
|
|
? { granted: true }
|
|
: { granted: false, reason: permission === 'denied' ? 'denied' : 'unsupported' }
|
|
} catch {
|
|
return { granted: false, reason: 'unsupported' }
|
|
}
|
|
}
|
|
|
|
export async function showCompletionNotification(payload: CompletionNotificationPayload): Promise<boolean> {
|
|
if (!supportsBrowserNotification() || !isBrowserNotificationSecureContext() || Notification.permission !== 'granted') {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
if (await showServiceWorkerNotification(payload)) return true
|
|
|
|
const notification = new Notification(payload.title, browserNotificationOptions(payload))
|
|
notification.onclick = () => {
|
|
window.focus()
|
|
notification.close()
|
|
}
|
|
return true
|
|
} catch (err) {
|
|
console.warn('Failed to show browser completion notification:', err)
|
|
return false
|
|
}
|
|
}
|