diff --git a/README.md b/README.md index 8d3d0fb4..3a8c35f2 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,16 @@ browser; its URL is never sent to an image or QR service. `PUBLIC_APP_URL` must HTTP(S) origin with no path, query, fragment, or credentials. If it is unset, the QR uses the current browser origin, which is only useful when that origin is phone-reachable. +### Notifications + +Browser sound profiles and optional generic status speech are stored per device. Browser +and ntfy event delivery toggles remain server-backed and independent. Spoken notifications +never include prompts, paths, filenames, commands, tool output, or notification bodies. + +Constructor-based browser Notifications on iPhone and iPad still require installed-PWA +and service-worker support. This feature does not add a service worker; ntfy remains the +reliable phone notification path. + Verification requires no live agent or model credentials: ```bash diff --git a/client/lib/notificationMedia.ts b/client/lib/notificationMedia.ts new file mode 100644 index 00000000..347edb95 --- /dev/null +++ b/client/lib/notificationMedia.ts @@ -0,0 +1,167 @@ +import type { NotifyEvent } from "./api.js"; + +export const NOTIFICATION_MEDIA_STORAGE_KEY = "opencode-notification-media-v1"; +export const NOTIFICATION_MEDIA_CHANGE_EVENT = "opencode-notification-media-preferences"; +export const SOUND_PROFILES = ["subtle", "distinct", "minimal"] as const; +export type SoundProfile = (typeof SOUND_PROFILES)[number]; + +export interface DeviceNotificationPreferences { + version: 1; + sound: { + enabled: boolean; + volume: number; + profile: SoundProfile; + events: Record; + }; + speech: { + enabled: boolean; + rate: number; + }; +} + +export type DeviceNotificationPreferenceLoadState = "absent" | "present" | "corrupt" | "unavailable"; + +export interface DeviceNotificationPreferenceLoadResult { + state: DeviceNotificationPreferenceLoadState; + preferences: DeviceNotificationPreferences; +} + +export interface DeviceNotificationPreferenceInitialization extends DeviceNotificationPreferenceLoadResult { + migrated: boolean; +} + +export interface Tone { + frequency: number; + offset: number; + duration: number; + type: OscillatorType; +} + +const EVENTS: NotifyEvent[] = ["idle", "error", "abort", "permission", "question", "parked"]; +const DEFAULT_EVENTS = Object.fromEntries(EVENTS.map((event) => [event, event !== "abort"])) as Record; + +export const DEFAULT_DEVICE_NOTIFICATION_PREFERENCES: DeviceNotificationPreferences = { + version: 1, + sound: { enabled: false, volume: 0.5, profile: "distinct", events: { ...DEFAULT_EVENTS } }, + speech: { enabled: false, rate: 1 }, +}; + +function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback; +} + +export function normalizeDeviceNotificationPreferences(value: unknown): DeviceNotificationPreferences { + const source = value && typeof value === "object" ? value as Record : {}; + const sound = source.sound && typeof source.sound === "object" ? source.sound as Record : {}; + const speech = source.speech && typeof source.speech === "object" ? source.speech as Record : {}; + const eventSource = sound.events && typeof sound.events === "object" ? sound.events as Record : {}; + const profile = SOUND_PROFILES.includes(sound.profile as SoundProfile) ? sound.profile as SoundProfile : "distinct"; + return { + version: 1, + sound: { + enabled: sound.enabled === true, + volume: clamp(sound.volume, 0, 1, 0.5), + profile, + events: Object.fromEntries(EVENTS.map((event) => [ + event, + typeof eventSource[event] === "boolean" ? eventSource[event] : DEFAULT_EVENTS[event], + ])) as Record, + }, + speech: { + enabled: speech.enabled === true, + rate: clamp(speech.rate, 0.7, 1.3, 1), + }, + }; +} + +export function loadDeviceNotificationPreferences(storage?: Pick): DeviceNotificationPreferenceLoadResult { + try { + const target = storage ?? localStorage; + const raw = target.getItem(NOTIFICATION_MEDIA_STORAGE_KEY); + if (raw === null) { + return { state: "absent", preferences: normalizeDeviceNotificationPreferences(null) }; + } + try { + return { state: "present", preferences: normalizeDeviceNotificationPreferences(JSON.parse(raw)) }; + } catch { + return { state: "corrupt", preferences: normalizeDeviceNotificationPreferences(null) }; + } + } catch { + return { state: "unavailable", preferences: normalizeDeviceNotificationPreferences(null) }; + } +} + +export function resolveDeviceNotificationPreferences( + loaded: DeviceNotificationPreferenceLoadResult, + legacy: { sound: boolean; volume: number }, +): DeviceNotificationPreferenceInitialization { + if (loaded.state !== "absent") return { ...loaded, migrated: false }; + return { + state: "present", + migrated: true, + preferences: normalizeDeviceNotificationPreferences({ + ...loaded.preferences, + sound: { + ...loaded.preferences.sound, + enabled: legacy.sound, + volume: legacy.volume, + }, + }), + }; +} + +export function initializeDeviceNotificationPreferences( + legacy: { sound: boolean; volume: number }, + storage?: Pick, +): DeviceNotificationPreferenceInitialization { + const loaded = loadDeviceNotificationPreferences(storage); + const initialized = resolveDeviceNotificationPreferences(loaded, legacy); + if (loaded.state !== "unavailable") saveDeviceNotificationPreferences(initialized.preferences, storage); + return initialized; +} + +export function saveDeviceNotificationPreferences( + preferences: DeviceNotificationPreferences, + storage?: Pick, +): DeviceNotificationPreferences { + const normalized = normalizeDeviceNotificationPreferences(preferences); + try { + (storage ?? localStorage).setItem(NOTIFICATION_MEDIA_STORAGE_KEY, JSON.stringify(normalized)); + } catch { + // Storage may be blocked by browser privacy settings; in-memory settings still work. + } + return normalized; +} + +export function notificationPhrase(event: NotifyEvent): string | null { + switch (event) { + case "idle": return "Session finished"; + case "error": return "Session failed"; + case "permission": return "OpenCode needs permission"; + case "question": return "OpenCode asked a question"; + case "parked": return "Session is waiting for approval"; + case "abort": return null; + } +} + +const DISTINCT_FREQUENCIES: Record = { + idle: [523, 659], + error: [330, 220], + abort: [294], + permission: [440, 587], + question: [494, 659, 587], + parked: [392, 392, 523], +}; + +export function tonePattern(event: NotifyEvent, profile: SoundProfile): Tone[] { + const frequencies = profile === "minimal" ? DISTINCT_FREQUENCIES[event].slice(0, 1) : DISTINCT_FREQUENCIES[event]; + const duration = profile === "subtle" ? 0.075 : profile === "minimal" ? 0.1 : 0.11; + const gap = profile === "subtle" ? 0.085 : 0.13; + return frequencies.map((frequency, index) => ({ + frequency: profile === "subtle" ? Math.round(frequency * 0.82) : frequency, + offset: index * gap, + duration, + type: profile === "distinct" && event === "error" ? "sawtooth" : "sine", + })); +} diff --git a/client/lib/notificationMediaBrowser.ts b/client/lib/notificationMediaBrowser.ts new file mode 100644 index 00000000..4b6d7559 --- /dev/null +++ b/client/lib/notificationMediaBrowser.ts @@ -0,0 +1,83 @@ +import type { NotifyEvent } from "./api.js"; +import { + notificationPhrase, + tonePattern, + type DeviceNotificationPreferences, + type Tone, +} from "./notificationMedia.js"; + +let audioContext: AudioContext | null = null; + +export interface NotificationCapabilities { + audio: boolean; + speech: boolean; + desktop: boolean; + desktopPermission: NotificationPermission | "unavailable"; +} + +export function notificationCapabilities(): NotificationCapabilities { + const hasNotification = "Notification" in window; + return { + audio: Boolean(window.AudioContext), + speech: "speechSynthesis" in window && "SpeechSynthesisUtterance" in window, + desktop: hasNotification, + desktopPermission: hasNotification ? Notification.permission : "unavailable", + }; +} + +export async function unlockNotificationAudio(): Promise { + if (!window.AudioContext) return false; + audioContext ??= new window.AudioContext(); + if (audioContext.state === "suspended") await audioContext.resume(); + return audioContext.state === "running"; +} + +function playPattern(pattern: Tone[], volume: number): boolean { + if (!audioContext || audioContext.state !== "running") return false; + const gainValue = Math.max(0, Math.min(1, volume)) * 0.1; + for (const tone of pattern) { + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); + const start = audioContext.currentTime + Math.max(0, tone.offset); + const duration = Math.max(0.04, Math.min(0.14, tone.duration)); + oscillator.type = tone.type; + oscillator.frequency.value = Math.max(120, Math.min(1200, tone.frequency)); + gain.gain.setValueAtTime(0, start); + gain.gain.linearRampToValueAtTime(gainValue, start + 0.01); + gain.gain.linearRampToValueAtTime(0, start + duration); + oscillator.connect(gain).connect(audioContext.destination); + oscillator.start(start); + oscillator.stop(start + duration + 0.01); + } + return pattern.length > 0; +} + +export function playNotificationSound(preferences: DeviceNotificationPreferences, event: NotifyEvent): boolean { + if (!preferences.sound.enabled || !preferences.sound.events[event]) return false; + return playPattern(tonePattern(event, preferences.sound.profile), preferences.sound.volume); +} + +export async function previewNotificationSound(preferences: DeviceNotificationPreferences, event: NotifyEvent = "idle"): Promise { + if (!await unlockNotificationAudio()) return false; + return playPattern(tonePattern(event, preferences.sound.profile), preferences.sound.volume); +} + +export function speakNotification(preferences: DeviceNotificationPreferences, event: NotifyEvent): boolean { + const phrase = notificationPhrase(event); + if (!preferences.speech.enabled || !phrase || !notificationCapabilities().speech) return false; + speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(phrase); + utterance.rate = preferences.speech.rate; + speechSynthesis.speak(utterance); + return true; +} + +export function previewNotificationSpeech(preferences: DeviceNotificationPreferences, event: NotifyEvent = "idle"): boolean { + const phrase = notificationPhrase(event); + if (!phrase || !notificationCapabilities().speech) return false; + speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(phrase); + utterance.rate = preferences.speech.rate; + speechSynthesis.speak(utterance); + return true; +} diff --git a/client/lib/useNotifyWatcher.ts b/client/lib/useNotifyWatcher.ts index 247d07b1..40fce1c0 100644 --- a/client/lib/useNotifyWatcher.ts +++ b/client/lib/useNotifyWatcher.ts @@ -1,6 +1,13 @@ import { useEffect } from "react"; import { api, type NotificationPreferences, type NotifyEvent } from "./api.js"; +import { + initializeDeviceNotificationPreferences, + loadDeviceNotificationPreferences, + NOTIFICATION_MEDIA_CHANGE_EVENT, + type DeviceNotificationPreferences, +} from "./notificationMedia.js"; +import { playNotificationSound, speakNotification, unlockNotificationAudio } from "./notificationMediaBrowser.js"; function classify(type: string, properties: Record): NotifyEvent | null { if (type === "session.idle") return "idle"; @@ -16,28 +23,16 @@ function classify(type: string, properties: Record): NotifyEven return null; } -function play(volume: number): void { - const AudioContextClass = window.AudioContext; - if (!AudioContextClass) return; - const context = new AudioContextClass(); - const oscillator = context.createOscillator(); - const gain = context.createGain(); - oscillator.frequency.value = 660; - gain.gain.value = Math.max(0, Math.min(1, volume)) * 0.08; - oscillator.connect(gain).connect(context.destination); - oscillator.start(); - oscillator.stop(context.currentTime + 0.12); - oscillator.addEventListener("ended", () => void context.close()); -} - export function notifyBrowser( preferences: NotificationPreferences, event: NotifyEvent, title = `OpenCode: ${event}`, click?: string, + devicePreferences = loadDeviceNotificationPreferences().preferences, ): void { if (!preferences.browser.events[event]) return; - if (preferences.browser.sound) play(preferences.browser.volume); + playNotificationSound(devicePreferences, event); + speakNotification(devicePreferences, event); if ( preferences.browser.desktop && "Notification" in window && @@ -52,12 +47,23 @@ export function notifyBrowser( export function useNotifyWatcher(): void { useEffect(() => { let preferences: NotificationPreferences | null = null; + let devicePreferences: DeviceNotificationPreferences = loadDeviceNotificationPreferences().preferences; const seen = new Map(); const refreshPreferences = () => void api.notifications().then((result) => { preferences = result.preferences; + devicePreferences = initializeDeviceNotificationPreferences(result.preferences.browser).preferences; }).catch(() => undefined); refreshPreferences(); + const refreshDevicePreferences = (event: Event) => { + devicePreferences = event instanceof CustomEvent && event.detail + ? event.detail as DeviceNotificationPreferences + : loadDeviceNotificationPreferences().preferences; + }; window.addEventListener("opencode-notification-preferences", refreshPreferences); + window.addEventListener(NOTIFICATION_MEDIA_CHANGE_EVENT, refreshDevicePreferences); + const unlockAudio = () => void unlockNotificationAudio().catch(() => undefined); + window.addEventListener("pointerdown", unlockAudio, { once: true }); + window.addEventListener("keydown", unlockAudio, { once: true }); const source = new EventSource(api.eventsUrl()); source.onmessage = (message) => { let event: { type?: string; properties?: Record; click?: string }; @@ -79,11 +85,14 @@ export function useNotifyWatcher(): void { if (now - timestamp > 60_000) seen.delete(seenKey); } } - notifyBrowser(preferences, kind, undefined, event.click); + notifyBrowser(preferences, kind, undefined, event.click, devicePreferences); }; return () => { source.close(); window.removeEventListener("opencode-notification-preferences", refreshPreferences); + window.removeEventListener(NOTIFICATION_MEDIA_CHANGE_EVENT, refreshDevicePreferences); + window.removeEventListener("pointerdown", unlockAudio); + window.removeEventListener("keydown", unlockAudio); }; }, []); } diff --git a/client/pages/Notifications.tsx b/client/pages/Notifications.tsx index fede05e0..8462c2f6 100644 --- a/client/pages/Notifications.tsx +++ b/client/pages/Notifications.tsx @@ -7,18 +7,34 @@ import { type NotificationPreferences, type NotifyEvent, } from "../lib/api.js"; +import { + initializeDeviceNotificationPreferences, + loadDeviceNotificationPreferences, + NOTIFICATION_MEDIA_CHANGE_EVENT, + saveDeviceNotificationPreferences, + SOUND_PROFILES, + type DeviceNotificationPreferences, +} from "../lib/notificationMedia.js"; +import { + notificationCapabilities, + previewNotificationSound, + previewNotificationSpeech, +} from "../lib/notificationMediaBrowser.js"; import { notifyBrowser } from "../lib/useNotifyWatcher.js"; const EVENTS: NotifyEvent[] = ["idle", "error", "abort", "permission", "question", "parked"]; export function NotificationsPage() { const [preferences, setPreferences] = useState(null); + const [devicePreferences, setDevicePreferences] = useState(() => loadDeviceNotificationPreferences().preferences); const [tokenConfigured, setTokenConfigured] = useState(false); const [message, setMessage] = useState(""); const [error, setError] = useState(""); + const capabilities = notificationCapabilities(); useEffect(() => { void api.notifications().then((result) => { setPreferences(result.preferences); + setDevicePreferences(initializeDeviceNotificationPreferences(result.preferences.browser).preferences); setTokenConfigured(result.tokenConfigured); }).catch((e: Error) => setError(e.message)); }, []); @@ -28,7 +44,10 @@ export function NotificationsPage() { try { const result = await api.saveNotifications(preferences); setPreferences(result.preferences); + const savedDevicePreferences = saveDeviceNotificationPreferences(devicePreferences); + setDevicePreferences(savedDevicePreferences); window.dispatchEvent(new Event("opencode-notification-preferences")); + window.dispatchEvent(new CustomEvent(NOTIFICATION_MEDIA_CHANGE_EVENT, { detail: savedDevicePreferences })); setMessage("Saved"); } catch (e) { setError((e as Error).message); @@ -40,12 +59,12 @@ export function NotificationsPage() { if ("Notification" in window && Notification.permission === "default") { await Notification.requestPermission(); } - notifyBrowser(preferences, "idle", "OpenCode notification test"); + notifyBrowser(preferences, "idle", "OpenCode notification test", undefined, devicePreferences); setMessage("Browser test triggered"); }; return ( -
+

Notifications

Choose events independently for browser and ntfy delivery.

@@ -60,11 +79,66 @@ export function NotificationsPage() {

Token: {tokenConfigured ? "configured in the environment" : "not configured"}

- - +

+ Desktop notifications: {capabilities.desktop ? capabilities.desktopPermission : "unavailable in this browser"}. +

+

On iPhone and iPad, browser notifications require installed-PWA and service-worker support. ntfy is the reliable phone notification path.

+
+
+

Notification sound & speech

+

These settings stay on this device. Spoken alerts use only generic status phrases.

+
+ +
+
+ + + + +

{capabilities.audio ? "Audio starts after you interact with this page." : "WebAudio is unavailable in this browser."}

+
+ +
+ + + +

{capabilities.speech ? "New speech replaces any status still being spoken." : "Speech synthesis is unavailable in this browser."}

+
+
+ +
+ Sound by event +
+ {EVENTS.map((event) => ( + + ))} +
+
+
+
diff --git a/tests/e2e/notification-media.ui.spec.ts b/tests/e2e/notification-media.ui.spec.ts new file mode 100644 index 00000000..e717d5f1 --- /dev/null +++ b/tests/e2e/notification-media.ui.spec.ts @@ -0,0 +1,155 @@ +import { expect, test } from "@playwright/test"; + +const MOCK_URL = `http://127.0.0.1:${process.env.MOCK_OPENCODE_PORT || 4599}`; +const DEVICE_DEFAULT = JSON.stringify({ + version: 1, + sound: { + enabled: false, + volume: 0.5, + profile: "distinct", + events: { idle: true, error: true, abort: false, permission: true, question: true, parked: true }, + }, + speech: { enabled: false, rate: 1 }, +}); + +async function stubLegacySound(page: import("@playwright/test").Page, sound: boolean, volume: number) { + await page.route("**/api/notifications", async (route) => { + const response = await route.fetch(); + const body = await response.json() as { preferences: { browser: { sound: boolean; volume: number } } }; + body.preferences.browser.sound = sound; + body.preferences.browser.volume = volume; + await route.fulfill({ response, json: body }); + }); +} + +async function installMediaStubs(page: import("@playwright/test").Page, stored?: string) { + await page.addInitScript(({ stored }) => { + if (stored !== undefined) localStorage.setItem("opencode-notification-media-v1", stored); + const calls = { frequencies: [] as number[], starts: 0, resumes: 0, speech: [] as string[], cancels: 0 }; + Object.defineProperty(window, "__mediaCalls", { value: calls, configurable: true }); + class FakeAudioContext { + currentTime = 0; + destination = {}; + state: AudioContextState = "suspended"; + resume() { calls.resumes += 1; this.state = "running"; return Promise.resolve(); } + createOscillator() { + const frequency = { value: 0 }; + return { + frequency, + type: "sine", + connect() { return this; }, + start() { calls.starts += 1; calls.frequencies.push(frequency.value); }, + stop() {}, + }; + } + createGain() { + return { + gain: { setValueAtTime() {}, linearRampToValueAtTime() {} }, + connect() { return this; }, + }; + } + } + class FakeUtterance { + rate = 1; + constructor(public text: string) {} + } + Object.defineProperty(window, "AudioContext", { value: FakeAudioContext, configurable: true }); + Object.defineProperty(window, "SpeechSynthesisUtterance", { value: FakeUtterance, configurable: true }); + Object.defineProperty(window, "speechSynthesis", { + value: { + cancel() { calls.cancels += 1; }, + speak(utterance: FakeUtterance) { calls.speech.push(utterance.text); }, + }, + configurable: true, + }); + }, { stored }); +} + +test.describe("notification sound and speech", () => { + test("disabled media makes no calls and previews unlock after a click", async ({ page }) => { + await installMediaStubs(page, DEVICE_DEFAULT); + await page.goto("/settings/notifications"); + await expect(page.getByTestId("opencode-browser-sound")).not.toBeChecked(); + await expect(page.getByTestId("opencode-speech-enabled")).not.toBeChecked(); + + await fetch(`${MOCK_URL}/test/mobile/idle`, { method: "POST" }); + await page.waitForTimeout(100); + expect(await page.evaluate(() => (window as unknown as { __mediaCalls: { starts: number; speech: string[] } }).__mediaCalls)).toMatchObject({ starts: 0, speech: [] }); + + await page.getByTestId("opencode-preview-sound").click(); + await page.getByTestId("opencode-preview-speech").click(); + const calls = await page.evaluate(() => (window as unknown as { __mediaCalls: { starts: number; resumes: number; speech: string[] } }).__mediaCalls); + expect(calls.resumes).toBe(1); + expect(calls.starts).toBeGreaterThan(0); + expect(calls.speech).toEqual(["Session finished"]); + }); + + test("event kinds use distinct tones and safe phrases after saving", async ({ page }) => { + await installMediaStubs(page, DEVICE_DEFAULT); + await page.goto("/settings/notifications"); + await page.getByTestId("opencode-browser-sound").check(); + await page.getByTestId("opencode-speech-enabled").check(); + await page.getByTestId("opencode-notifications-save").click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible(); + + await page.evaluate(() => { + const calls = (window as unknown as { __mediaCalls: { frequencies: number[]; starts: number; speech: string[] } }).__mediaCalls; + calls.frequencies.length = 0; + calls.starts = 0; + calls.speech.length = 0; + }); + await fetch(`${MOCK_URL}/test/mobile/idle`, { method: "POST" }); + await expect.poll(() => page.evaluate(() => (window as unknown as { __mediaCalls: { speech: string[] } }).__mediaCalls.speech)).toContain("Session finished"); + const idleFrequencies = await page.evaluate(() => (window as unknown as { __mediaCalls: { frequencies: number[] } }).__mediaCalls.frequencies.splice(0)); + + await fetch(`${MOCK_URL}/test/permission?directory=/tmp/mock-project`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: `per_media_${Date.now()}`, sessionID: "ses_mock_done", permission: "bash", patterns: ["private command"] }), + }); + await expect.poll(() => page.evaluate(() => (window as unknown as { __mediaCalls: { speech: string[] } }).__mediaCalls.speech)).toContain("OpenCode needs permission"); + const permissionFrequencies = await page.evaluate(() => (window as unknown as { __mediaCalls: { frequencies: number[] } }).__mediaCalls.frequencies); + expect(idleFrequencies).not.toEqual(permissionFrequencies); + expect(await page.evaluate(() => (window as unknown as { __mediaCalls: { speech: string[] } }).__mediaCalls.speech)).toEqual([ + "Session finished", + "OpenCode needs permission", + ]); + }); + + test("corrupt storage resets and the controls fit at 390px", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 740 }); + await stubLegacySound(page, true, 0.9); + await installMediaStubs(page, "{bad-json"); + await page.goto("/settings/notifications"); + await expect(page.getByTestId("opencode-sound-profile")).toHaveValue("distinct"); + await expect(page.getByTestId("opencode-browser-sound")).not.toBeChecked(); + const recovered = await page.evaluate(() => JSON.parse(localStorage.getItem("opencode-notification-media-v1") ?? "null")); + expect(recovered).toMatchObject({ version: 1, sound: { enabled: false, volume: 0.5 } }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + }); + + test("migrates legacy sound when the device key is absent", async ({ page }) => { + await stubLegacySound(page, true, 0.8); + await installMediaStubs(page); + await page.goto("/settings/notifications"); + + await expect(page.getByTestId("opencode-browser-sound")).toBeChecked(); + await expect(page.getByTestId("opencode-browser-volume")).toHaveValue("0.8"); + const migrated = await page.evaluate(() => JSON.parse(localStorage.getItem("opencode-notification-media-v1") ?? "null")); + expect(migrated.sound).toMatchObject({ enabled: true, volume: 0.8 }); + }); + + test("keeps an existing device key authoritative over legacy sound", async ({ page }) => { + const existing = JSON.stringify({ + ...JSON.parse(DEVICE_DEFAULT), + sound: { ...JSON.parse(DEVICE_DEFAULT).sound, enabled: false, volume: 0.25, profile: "minimal" }, + }); + await stubLegacySound(page, true, 0.9); + await installMediaStubs(page, existing); + await page.goto("/settings/notifications"); + + await expect(page.getByTestId("opencode-browser-sound")).not.toBeChecked(); + await expect(page.getByTestId("opencode-browser-volume")).toHaveValue("0.25"); + await expect(page.getByTestId("opencode-sound-profile")).toHaveValue("minimal"); + }); +}); diff --git a/tests/notification-media.test.ts b/tests/notification-media.test.ts new file mode 100644 index 00000000..4903bc19 --- /dev/null +++ b/tests/notification-media.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_DEVICE_NOTIFICATION_PREFERENCES, + initializeDeviceNotificationPreferences, + loadDeviceNotificationPreferences, + notificationPhrase, + normalizeDeviceNotificationPreferences, + NOTIFICATION_MEDIA_STORAGE_KEY, + tonePattern, +} from "../client/lib/notificationMedia.js"; + +describe("device notification media preferences", () => { + function memoryStorage(initial?: string) { + const values = new Map(); + if (initial !== undefined) values.set(NOTIFICATION_MEDIA_STORAGE_KEY, initial); + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; + } + + it("clamps loaded values and restores missing event defaults", () => { + const preferences = normalizeDeviceNotificationPreferences({ + sound: { enabled: true, volume: 9, profile: "unknown", events: { idle: false } }, + speech: { enabled: true, rate: -2 }, + }); + + expect(preferences.sound).toMatchObject({ enabled: true, volume: 1, profile: "distinct" }); + expect(preferences.sound.events.idle).toBe(false); + expect(preferences.sound.events.permission).toBe(true); + expect(preferences.speech).toEqual({ enabled: true, rate: 0.7 }); + }); + + it("migrates legacy sound only when device storage is absent", () => { + const storage = memoryStorage(); + const loaded = loadDeviceNotificationPreferences(storage); + expect(loaded.state).toBe("absent"); + expect(storage.values.has(NOTIFICATION_MEDIA_STORAGE_KEY)).toBe(false); + + const initialized = initializeDeviceNotificationPreferences({ sound: true, volume: 0.85 }, storage); + expect(initialized.migrated).toBe(true); + expect(initialized.preferences.sound).toMatchObject({ enabled: true, volume: 0.85 }); + expect(JSON.parse(storage.values.get(NOTIFICATION_MEDIA_STORAGE_KEY) ?? "null").sound.volume).toBe(0.85); + }); + + it("preserves a present device preference over legacy sound", () => { + const existing = normalizeDeviceNotificationPreferences({ sound: { enabled: false, volume: 0.2, profile: "minimal" } }); + const storage = memoryStorage(JSON.stringify(existing)); + const initialized = initializeDeviceNotificationPreferences({ sound: true, volume: 0.9 }, storage); + + expect(initialized.migrated).toBe(false); + expect(initialized.preferences.sound).toMatchObject({ enabled: false, volume: 0.2, profile: "minimal" }); + }); + + it("resets corrupt storage without migrating legacy sound", () => { + const storage = memoryStorage("not-json"); + const initialized = initializeDeviceNotificationPreferences({ sound: true, volume: 0.9 }, storage); + + expect(initialized).toMatchObject({ state: "corrupt", migrated: false }); + expect(initialized.preferences).toEqual(DEFAULT_DEVICE_NOTIFICATION_PREFERENCES); + expect(JSON.parse(storage.values.get(NOTIFICATION_MEDIA_STORAGE_KEY) ?? "null")).toEqual(DEFAULT_DEVICE_NOTIFICATION_PREFERENCES); + }); + + it("maps only privacy-safe generic phrases", () => { + expect(notificationPhrase("idle")).toBe("Session finished"); + expect(notificationPhrase("permission")).toBe("OpenCode needs permission"); + expect(notificationPhrase("question")).toBe("OpenCode asked a question"); + expect(notificationPhrase("error")).toBe("Session failed"); + expect(notificationPhrase("parked")).toBe("Session is waiting for approval"); + expect(notificationPhrase("abort")).toBeNull(); + }); + + it("produces bounded, deterministic, distinct patterns", () => { + const idle = tonePattern("idle", "distinct"); + const permission = tonePattern("permission", "distinct"); + expect(idle).not.toEqual(permission); + expect(tonePattern("idle", "distinct")).toEqual(idle); + expect(idle.every((tone) => tone.duration <= 0.14 && tone.offset <= 0.65)).toBe(true); + expect(tonePattern("question", "minimal")).toHaveLength(1); + }); +});
EventBrowserntfy