diff --git a/apps/code/build/entitlements.mac.plist b/apps/code/build/entitlements.mac.plist index 0d3832ce4d..75dd72a95f 100644 --- a/apps/code/build/entitlements.mac.plist +++ b/apps/code/build/entitlements.mac.plist @@ -13,5 +13,9 @@ com.apple.security.cs.allow-jit + + + com.apple.security.device.audio-input + diff --git a/apps/code/forge.config.ts b/apps/code/forge.config.ts index b88e085c80..66ed49f658 100644 --- a/apps/code/forge.config.ts +++ b/apps/code/forge.config.ts @@ -173,11 +173,11 @@ const config: ForgeConfig = { extraResource: hasAssetsCar ? ["build/Assets.car", "build/app-icon.png"] : ["build/app-icon.png"], - extendInfo: hasAssetsCar - ? { - CFBundleIconName: "Icon", - } - : {}, + extendInfo: { + NSMicrophoneUsageDescription: + "PostHog Code uses your microphone to record a custom notification sound.", + ...(hasAssetsCar ? { CFBundleIconName: "Icon" } : {}), + }, ...(osxSignConfig ? { osxSign: osxSignConfig, diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index a5dd914288..a018e6f17b 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -200,6 +200,7 @@ container dockBounceNotifications: s.dockBounceNotifications, completionSound: s.completionSound, completionVolume: s.completionVolume, + customCompletionSound: s.customCompletionSound, }; }, }); diff --git a/packages/ui/src/features/notifications/identifiers.ts b/packages/ui/src/features/notifications/identifiers.ts index 3343baeedb..fc7a24b05c 100644 --- a/packages/ui/src/features/notifications/identifiers.ts +++ b/packages/ui/src/features/notifications/identifiers.ts @@ -6,6 +6,7 @@ export interface NotificationSettings { dockBounceNotifications: boolean; completionSound: CompletionSound; completionVolume: number; + customCompletionSound: string | null; } export interface INotificationSettings { diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index c4906818bd..3f88d1abdc 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -33,6 +33,7 @@ function makeService(overrides?: { dockBounceNotifications: true, completionSound: "meep", completionVolume: 80, + customCompletionSound: null, ...overrides?.settings, }; @@ -155,6 +156,29 @@ describe("TaskNotificationService", () => { ); }); + it.each([ + { + name: "plays a recorded custom sound and silences the OS notification", + customCompletionSound: "data:audio/webm;base64,AAAA", + expectedSilent: true, + }, + { + name: "is not silent when custom is selected but nothing was recorded", + customCompletionSound: null, + expectedSilent: false, + }, + ])("$name", ({ customCompletionSound, expectedSilent }) => { + const { service, notify, play } = makeService({ + hasFocus: false, + settings: { completionSound: "custom", customCompletionSound }, + }); + service.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(play).toHaveBeenCalledWith("custom", 80, customCompletionSound); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ silent: expectedSilent }), + ); + }); + it("truncates long titles", () => { const { service, notify } = makeService({ hasFocus: false }); const longTitle = "x".repeat(80); diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index 0c862b194b..bdcc20ae64 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -44,14 +44,21 @@ export class TaskNotificationService { if (!this.shouldNotify(taskId)) return; const settings = this.settings.get(); - const willPlayCustomSound = settings.completionSound !== "none"; - playCompletionSound(settings.completionSound, settings.completionVolume); + const willPlaySound = + settings.completionSound !== "none" && + (settings.completionSound !== "custom" || + Boolean(settings.customCompletionSound)); + playCompletionSound( + settings.completionSound, + settings.completionVolume, + settings.customCompletionSound, + ); if (settings.desktopNotifications) { this.notifications.notify({ title: "PostHog Code", body, - silent: willPlayCustomSound, + silent: willPlaySound, taskId, }); } diff --git a/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx b/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx new file mode 100644 index 0000000000..3711e40c46 --- /dev/null +++ b/packages/ui/src/features/settings/sections/CustomSoundRecorder.tsx @@ -0,0 +1,116 @@ +import { Microphone, Stop, Trash } from "@phosphor-icons/react"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { Button, Flex, Text } from "@radix-ui/themes"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +// Keep recordings short so the data URL stays small enough to persist in settings. +const MAX_RECORDING_MS = 5000; + +export function CustomSoundRecorder() { + const customCompletionSound = useSettingsStore( + (s) => s.customCompletionSound, + ); + const setCustomCompletionSound = useSettingsStore( + (s) => s.setCustomCompletionSound, + ); + + const [isRecording, setIsRecording] = useState(false); + const recorderRef = useRef(null); + const stopTimeoutRef = useRef | null>(null); + + const stopRecording = useCallback(() => { + if (stopTimeoutRef.current) { + clearTimeout(stopTimeoutRef.current); + stopTimeoutRef.current = null; + } + if (recorderRef.current?.state === "recording") { + recorderRef.current.stop(); + } + }, []); + + const startRecording = useCallback(async () => { + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + toast.error("Microphone access denied", { + description: + "Allow microphone access for PostHog Code in System Settings > Privacy & Security > Microphone.", + }); + return; + } + + const chunks: Blob[] = []; + const recorder = new MediaRecorder(stream); + recorder.ondataavailable = (event) => { + if (event.data.size > 0) chunks.push(event.data); + }; + recorder.onstop = () => { + for (const track of stream.getTracks()) track.stop(); + setIsRecording(false); + const blob = new Blob(chunks, { + type: recorder.mimeType || "audio/webm", + }); + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + setCustomCompletionSound(reader.result); + } + }; + reader.onerror = () => { + toast.error("Couldn't save recording", { + description: "Reading the recorded audio failed. Please try again.", + }); + }; + reader.readAsDataURL(blob); + }; + + recorderRef.current = recorder; + recorder.start(); + setIsRecording(true); + stopTimeoutRef.current = setTimeout(stopRecording, MAX_RECORDING_MS); + }, [setCustomCompletionSound, stopRecording]); + + // Stop any in-flight recording and release the mic if the view unmounts. + useEffect(() => { + return () => { + if (stopTimeoutRef.current) clearTimeout(stopTimeoutRef.current); + if (recorderRef.current?.state === "recording") { + recorderRef.current.stop(); + } + }; + }, []); + + return ( + + {isRecording ? ( + + ) : ( + + )} + {customCompletionSound && !isRecording && ( + + )} + {!customCompletionSound && !isRecording && ( + + No recording yet + + )} + + ); +} diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 9702e59a72..0bcc6ddd43 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -8,6 +8,7 @@ import { type CollapseMode, } from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig"; import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; +import { CustomSoundRecorder } from "@posthog/ui/features/settings/sections/CustomSoundRecorder"; import { type AutoConvertLongText, type CompletionSound, @@ -81,6 +82,7 @@ export function GeneralSettings() { dockBounceNotifications, completionSound, completionVolume, + customCompletionSound, autoConvertLongText, defaultInitialTaskMode, defaultMessagingMode, @@ -163,8 +165,16 @@ export function GeneralSettings() { ); const handleTestSound = useCallback(() => { - playCompletionSound(completionSound, completionVolume); - }, [completionSound, completionVolume]); + playCompletionSound( + completionSound, + completionVolume, + customCompletionSound, + ); + }, [completionSound, completionVolume, customCompletionSound]); + + const canTestSound = + completionSound !== "none" && + (completionSound !== "custom" || Boolean(customCompletionSound)); const handleAutoConvertLongTextChange = useCallback( (value: AutoConvertLongText) => { @@ -383,9 +393,10 @@ export function GeneralSettings() { Switch Wilhelm scream ICQ + Custom (record) - {completionSound !== "none" && ( + {canTestSound && ( @@ -393,7 +404,17 @@ export function GeneralSettings() { - {completionSound !== "none" && ( + {completionSound === "custom" && ( + + + + )} + + {canTestSound && ( void; setDockBadgeNotifications: (enabled: boolean) => void; setDockBounceNotifications: (enabled: boolean) => void; setCompletionSound: (sound: CompletionSound) => void; setCompletionVolume: (volume: number) => void; + setCustomCompletionSound: (dataUrl: string | null) => void; // Composer / chat autoConvertLongText: AutoConvertLongText; @@ -213,6 +217,7 @@ export const useSettingsStore = create()( dockBounceNotifications: false, completionSound: "none", completionVolume: 80, + customCompletionSound: null, setDesktopNotifications: (enabled) => set({ desktopNotifications: enabled }), setDockBadgeNotifications: (enabled) => @@ -221,6 +226,8 @@ export const useSettingsStore = create()( set({ dockBounceNotifications: enabled }), setCompletionSound: (sound) => set({ completionSound: sound }), setCompletionVolume: (volume) => set({ completionVolume: volume }), + setCustomCompletionSound: (dataUrl) => + set({ customCompletionSound: dataUrl }), // Composer / chat autoConvertLongText: "2500", @@ -324,6 +331,7 @@ export const useSettingsStore = create()( dockBounceNotifications: state.dockBounceNotifications, completionSound: state.completionSound, completionVolume: state.completionVolume, + customCompletionSound: state.customCompletionSound, // Composer / chat autoConvertLongText: state.autoConvertLongText, diff --git a/packages/ui/src/utils/sounds.ts b/packages/ui/src/utils/sounds.ts index ae61f978bb..a7917a4aba 100644 --- a/packages/ui/src/utils/sounds.ts +++ b/packages/ui/src/utils/sounds.ts @@ -14,7 +14,10 @@ import slideUrl from "../assets/sounds/slide.mp3"; import switchUrl from "../assets/sounds/switch.mp3"; import wilhelmUrl from "../assets/sounds/wilhelm.mp3"; -const SOUND_URLS: Record, string> = { +const SOUND_URLS: Record< + Exclude, + string +> = { guitar: guitarUrl, danilo: daniloUrl, revi: reviUrl, @@ -33,10 +36,14 @@ const SOUND_URLS: Record, string> = { let currentAudio: HTMLAudioElement | null = null; -export function playCompletionSound(sound: CompletionSound, volume = 80): void { +export function playCompletionSound( + sound: CompletionSound, + volume = 80, + customSoundUrl?: string | null, +): void { if (sound === "none") return; - const url = SOUND_URLS[sound]; + const url = sound === "custom" ? customSoundUrl : SOUND_URLS[sound]; if (!url) return; if (currentAudio) {