import { ipcRenderer } from 'electron' import type { NotificationDeliveryProbeResult, NotificationDismissResult, NotificationDispatchResult, NotificationPermissionStatusResult, NotificationSoundDataResult, NotificationSoundPathResult, NotificationSoundResult, StructuredNotificationRead } from '../../shared/notification-settings-types' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { PreloadApi } from '../api-types' // Why: cache one shared Audio + blob URL per sound path so notifications do not re-read large files. let cachedNotificationSound: { path: string blobUrl: string audio: HTMLAudioElement } | null = null function disposeCachedNotificationSound(): void { if (cachedNotificationSound) { cachedNotificationSound.audio.pause() cachedNotificationSound.audio.src = '' URL.revokeObjectURL(cachedNotificationSound.blobUrl) cachedNotificationSound = null } } export const notificationsApi = { getDesktopAwayState: (): Promise => ipcRenderer.invoke('notifications:getDesktopAwayState'), dispatch: (args: Record): Promise => ipcRenderer.invoke('notifications:dispatch', args), dismiss: ( ids: string[], paneKeys?: string[], reads?: StructuredNotificationRead[] ): Promise => reads === undefined ? ipcRenderer.invoke('notifications:dismiss', ids, paneKeys) : ipcRenderer.invoke('notifications:dismiss', ids, paneKeys, reads), settleStructuredPrompts: ( scope: AgentSessionExecutionLocation, sessionId: string ): Promise => ipcRenderer.invoke('notifications:settleStructuredPrompts', scope, sessionId), openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), getPermissionStatus: (): Promise => ipcRenderer.invoke('notifications:getPermissionStatus'), probeDelivery: (args?: { force?: boolean }): Promise => ipcRenderer.invoke('notifications:probeDelivery', args), playSound: async (options?: { force?: boolean volume?: number }): Promise => { try { const resolved = (await ipcRenderer.invoke( 'notifications:resolveSoundPath' )) as NotificationSoundPathResult if (!resolved.ok) { if (cachedNotificationSound) { disposeCachedNotificationSound() } return { played: false, reason: resolved.reason } } let entry = cachedNotificationSound if (!entry || entry.path !== resolved.path) { const sound = (await ipcRenderer.invoke( 'notifications:loadSound' )) as NotificationSoundDataResult if (!sound.ok) { disposeCachedNotificationSound() return { played: false, reason: sound.reason } } // Why: a concurrent playSound may have cached the same path while this load was in flight. const latestEntry = cachedNotificationSound if (latestEntry?.path === sound.path) { entry = latestEntry } else { const arrayBuffer = new ArrayBuffer(sound.data.byteLength) new Uint8Array(arrayBuffer).set(sound.data) const blob = new Blob([arrayBuffer], { type: sound.mimeType }) disposeCachedNotificationSound() const blobUrl = URL.createObjectURL(blob) entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } cachedNotificationSound = entry } } const audio = entry.audio // Why: restart from zero on each play so bursts replay instead of stacking copies (GNOME canberra / VS Code signal service). audio.currentTime = 0 if (typeof options?.volume === 'number' && Number.isFinite(options.volume)) { audio.volume = Math.min(1, Math.max(0, options.volume / 100)) } try { await audio.play() } catch { return { played: false, reason: 'playback-failed' } } return { played: true } } catch { return { played: false, reason: 'playback-failed' } } } } satisfies PreloadApi['notifications']