mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Add custom desktop notification sounds (#1430)
* Add support for custom desktop notification sounds * perf(notifications): cache custom sound + restart-on-play Avoids re-reading the configured audio file (up to 10MB) from disk and re-transferring it over IPC on every notification. Adds a path-only resolver so repeated dispatches with an unchanged sound skip the heavy load entirely. For burst handling, follows the VS Code AccessibilitySignalService / GNOME canberra pattern: one shared HTMLAudioElement per sound, restarted from t=0 on each play, with an in-flight guard that drops new plays while the sound is still ringing. This self-dedupes by the sound's own duration without any magic time constant — distinct sounds remain free to overlap. The Test button passes force: true so an explicit user action always plays through. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -73,7 +73,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: false,
|
||||
suppressWhenFocused: true
|
||||
suppressWhenFocused: true,
|
||||
customSoundPath: null
|
||||
},
|
||||
promptCacheTimerEnabled: false,
|
||||
promptCacheTtlMs: 300_000,
|
||||
|
||||
@@ -67,7 +67,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: false,
|
||||
suppressWhenFocused: true
|
||||
suppressWhenFocused: true,
|
||||
customSoundPath: null
|
||||
},
|
||||
promptCacheTimerEnabled: false,
|
||||
promptCacheTtlMs: 300_000,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const {
|
||||
removeHandlerMock,
|
||||
@@ -62,9 +65,12 @@ import {
|
||||
} from './notifications'
|
||||
|
||||
describe('registerNotificationHandlers', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-03-28T16:00:00Z'))
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-notification-test-'))
|
||||
removeHandlerMock.mockReset()
|
||||
handleMock.mockReset()
|
||||
notificationCtorMock.mockClear()
|
||||
@@ -77,6 +83,10 @@ describe('registerNotificationHandlers', () => {
|
||||
getAllWindowsMock.mockReturnValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function getDispatchHandler(): (event: unknown, args: unknown) => unknown {
|
||||
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:dispatch')
|
||||
if (!call) {
|
||||
@@ -85,6 +95,24 @@ describe('registerNotificationHandlers', () => {
|
||||
return call[1] as (event: unknown, args: unknown) => unknown
|
||||
}
|
||||
|
||||
function getLoadSoundHandler(): (event: unknown) => Promise<unknown> {
|
||||
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === 'notifications:loadSound')
|
||||
if (!call) {
|
||||
throw new Error('notifications:loadSound handler not registered')
|
||||
}
|
||||
return call[1] as (event: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
function getResolveSoundPathHandler(): (event: unknown) => unknown {
|
||||
const call = handleMock.mock.calls.find(
|
||||
(c: unknown[]) => c[0] === 'notifications:resolveSoundPath'
|
||||
)
|
||||
if (!call) {
|
||||
throw new Error('notifications:resolveSoundPath handler not registered')
|
||||
}
|
||||
return call[1] as (event: unknown) => unknown
|
||||
}
|
||||
|
||||
it('registers the IPC handler', () => {
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
@@ -171,6 +199,28 @@ describe('registerNotificationHandlers', () => {
|
||||
expect(notificationShowMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('silences the native notification when a custom sound is configured', () => {
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: true,
|
||||
customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg'
|
||||
}
|
||||
})
|
||||
} as never)
|
||||
|
||||
const handler = getDispatchHandler()
|
||||
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
|
||||
expect(notificationCtorMock).toHaveBeenCalledWith({
|
||||
title: 'Orca notifications are on',
|
||||
body: 'This is a test notification from Orca.',
|
||||
silent: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns source-disabled when the specific source toggle is off', () => {
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
@@ -242,6 +292,87 @@ describe('registerNotificationHandlers', () => {
|
||||
})
|
||||
expect(notificationShowMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('loads allowed custom sound files for preload playback', async () => {
|
||||
const soundPath = join(tempDir, 'sound.ogg')
|
||||
writeFileSync(soundPath, Buffer.from([1, 2, 3]))
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false,
|
||||
customSoundPath: soundPath
|
||||
}
|
||||
})
|
||||
} as never)
|
||||
|
||||
const handler = getLoadSoundHandler()
|
||||
await expect(handler({})).resolves.toMatchObject({
|
||||
ok: true,
|
||||
data: new Uint8Array([1, 2, 3]),
|
||||
mimeType: 'audio/ogg'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsupported custom sound file types', async () => {
|
||||
const soundPath = join(tempDir, 'sound.txt')
|
||||
writeFileSync(soundPath, 'not audio')
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false,
|
||||
customSoundPath: soundPath
|
||||
}
|
||||
})
|
||||
} as never)
|
||||
|
||||
const handler = getLoadSoundHandler()
|
||||
await expect(handler({})).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: 'unsupported-type'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves the sound path without reading the file', () => {
|
||||
const soundPath = join(tempDir, 'sound.ogg')
|
||||
writeFileSync(soundPath, Buffer.from([1, 2, 3]))
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false,
|
||||
customSoundPath: soundPath
|
||||
}
|
||||
})
|
||||
} as never)
|
||||
|
||||
const handler = getResolveSoundPathHandler()
|
||||
expect(handler({})).toEqual({ ok: true, path: soundPath })
|
||||
})
|
||||
|
||||
it('rejects unsupported types from resolveSoundPath without touching the disk', () => {
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: true,
|
||||
suppressWhenFocused: false,
|
||||
customSoundPath: '/some/where/sound.txt'
|
||||
}
|
||||
})
|
||||
} as never)
|
||||
|
||||
const handler = getResolveSoundPathHandler()
|
||||
expect(handler({})).toEqual({ ok: false, reason: 'unsupported-type' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('triggerStartupNotificationRegistration', () => {
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { extname, isAbsolute, normalize } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import type { NotificationDispatchRequest, NotificationDispatchResult } from '../../shared/types'
|
||||
import type {
|
||||
NotificationDispatchRequest,
|
||||
NotificationDispatchResult,
|
||||
NotificationSoundDataResult
|
||||
} from '../../shared/types'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
const NOTIFICATION_COOLDOWN_MS = 5000
|
||||
const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024
|
||||
const NOTIFICATION_SOUND_MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
|
||||
['.ogg', 'audio/ogg'],
|
||||
['.mp3', 'audio/mpeg'],
|
||||
['.wav', 'audio/wav'],
|
||||
['.m4a', 'audio/mp4'],
|
||||
['.aac', 'audio/aac'],
|
||||
['.flac', 'audio/flac']
|
||||
])
|
||||
|
||||
// Why: Electron Notification objects are normal JS objects — if the only
|
||||
// reference is a local variable inside the ipcMain handler, the GC can
|
||||
@@ -90,7 +105,11 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
||||
}
|
||||
}
|
||||
|
||||
const notification = new Notification(buildNotificationOptions(args))
|
||||
const notificationOptions = buildNotificationOptions(args)
|
||||
if (settings.customSoundPath) {
|
||||
notificationOptions.silent = true
|
||||
}
|
||||
const notification = new Notification(notificationOptions)
|
||||
|
||||
// Why: prevent GC from collecting the notification (and its click
|
||||
// handler) while it's still visible in macOS Notification Center.
|
||||
@@ -139,6 +158,64 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
|
||||
return { delivered: true }
|
||||
}
|
||||
)
|
||||
|
||||
// Why: the preload caches the decoded blob keyed by path. Returning just
|
||||
// the validated path lets it skip the 10MB IPC round-trip on every dispatch
|
||||
// when the user's selection hasn't changed — terminal-bell bursts can fire
|
||||
// many notifications in seconds.
|
||||
ipcMain.removeHandler('notifications:resolveSoundPath')
|
||||
ipcMain.handle(
|
||||
'notifications:resolveSoundPath',
|
||||
():
|
||||
| { ok: true; path: string }
|
||||
| { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' } => {
|
||||
const pathValue = store.getSettings().notifications.customSoundPath
|
||||
if (!pathValue) {
|
||||
return { ok: false, reason: 'missing-path' }
|
||||
}
|
||||
const normalizedPath = normalize(pathValue)
|
||||
if (!isAbsolute(normalizedPath)) {
|
||||
return { ok: false, reason: 'invalid-path' }
|
||||
}
|
||||
if (!NOTIFICATION_SOUND_MIME_BY_EXTENSION.has(extname(normalizedPath).toLowerCase())) {
|
||||
return { ok: false, reason: 'unsupported-type' }
|
||||
}
|
||||
return { ok: true, path: normalizedPath }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.removeHandler('notifications:loadSound')
|
||||
ipcMain.handle('notifications:loadSound', async (): Promise<NotificationSoundDataResult> => {
|
||||
const pathValue = store.getSettings().notifications.customSoundPath
|
||||
if (!pathValue) {
|
||||
return { ok: false, reason: 'missing-path' }
|
||||
}
|
||||
|
||||
const normalizedPath = normalize(pathValue)
|
||||
if (!isAbsolute(normalizedPath)) {
|
||||
return { ok: false, reason: 'invalid-path' }
|
||||
}
|
||||
|
||||
const mimeType = NOTIFICATION_SOUND_MIME_BY_EXTENSION.get(extname(normalizedPath).toLowerCase())
|
||||
if (!mimeType) {
|
||||
return { ok: false, reason: 'unsupported-type' }
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(normalizedPath)
|
||||
if (!fileStat.isFile()) {
|
||||
return { ok: false, reason: 'invalid-path' }
|
||||
}
|
||||
if (fileStat.size > MAX_NOTIFICATION_SOUND_BYTES) {
|
||||
return { ok: false, reason: 'too-large' }
|
||||
}
|
||||
|
||||
const data = await readFile(normalizedPath)
|
||||
return { ok: true, data: new Uint8Array(data), mimeType, path: normalizedPath }
|
||||
} catch {
|
||||
return { ok: false, reason: 'read-failed' }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock, showOpenDialogMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
showOpenDialogMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: handleMock
|
||||
},
|
||||
shell: {
|
||||
showItemInFolder: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
openPath: vi.fn()
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: showOpenDialogMock
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerShellHandlers } from './shell'
|
||||
|
||||
describe('registerShellHandlers', () => {
|
||||
beforeEach(() => {
|
||||
handleMock.mockReset()
|
||||
showOpenDialogMock.mockReset()
|
||||
})
|
||||
|
||||
function getHandler(channel: string): (event: unknown, args?: unknown) => Promise<unknown> {
|
||||
registerShellHandlers()
|
||||
const call = handleMock.mock.calls.find((c: unknown[]) => c[0] === channel)
|
||||
if (!call) {
|
||||
throw new Error(`${channel} handler not registered`)
|
||||
}
|
||||
return call[1] as (event: unknown, args?: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
it('picks audio files with a constrained native dialog filter', async () => {
|
||||
showOpenDialogMock.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: ['/Users/kaylee/Downloads/Note_block_pling.ogg']
|
||||
})
|
||||
|
||||
const handler = getHandler('shell:pickAudio')
|
||||
await expect(handler({})).resolves.toBe('/Users/kaylee/Downloads/Note_block_pling.ogg')
|
||||
expect(showOpenDialogMock).toHaveBeenCalledWith({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when audio picking is canceled', async () => {
|
||||
showOpenDialogMock.mockResolvedValue({
|
||||
canceled: true,
|
||||
filePaths: []
|
||||
})
|
||||
|
||||
const handler = getHandler('shell:pickAudio')
|
||||
await expect(handler({})).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -123,6 +123,17 @@ export function registerShellHandlers(): void {
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('shell:pickAudio', async (): Promise<string | null> => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Audio', extensions: ['ogg', 'mp3', 'wav', 'm4a', 'aac', 'flac'] }]
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
// Why: copying a picked image next to the markdown file lets us insert a
|
||||
// relative path (e.g. ``) instead of embedding base64,
|
||||
// keeping markdown files small and portable.
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('Store', () => {
|
||||
expect(settings.terminalFontWeight).toBe(500)
|
||||
expect(settings.rightSidebarOpenByDefault).toBe(true)
|
||||
expect(settings.showTasksButton).toBe(true)
|
||||
expect(settings.notifications.customSoundPath).toBeNull()
|
||||
})
|
||||
|
||||
it('returns default UI state when no data file exists', async () => {
|
||||
@@ -147,10 +148,36 @@ describe('Store', () => {
|
||||
expect(store.getSettings().refreshLocalBaseRefOnWorktreeCreate).toBe(false)
|
||||
expect(store.getSettings().rightSidebarOpenByDefault).toBe(true)
|
||||
expect(store.getSettings().showTasksButton).toBe(true)
|
||||
expect(store.getSettings().notifications.customSoundPath).toBeNull()
|
||||
// repos should be loaded
|
||||
expect(store.getRepos()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves custom notification sound paths from persisted settings', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {
|
||||
notifications: {
|
||||
customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg'
|
||||
}
|
||||
},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().notifications).toMatchObject({
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: false,
|
||||
suppressWhenFocused: true,
|
||||
customSoundPath: '/Users/kaylee/Downloads/Note_block_pling.ogg'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves editorAutoSaveDelayMs when set in persisted data', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
GetRateLimitResult,
|
||||
NotificationDispatchRequest,
|
||||
NotificationDispatchResult,
|
||||
NotificationSoundResult,
|
||||
OrcaHooks,
|
||||
PersistedUIState,
|
||||
PRCheckDetail,
|
||||
@@ -720,6 +721,7 @@ export type PreloadApi = {
|
||||
notifications: {
|
||||
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
|
||||
openSystemSettings: () => Promise<void>
|
||||
playSound: (options?: { force?: boolean }) => Promise<NotificationSoundResult>
|
||||
}
|
||||
developerPermissions: {
|
||||
getStatus: () => Promise<DeveloperPermissionState[]>
|
||||
@@ -734,6 +736,7 @@ export type PreloadApi = {
|
||||
pathExists: (path: string) => Promise<boolean>
|
||||
pickAttachment: () => Promise<string | null>
|
||||
pickImage: () => Promise<string | null>
|
||||
pickAudio: () => Promise<string | null>
|
||||
pickDirectory: (args: { defaultPath?: string }) => Promise<string | null>
|
||||
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>
|
||||
}
|
||||
|
||||
+88
-1
@@ -21,6 +21,9 @@ import type {
|
||||
ListWorkItemsResult,
|
||||
MemorySnapshot,
|
||||
NotificationDispatchResult,
|
||||
NotificationSoundDataResult,
|
||||
NotificationSoundPathResult,
|
||||
NotificationSoundResult,
|
||||
SearchResult
|
||||
} from '../shared/types'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types'
|
||||
@@ -79,6 +82,29 @@ type NativeDropResolution =
|
||||
// falling back to 'editor' — fail-closed behavior per design §7.1.
|
||||
| { target: 'rejected' }
|
||||
|
||||
// Why: one shared HTMLAudioElement per sound file, restarted from t=0 on each
|
||||
// play, with an in-flight guard that drops new plays while the sound is still
|
||||
// ringing. This mirrors VS Code's AccessibilitySignalService and GNOME's
|
||||
// libcanberra: a burst of triggers self-dedupes by the sound's own duration
|
||||
// (no magic time constant), while distinct sounds are still allowed to overlap.
|
||||
// We also cache the decoded blob URL by path so we don't re-read 10MB from
|
||||
// disk and re-transfer it over IPC on every notification.
|
||||
let cachedNotificationSound: {
|
||||
path: string
|
||||
blobUrl: string
|
||||
audio: HTMLAudioElement
|
||||
} | null = null
|
||||
let isNotificationSoundPlaying = false
|
||||
|
||||
function disposeCachedNotificationSound(): void {
|
||||
if (cachedNotificationSound) {
|
||||
cachedNotificationSound.audio.pause()
|
||||
cachedNotificationSound.audio.src = ''
|
||||
URL.revokeObjectURL(cachedNotificationSound.blobUrl)
|
||||
cachedNotificationSound = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the composed event path to classify which UI surface the native OS drop
|
||||
* landed on, and — for file-explorer drops — extract the nearest destination
|
||||
@@ -843,7 +869,66 @@ const api = {
|
||||
notifications: {
|
||||
dispatch: (args: Record<string, unknown>): Promise<NotificationDispatchResult> =>
|
||||
ipcRenderer.invoke('notifications:dispatch', args),
|
||||
openSystemSettings: (): Promise<void> => ipcRenderer.invoke('notifications:openSystemSettings')
|
||||
openSystemSettings: (): Promise<void> => ipcRenderer.invoke('notifications:openSystemSettings'),
|
||||
playSound: async (options?: { force?: boolean }): Promise<NotificationSoundResult> => {
|
||||
try {
|
||||
// Why: drop replays while the sound is still ringing. The "test"
|
||||
// button bypasses with force so the user always hears a confirmation.
|
||||
if (!options?.force && isNotificationSoundPlaying) {
|
||||
return { played: false, reason: 'deduped' }
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
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 every play so a burst of triggers replays
|
||||
// the sound from the start instead of stacking overlapping copies.
|
||||
// Matches GNOME canberra and VS Code AccessibilitySignalService.
|
||||
audio.currentTime = 0
|
||||
isNotificationSoundPlaying = true
|
||||
const release = (): void => {
|
||||
isNotificationSoundPlaying = false
|
||||
}
|
||||
audio.addEventListener('ended', release, { once: true })
|
||||
audio.addEventListener('error', release, { once: true })
|
||||
try {
|
||||
await audio.play()
|
||||
} catch {
|
||||
release()
|
||||
return { played: false, reason: 'playback-failed' }
|
||||
}
|
||||
return { played: true }
|
||||
} catch {
|
||||
isNotificationSoundPlaying = false
|
||||
return { played: false, reason: 'playback-failed' }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
developerPermissions: {
|
||||
@@ -869,6 +954,8 @@ const api = {
|
||||
|
||||
pickImage: (): Promise<string | null> => ipcRenderer.invoke('shell:pickImage'),
|
||||
|
||||
pickAudio: (): Promise<string | null> => ipcRenderer.invoke('shell:pickAudio'),
|
||||
|
||||
pickDirectory: (args: { defaultPath?: string }): Promise<string | null> =>
|
||||
ipcRenderer.invoke('shell:pickDirectory', args),
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Separator } from '../ui/separator'
|
||||
import { BellRing, Bot, Siren } from 'lucide-react'
|
||||
import { BellRing, Bot, FileAudio, Siren, X } from 'lucide-react'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { basename } from '@/lib/path'
|
||||
|
||||
export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
@@ -28,6 +29,11 @@ export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
description: 'Avoid notifying when Orca is focused on the active worktree.',
|
||||
keywords: ['notifications', 'focused', 'suppress', 'filtering']
|
||||
},
|
||||
{
|
||||
title: 'Custom Sound',
|
||||
description: 'Choose one local audio file for all delivered desktop notifications.',
|
||||
keywords: ['notifications', 'sound', 'audio', 'ogg', 'mp3', 'wav']
|
||||
},
|
||||
{
|
||||
title: 'Send Test Notification',
|
||||
description: 'Trigger a sample desktop notification using the native delivery path.',
|
||||
@@ -45,6 +51,7 @@ export function NotificationsPane({
|
||||
updateSettings
|
||||
}: NotificationsPaneProps): React.JSX.Element {
|
||||
const notificationSettings = settings.notifications
|
||||
const [isPickingSound, setIsPickingSound] = useState(false)
|
||||
|
||||
const updateNotificationSettings = (updates: Partial<GlobalSettings['notifications']>): void => {
|
||||
updateSettings({
|
||||
@@ -58,10 +65,34 @@ export function NotificationsPane({
|
||||
const handleSendTestNotification = async (): Promise<void> => {
|
||||
const result = await window.api.notifications.dispatch({ source: 'test' })
|
||||
if (result.delivered) {
|
||||
// Why: the Test button must always play through, even if the user clicks
|
||||
// it twice in quick succession — the in-flight dedupe is for incidental
|
||||
// bursts of real notifications, not for an explicit user action.
|
||||
const soundResult = notificationSettings.customSoundPath
|
||||
? await window.api.notifications.playSound({ force: true })
|
||||
: null
|
||||
if (notificationSettings.customSoundPath && soundResult && !soundResult.played) {
|
||||
toast.error('Custom notification sound could not be played')
|
||||
return
|
||||
}
|
||||
toast.success('Test notification sent')
|
||||
}
|
||||
}
|
||||
|
||||
const handleChooseSound = async (): Promise<void> => {
|
||||
setIsPickingSound(true)
|
||||
try {
|
||||
const soundPath = await window.api.shell.pickAudio()
|
||||
if (soundPath) {
|
||||
updateNotificationSettings({ customSoundPath: soundPath })
|
||||
}
|
||||
} finally {
|
||||
setIsPickingSound(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedSoundPath = notificationSettings.customSoundPath
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<SettingToggle
|
||||
@@ -101,6 +132,61 @@ export function NotificationsPane({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2 px-1 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileAudio className="size-4" />
|
||||
<Label>Custom Sound</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One local audio file for all delivered desktop notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div
|
||||
className="min-h-8 min-w-0 flex-1 rounded-md border border-border/50 bg-muted/35 px-2.5 py-1.5"
|
||||
title={selectedSoundPath ?? undefined}
|
||||
>
|
||||
{selectedSoundPath ? (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium">{basename(selectedSoundPath)}</div>
|
||||
<div className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
{selectedSoundPath}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">System notification sound</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!notificationSettings.enabled || isPickingSound}
|
||||
onClick={() => void handleChooseSound()}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileAudio className="size-3.5" />
|
||||
{selectedSoundPath ? 'Change' : 'Choose'}
|
||||
</Button>
|
||||
{selectedSoundPath ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!notificationSettings.enabled}
|
||||
onClick={() => updateNotificationSettings({ customSoundPath: null })}
|
||||
className="gap-2"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingToggle
|
||||
label="Suppress While Focused"
|
||||
description="Skip notifications when the triggering worktree is already visible."
|
||||
|
||||
@@ -255,7 +255,8 @@ describe('connectPanePty', () => {
|
||||
clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
notifications: {
|
||||
dispatch: vi.fn()
|
||||
dispatch: vi.fn().mockResolvedValue({ delivered: true }),
|
||||
playSound: vi.fn().mockResolvedValue({ played: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound'
|
||||
|
||||
/**
|
||||
* Returns a stable dispatch function for terminal notifications.
|
||||
@@ -36,15 +37,25 @@ export function useNotificationDispatch(
|
||||
// itself is the source of truth for its owning repo.
|
||||
const worktree = getWorktreeMapFromState(state).get(worktreeId)
|
||||
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
|
||||
const customSoundPath = state.settings?.notifications?.customSoundPath ?? null
|
||||
|
||||
void window.api.notifications.dispatch({
|
||||
source: event.source,
|
||||
worktreeId,
|
||||
repoLabel: repo?.displayName,
|
||||
worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId,
|
||||
terminalTitle: event.terminalTitle,
|
||||
isActiveWorktree: state.activeWorktreeId === worktreeId
|
||||
})
|
||||
void window.api.notifications
|
||||
.dispatch({
|
||||
source: event.source,
|
||||
worktreeId,
|
||||
repoLabel: repo?.displayName,
|
||||
worktreeLabel: worktree?.displayName || worktree?.branch || worktreeId,
|
||||
terminalTitle: event.terminalTitle,
|
||||
isActiveWorktree: state.activeWorktreeId === worktreeId
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.delivered) {
|
||||
void playDesktopNotificationSound(customSoundPath)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to dispatch notification:', err)
|
||||
})
|
||||
},
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export async function playDesktopNotificationSound(
|
||||
customSoundPath: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
if (!customSoundPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.api.notifications.playSound()
|
||||
// Why: 'deduped' is expected when bursts of notifications coalesce — not a failure.
|
||||
if (!result.played && result.reason !== 'deduped') {
|
||||
console.warn('Failed to play custom notification sound:', result.reason)
|
||||
}
|
||||
return result.played
|
||||
} catch (err) {
|
||||
console.warn('Failed to play custom notification sound:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,8 @@ export function getDefaultNotificationSettings(): NotificationSettings {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: false,
|
||||
suppressWhenFocused: true
|
||||
suppressWhenFocused: true,
|
||||
customSoundPath: null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -886,6 +886,7 @@ export type NotificationSettings = {
|
||||
agentTaskComplete: boolean
|
||||
terminalBell: boolean
|
||||
suppressWhenFocused: boolean
|
||||
customSoundPath: string | null
|
||||
}
|
||||
|
||||
export type CodexManagedAccount = {
|
||||
@@ -1263,6 +1264,34 @@ export type NotificationDispatchResult = {
|
||||
reason?: 'disabled' | 'source-disabled' | 'suppressed-focus' | 'cooldown' | 'not-supported'
|
||||
}
|
||||
|
||||
export type NotificationSoundResult = {
|
||||
played: boolean
|
||||
reason?:
|
||||
| 'missing-path'
|
||||
| 'invalid-path'
|
||||
| 'unsupported-type'
|
||||
| 'too-large'
|
||||
| 'read-failed'
|
||||
| 'playback-failed'
|
||||
| 'deduped'
|
||||
}
|
||||
|
||||
export type NotificationSoundDataResult =
|
||||
| {
|
||||
ok: true
|
||||
data: Uint8Array
|
||||
mimeType: string
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
reason: Exclude<NotificationSoundResult['reason'], 'playback-failed'>
|
||||
}
|
||||
|
||||
export type NotificationSoundPathResult =
|
||||
| { ok: true; path: string }
|
||||
| { ok: false; reason: 'missing-path' | 'invalid-path' | 'unsupported-type' }
|
||||
|
||||
export type WorktreeCardProperty =
|
||||
| 'status'
|
||||
| 'unread'
|
||||
|
||||
Reference in New Issue
Block a user