Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions client/lib/notificationMedia.ts
Original file line number Diff line number Diff line change
@@ -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<NotifyEvent, boolean>;
};
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<NotifyEvent, boolean>;

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<string, unknown> : {};
const sound = source.sound && typeof source.sound === "object" ? source.sound as Record<string, unknown> : {};
const speech = source.speech && typeof source.speech === "object" ? source.speech as Record<string, unknown> : {};
const eventSource = sound.events && typeof sound.events === "object" ? sound.events as Record<string, unknown> : {};
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<NotifyEvent, boolean>,
},
speech: {
enabled: speech.enabled === true,
rate: clamp(speech.rate, 0.7, 1.3, 1),
},
};
}

export function loadDeviceNotificationPreferences(storage?: Pick<Storage, "getItem">): 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<Storage, "getItem" | "setItem">,
): 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<Storage, "setItem">,
): 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<NotifyEvent, number[]> = {
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",
}));
}
83 changes: 83 additions & 0 deletions client/lib/notificationMediaBrowser.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
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;
}
41 changes: 25 additions & 16 deletions client/lib/useNotifyWatcher.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): NotifyEvent | null {
if (type === "session.idle") return "idle";
Expand All @@ -16,28 +23,16 @@ function classify(type: string, properties: Record<string, unknown>): 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 &&
Expand All @@ -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<string, number>();
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<string, unknown>; click?: string };
Expand All @@ -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);
};
}, []);
}
Loading
Loading