From 5eb73032a1a691e863da3c26cb5affc07c728f97 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 31 Aug 2026 22:00:48 -0600 Subject: [PATCH 01/10] feat(notifications): notify when a turn finishes, with a custom sound Roxy went quiet when the agent finished: the only way to know a turn was done was to sit and watch it. Codex solves this with three separate mechanisms (a desktop toast, terminal OSC9/BEL, and a otify hook that shells out with a JSON payload); this takes the parts that make sense for a desktop app. - Condition: never / only-when-unfocused / always, matching Codex's ui.notification_condition. Unfocused is the default -- pinging for a turn you just watched finish is noise. - Sound, played in the renderer because main has no audio output. The bundled chime is generated by script/gen-chime.mjs, deliberately NOT the system alert: the OS toast is posted with silent: true so one event never makes two noises. - A custom sound file. The picked file is COPIED into /sounds/ and referenced by NAME thereafter, so moving or deleting the original can't break it later, and the read-back handler resolves names against that one directory (path traversal returns null rather than a file). Capped at 2 MB, with a preview button that doubles as the gesture that unlocks Chromium's autoplay policy. - The OS toast is posted from main rather than the renderer's Web Notification API, because only main can focus the window on click. The trigger reads the queue BEFORE draining it: drainQueue awaits the whole chain it starts, so notifying after it would fire once per queued prompt as the recursion unwound. Stopped turns stay silent -- the user is already there. --- script/gen-chime.mjs | 61 ++++++ src/main/db/repo.ts | 53 ++++- src/main/ipc/index.ts | 49 +++++ src/main/services/notifications.ts | 130 +++++++++++++ src/preload/index.ts | 12 ++ src/renderer/src/assets/chime.wav | Bin 0 -> 48554 bytes .../src/components/NotificationSettings.tsx | 181 ++++++++++++++++++ src/renderer/src/lib/notify.ts | 90 +++++++++ src/renderer/src/lib/store.ts | 68 ++++++- src/renderer/src/locales/ar.json | 27 +++ src/renderer/src/locales/de.json | 27 +++ src/renderer/src/locales/default.json | 27 +++ src/renderer/src/locales/es.json | 27 +++ src/renderer/src/locales/fr.json | 27 +++ src/renderer/src/locales/hi.json | 27 +++ src/renderer/src/locales/ja.json | 27 +++ src/renderer/src/locales/pt.json | 27 +++ src/renderer/src/locales/ru.json | 27 +++ src/renderer/src/locales/zh.json | 27 +++ src/renderer/src/routes/Settings.tsx | 6 + src/shared/api.ts | 31 +++ src/shared/ipc.ts | 14 ++ src/shared/types.ts | 47 +++++ 23 files changed, 1010 insertions(+), 2 deletions(-) create mode 100644 script/gen-chime.mjs create mode 100644 src/main/services/notifications.ts create mode 100644 src/renderer/src/assets/chime.wav create mode 100644 src/renderer/src/components/NotificationSettings.tsx create mode 100644 src/renderer/src/lib/notify.ts diff --git a/script/gen-chime.mjs b/script/gen-chime.mjs new file mode 100644 index 0000000..c06b995 --- /dev/null +++ b/script/gen-chime.mjs @@ -0,0 +1,61 @@ +// One-off generator for the bundled notification sound. +// Run with `node script/gen-chime.mjs`; writes src/renderer/src/assets/chime.wav. +import { writeFileSync, mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const RATE = 44100 +const DURATION = 0.55 +const frames = Math.round(RATE * DURATION) + +// Two plucked partials a fifth apart, the second struck slightly later: a soft +// "ti-ding" that reads as a completion rather than an alert. +const notes = [ + { freq: 880, start: 0, gain: 0.55 }, + { freq: 1318.51, start: 0.09, gain: 0.45 } +] + +const samples = new Float32Array(frames) +for (const note of notes) { + const offset = Math.round(note.start * RATE) + for (let i = offset; i < frames; i++) { + const t = (i - offset) / RATE + // Exponential decay for the body, short attack ramp so it never clicks. + const decay = Math.exp(-4.2 * t) + const attack = Math.min(1, t / 0.004) + const partial = + Math.sin(2 * Math.PI * note.freq * t) + 0.22 * Math.sin(2 * Math.PI * note.freq * 2 * t) + samples[i] += note.gain * attack * decay * partial + } +} + +// Normalize, then fade the tail to zero so the buffer ends in silence. +let peak = 0 +for (const s of samples) peak = Math.max(peak, Math.abs(s)) +const fadeFrames = Math.round(0.02 * RATE) +const pcm = Buffer.alloc(frames * 2) +for (let i = 0; i < frames; i++) { + const fade = i > frames - fadeFrames ? (frames - i) / fadeFrames : 1 + const v = Math.max(-1, Math.min(1, (samples[i] / peak) * 0.85 * fade)) + pcm.writeInt16LE(Math.round(v * 32767), i * 2) +} + +const header = Buffer.alloc(44) +header.write('RIFF', 0) +header.writeUInt32LE(36 + pcm.length, 4) +header.write('WAVE', 8) +header.write('fmt ', 12) +header.writeUInt32LE(16, 16) +header.writeUInt16LE(1, 20) // PCM +header.writeUInt16LE(1, 22) // mono +header.writeUInt32LE(RATE, 24) +header.writeUInt32LE(RATE * 2, 28) +header.writeUInt16LE(2, 32) +header.writeUInt16LE(16, 34) +header.write('data', 36) +header.writeUInt32LE(pcm.length, 40) + +const out = join(dirname(fileURLToPath(import.meta.url)), '../src/renderer/src/assets/chime.wav') +mkdirSync(dirname(out), { recursive: true }) +writeFileSync(out, Buffer.concat([header, pcm])) +console.log(`wrote ${out} (${header.length + pcm.length} bytes)`) diff --git a/src/main/db/repo.ts b/src/main/db/repo.ts index 0b0f9be..cf80110 100644 --- a/src/main/db/repo.ts +++ b/src/main/db/repo.ts @@ -4,6 +4,7 @@ import { normalizeServerConfig, type McpServerConfig, type McpServerRecord } fro import { DEFAULT_BRANCH_PREFIX, normalizeBranchPrefix } from '../../shared/branch' import { DEFAULT_LANGUAGE, normalizeLanguage } from '../../shared/i18n' import type { Language } from '../../shared/i18n' +import { DEFAULT_NOTIFY_VOLUME } from '../../shared/types' import type { AddMessageInput, AppSettings, @@ -15,6 +16,7 @@ import type { Message, MessagePart, MessageRole, + NotifyCondition, ProviderAuth, ProviderWire, QueueImage, @@ -125,10 +127,32 @@ export function getSettings(): AppSettings { // (or a language later dropped from the app) must degrade to English rather // than leave the UI rendering raw keys. language: normalizeLanguage(map.get('language')), - activeThemeId: map.get('active_theme_id') ?? null + activeThemeId: map.get('active_theme_id') ?? null, + // Only 'never' and 'always' are ever written; anything else (no row, or a + // value from a build that spelled it differently) means the default. + notifyCondition: ((): NotifyCondition => { + const v = map.get('notify_condition') + return v === 'never' || v === 'always' ? v : 'unfocused' + })(), + // Defaults ON, so the absence of a row means enabled - same convention as + // auto_workstream above, and for the same reason: no migration needed. + notifySound: map.get('notify_sound') !== '0', + notifySoundName: map.get('notify_sound_name') ?? null, + notifyVolume: clampVolume(Number(map.get('notify_volume'))), + notifySystemToast: map.get('notify_system_toast') !== '0' } } +/** + * Volume is read back through this rather than trusted, because it also runs on + * `Number(undefined)` -> NaN for a fresh install. NaN would sail through a bare + * range check and reach `audio.volume`, which throws on it. + */ +function clampVolume(v: number): number { + if (!Number.isFinite(v)) return DEFAULT_NOTIFY_VOLUME + return Math.min(1, Math.max(0, v)) +} + function setSetting(key: string, value: string | null): void { const db = getDb() if (value === null) { @@ -232,6 +256,33 @@ export function setAutoWorkstream(enabled: boolean): AppSettings { return getSettings() } +export function setNotifyCondition(condition: NotifyCondition): AppSettings { + // 'unfocused' is the default, so it clears the row rather than writing one. + setSetting('notify_condition', condition === 'unfocused' ? null : condition) + return getSettings() +} + +export function setNotifySound(enabled: boolean): AppSettings { + setSetting('notify_sound', enabled ? null : '0') + return getSettings() +} + +/** Point at a sound already copied into `/sounds/`; null = the default. */ +export function setNotifySoundName(name: string | null): AppSettings { + setSetting('notify_sound_name', name) + return getSettings() +} + +export function setNotifyVolume(volume: number): AppSettings { + setSetting('notify_volume', String(clampVolume(volume))) + return getSettings() +} + +export function setNotifySystemToast(enabled: boolean): AppSettings { + setSetting('notify_system_toast', enabled ? null : '0') + return getSettings() +} + export function completeOnboarding(): AppSettings { setSetting('onboarding_completed', '1') return getSettings() diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4116a02..166143a 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -23,12 +23,15 @@ import type { SyncOutcome, UpsertMcpServerInput } from '../../shared/api' +import { NOTIFY_SOUND_EXTENSIONS } from '../../shared/types' import type { AddMessageInput, ConnectProviderInput, + NotifyCondition, QueueImage, ReasoningEffort } from '../../shared/types' +import * as notifications from '../services/notifications' import * as repo from '../db/repo' import * as copilot from '../services/copilot' import * as cliproxy from '../services/cliproxy' @@ -210,6 +213,18 @@ export function registerIpc(): void { ipcMain.handle(CHANNELS.settingsSetBranchPrefix, (_e, prefix: string) => repo.setBranchPrefix(prefix) ) + ipcMain.handle(CHANNELS.settingsSetNotifyCondition, (_e, condition: NotifyCondition) => + repo.setNotifyCondition(condition) + ) + ipcMain.handle(CHANNELS.settingsSetNotifySound, (_e, enabled: boolean) => + repo.setNotifySound(enabled) + ) + ipcMain.handle(CHANNELS.settingsSetNotifyVolume, (_e, volume: number) => + repo.setNotifyVolume(volume) + ) + ipcMain.handle(CHANNELS.settingsSetNotifySystemToast, (_e, enabled: boolean) => + repo.setNotifySystemToast(enabled) + ) ipcMain.handle(CHANNELS.settingsCompleteOnboarding, () => repo.completeOnboarding()) ipcMain.handle(CHANNELS.settingsReset, async () => { // "Wipes all providers" has to include the subscription tokens held by the @@ -230,6 +245,40 @@ export function registerIpc(): void { setTrackingEnabled(enabled) ) + // ---- notifications ---- + // Both strings arrive translated - see the note on the api type. + ipcMain.handle(CHANNELS.notifyToast, (_e, title: string, body: string) => { + notifications.showTurnToast(title, body) + }) + ipcMain.handle(CHANNELS.notifyPickSound, async (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + const options = { + title: 'Choose a notification sound', + properties: ['openFile'] as const, + filters: [{ name: 'Audio', extensions: [...NOTIFY_SOUND_EXTENSIONS] }] + } + const result = win + ? await dialog.showOpenDialog(win, { ...options, properties: [...options.properties] }) + : await dialog.showOpenDialog({ ...options, properties: [...options.properties] }) + if (result.canceled || result.filePaths.length === 0) { + return { settings: repo.getSettings(), error: 'cancelled' as const } + } + const imported = await notifications.importSound(result.filePaths[0]) + if (!imported.ok) return { settings: repo.getSettings(), error: imported.error } + return { settings: repo.setNotifySoundName(imported.name), error: null } + }) + ipcMain.handle(CHANNELS.notifyClearSound, async () => { + await notifications.clearSounds() + return repo.setNotifySoundName(null) + }) + ipcMain.handle(CHANNELS.notifyReadSound, async () => { + // Read the name here rather than trusting one from the renderer: this + // handler turns a name into file bytes, so it stays the sole authority on + // which file that may be. + const name = repo.getSettings().notifySoundName + return name ? notifications.readSound(name) : null + }) + // ---- providers ---- ipcMain.handle(CHANNELS.providersList, () => repo.listConnectedProviders()) ipcMain.handle(CHANNELS.providersConnect, (_e, input: ConnectProviderInput) => { diff --git a/src/main/services/notifications.ts b/src/main/services/notifications.ts new file mode 100644 index 0000000..8a85537 --- /dev/null +++ b/src/main/services/notifications.ts @@ -0,0 +1,130 @@ +/** + * Turn-completion notifications — the main-process half. + * + * Two jobs, both of which have to live here rather than in the renderer: + * + * - The native OS toast. The renderer's Web `Notification` also reaches the OS, + * but it gives no way to focus the window when the toast is clicked, which is + * the entire point of the notification. + * - Custom sound files. The chosen file is COPIED into `/sounds/` + * and thereafter read back by name, so moving or deleting the original can't + * silently break the sound, and the renderer never touches an arbitrary path. + * + * The sound itself is PLAYED in the renderer (Electron's main process has no + * audio output at all), so this file only ever hands over bytes. + * + * Deliberately says nothing about WHEN to notify: the `notifyCondition` / + * `notifySound` decision is the renderer's, because only it knows whether the + * turn was stopped, whether a queue is still draining, and what the strings say. + */ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { app, BrowserWindow, Notification } from 'electron' +import { + NOTIFY_SOUND_EXTENSIONS, + NOTIFY_SOUND_MAX_BYTES, + type NotifySoundFile +} from '../../shared/types' + +/** Where custom notification sounds are kept. */ +function soundsDir(): string { + return path.join(app.getPath('userData'), 'sounds') +} + +/** + * Resolve a stored sound NAME to a path inside the sounds directory, or null if + * it escapes it. + * + * The name comes back out of the settings table, which a determined user can + * edit by hand — `../../../etc/passwd` must not become a readable file. Compare + * the resolved path rather than scanning for `..`, so encodings and symlinked + * separators can't sneak past a substring check. + */ +function resolveSoundPath(name: string): string | null { + const dir = soundsDir() + const full = path.resolve(dir, name) + if (path.dirname(full) !== path.resolve(dir)) return null + return full +} + +/** + * Copy a user-chosen audio file into the sounds directory and return its new + * name, or an error the UI can show. + * + * Only ONE custom sound exists at a time: the copy always lands on + * `custom.` and older ones are removed, so picking a new sound can't leave + * a directory of abandoned files behind. The extension is kept because that is + * what tells the renderer's `