diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts
index 0de8a79d347..55663a86558 100644
--- a/apps/desktop/src/lib/trpc/routers/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/index.ts
@@ -6,6 +6,7 @@ import { createExternalRouter } from "./external";
import { createMenuRouter } from "./menu";
import { createNotificationsRouter } from "./notifications";
import { createProjectsRouter } from "./projects";
+import { createRingtoneRouter } from "./ringtone";
import { createSettingsRouter } from "./settings";
import { createTerminalRouter } from "./terminal";
import { createUiStateRouter } from "./ui-state";
@@ -32,6 +33,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
settings: createSettingsRouter(),
config: createConfigRouter(),
uiState: createUiStateRouter(),
+ ringtone: createRingtoneRouter(),
});
};
diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts
new file mode 100644
index 00000000000..ea41aba3612
--- /dev/null
+++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts
@@ -0,0 +1,177 @@
+import type { ChildProcess } from "node:child_process";
+import { execFile } from "node:child_process";
+import { existsSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+import { app } from "electron";
+import { z } from "zod";
+import { publicProcedure, router } from "../..";
+
+/**
+ * Track current playing session to handle race conditions.
+ * Each play operation gets a unique session ID. When stop is called,
+ * the session is invalidated so any pending fallback processes won't start.
+ */
+let currentSession: {
+ id: number;
+ process: ChildProcess | null;
+} | null = null;
+let nextSessionId = 0;
+
+/**
+ * Gets the path to a ringtone sound file.
+ * In development, reads from src/resources. In production, reads from the bundled resources.
+ */
+function getRingtonePath(filename: string): string {
+ const isDev = !app.isPackaged;
+
+ if (isDev) {
+ return join(app.getAppPath(), "src/resources/sounds", filename);
+ }
+ return join(process.resourcesPath, "resources/sounds", filename);
+}
+
+/**
+ * Gets the directory containing ringtone files
+ */
+function getRingtonesDirectory(): string {
+ const isDev = !app.isPackaged;
+
+ if (isDev) {
+ return join(app.getAppPath(), "src/resources/sounds");
+ }
+ return join(process.resourcesPath, "resources/sounds");
+}
+
+/**
+ * Stops the currently playing sound and invalidates the session
+ */
+function stopCurrentSound(): void {
+ if (currentSession) {
+ if (currentSession.process) {
+ // Use SIGKILL for immediate termination (afplay doesn't always respond to SIGTERM)
+ currentSession.process.kill("SIGKILL");
+ }
+ currentSession = null;
+ }
+}
+
+/**
+ * Plays a sound file using platform-specific commands.
+ * Uses session tracking to prevent race conditions with fallback audio players.
+ */
+function playSoundFile(soundPath: string): void {
+ if (!existsSync(soundPath)) {
+ console.warn(`[ringtone] Sound file not found: ${soundPath}`);
+ return;
+ }
+
+ // Stop any currently playing sound first
+ stopCurrentSound();
+
+ // Create a new session for this play operation
+ const sessionId = nextSessionId++;
+ currentSession = { id: sessionId, process: null };
+
+ if (process.platform === "darwin") {
+ currentSession.process = execFile("afplay", [soundPath], () => {
+ // Only clear if this session is still active
+ if (currentSession?.id === sessionId) {
+ currentSession = null;
+ }
+ });
+ } else if (process.platform === "win32") {
+ currentSession.process = execFile(
+ "powershell",
+ ["-c", `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`],
+ () => {
+ if (currentSession?.id === sessionId) {
+ currentSession = null;
+ }
+ },
+ );
+ } else {
+ // Linux - try common audio players with race-safe fallback
+ currentSession.process = execFile("paplay", [soundPath], (error) => {
+ // Check if this session is still active before proceeding
+ if (currentSession?.id !== sessionId) {
+ return; // Session was stopped, don't start fallback
+ }
+
+ if (error) {
+ // paplay failed, try aplay as fallback
+ currentSession.process = execFile("aplay", [soundPath], () => {
+ if (currentSession?.id === sessionId) {
+ currentSession = null;
+ }
+ });
+ } else {
+ currentSession = null;
+ }
+ });
+ }
+}
+
+/**
+ * Ringtone router for audio preview and playback operations
+ */
+export const createRingtoneRouter = () => {
+ return router({
+ /**
+ * Preview a ringtone sound by filename
+ */
+ preview: publicProcedure
+ .input(z.object({ filename: z.string() }))
+ .mutation(({ input }) => {
+ // Handle "none" case - no sound
+ if (!input.filename || input.filename === "") {
+ return { success: true as const };
+ }
+
+ const soundPath = getRingtonePath(input.filename);
+ playSoundFile(soundPath);
+ return { success: true as const };
+ }),
+
+ /**
+ * Stop the currently playing ringtone preview
+ */
+ stop: publicProcedure.mutation(() => {
+ stopCurrentSound();
+ return { success: true as const };
+ }),
+
+ /**
+ * Get the list of available ringtone files from the sounds directory
+ */
+ list: publicProcedure.query(() => {
+ const ringtonesDir = getRingtonesDirectory();
+ const files: string[] = [];
+
+ // Add ringtones from the sounds directory if it exists
+ if (existsSync(ringtonesDir)) {
+ const dirFiles = readdirSync(ringtonesDir).filter(
+ (file) =>
+ file.endsWith(".mp3") ||
+ file.endsWith(".wav") ||
+ file.endsWith(".ogg"),
+ );
+ files.push(...dirFiles);
+ }
+
+ return files;
+ }),
+ });
+};
+
+/**
+ * Plays the notification sound based on the selected ringtone.
+ * This is used by the notification system.
+ */
+export function playNotificationRingtone(filename: string): void {
+ if (!filename || filename === "") {
+ return; // No sound for "none" option
+ }
+
+ const soundPath = getRingtonePath(filename);
+ playSoundFile(soundPath);
+}
diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts
index cfc1097e59a..0007907dbe6 100644
--- a/apps/desktop/src/lib/trpc/routers/settings/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts
@@ -1,8 +1,12 @@
import { db } from "main/lib/db";
import { nanoid } from "nanoid";
+import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones";
import { z } from "zod";
import { publicProcedure, router } from "../..";
+/** Valid ringtone IDs for validation */
+const VALID_RINGTONE_IDS = RINGTONES.map((r) => r.id);
+
export const createSettingsRouter = () => {
return router({
getLastUsedApp: publicProcedure.query(() => {
@@ -76,6 +80,44 @@ export const createSettingsRouter = () => {
);
});
+ return { success: true };
+ }),
+
+ getSelectedRingtoneId: publicProcedure.query(async () => {
+ const storedId = db.data.settings.selectedRingtoneId;
+
+ // If no stored ID, return default
+ if (!storedId) {
+ return DEFAULT_RINGTONE_ID;
+ }
+
+ // If stored ID is valid, return it
+ if (VALID_RINGTONE_IDS.includes(storedId)) {
+ return storedId;
+ }
+
+ // Stored ID is invalid/stale - self-heal by persisting the default
+ console.warn(
+ `[settings] Invalid ringtone ID "${storedId}" found, resetting to default`,
+ );
+ await db.update((data) => {
+ data.settings.selectedRingtoneId = DEFAULT_RINGTONE_ID;
+ });
+ return DEFAULT_RINGTONE_ID;
+ }),
+
+ setSelectedRingtoneId: publicProcedure
+ .input(z.object({ ringtoneId: z.string() }))
+ .mutation(async ({ input }) => {
+ // Validate ringtone ID exists
+ if (!VALID_RINGTONE_IDS.includes(input.ringtoneId)) {
+ throw new Error(`Invalid ringtone ID: ${input.ringtoneId}`);
+ }
+
+ await db.update((data) => {
+ data.settings.selectedRingtoneId = input.ringtoneId;
+ });
+
return { success: true };
}),
});
diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts
index 91cb77db130..81bf2053c75 100644
--- a/apps/desktop/src/main/lib/db/schemas.ts
+++ b/apps/desktop/src/main/lib/db/schemas.ts
@@ -107,6 +107,7 @@ export interface Settings {
lastActiveWorkspaceId?: string;
lastUsedApp?: ExternalApp;
terminalPresets?: TerminalPreset[];
+ selectedRingtoneId?: string;
}
export interface Database {
diff --git a/apps/desktop/src/main/lib/notification-sound.ts b/apps/desktop/src/main/lib/notification-sound.ts
index c858be84a53..08ce0b92757 100644
--- a/apps/desktop/src/main/lib/notification-sound.ts
+++ b/apps/desktop/src/main/lib/notification-sound.ts
@@ -2,28 +2,53 @@ import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { app } from "electron";
+import {
+ DEFAULT_RINGTONE_ID,
+ getRingtoneFilename,
+} from "../../shared/ringtones";
+import { db } from "./db";
/**
- * Gets the path to the notification sound file.
+ * Gets the path to a ringtone sound file.
* In development, reads from src/resources. In production, reads from the bundled resources.
*/
-function getNotificationSoundPath(): string {
+function getRingtonePath(filename: string): string {
const isDev = !app.isPackaged;
if (isDev) {
- return join(app.getAppPath(), "src/resources/sounds/arcade.mp3");
+ return join(app.getAppPath(), "src/resources/sounds", filename);
}
-
- return join(process.resourcesPath, "resources/sounds/arcade.mp3");
+ return join(process.resourcesPath, "resources/sounds", filename);
}
/**
- * Plays the custom notification sound.
- * Uses platform-specific commands to play the audio file.
+ * Gets the selected ringtone filename from the database.
+ * Falls back to default ringtone if the stored ID is invalid/stale.
*/
-export function playNotificationSound(): void {
- const soundPath = getNotificationSoundPath();
+function getSelectedRingtoneFilename(): string {
+ const defaultFilename = getRingtoneFilename(DEFAULT_RINGTONE_ID);
+
+ try {
+ const selectedId =
+ db.data.settings.selectedRingtoneId ?? DEFAULT_RINGTONE_ID;
+
+ // "none" means silent - return empty string intentionally
+ if (selectedId === "none") {
+ return "";
+ }
+ const filename = getRingtoneFilename(selectedId);
+ // Fall back to default if stored ID is stale/unknown
+ return filename || defaultFilename;
+ } catch {
+ return defaultFilename;
+ }
+}
+
+/**
+ * Plays a sound file using platform-specific commands
+ */
+function playSoundFile(soundPath: string): void {
if (!existsSync(soundPath)) {
console.warn(`[notification-sound] Sound file not found: ${soundPath}`);
return;
@@ -45,3 +70,19 @@ export function playNotificationSound(): void {
});
}
}
+
+/**
+ * Plays the notification sound based on user's selected ringtone.
+ * Uses platform-specific commands to play the audio file.
+ */
+export function playNotificationSound(): void {
+ const filename = getSelectedRingtoneFilename();
+
+ // No sound if "none" is selected
+ if (!filename) {
+ return;
+ }
+
+ const soundPath = getRingtonePath(filename);
+ playSoundFile(soundPath);
+}
diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts
index d91e47be3c7..0d855eca06f 100644
--- a/apps/desktop/src/renderer/lib/trpc-storage.ts
+++ b/apps/desktop/src/renderer/lib/trpc-storage.ts
@@ -60,3 +60,23 @@ export const trpcThemeStorage = createJSONStorage(() =>
set: (input) => trpcClient.uiState.theme.set.mutate(input as any),
}),
);
+
+/**
+ * Zustand storage adapter for ringtone state using tRPC.
+ * Only the selectedRingtoneId is persisted.
+ */
+export const trpcRingtoneStorage = createJSONStorage(() =>
+ createTrpcStorageAdapter({
+ get: async () => {
+ const ringtoneId =
+ await trpcClient.settings.getSelectedRingtoneId.query();
+ return { selectedRingtoneId: ringtoneId };
+ },
+ set: async (input) => {
+ const state = input as { selectedRingtoneId: string };
+ await trpcClient.settings.setSelectedRingtoneId.mutate({
+ ringtoneId: state.selectedRingtoneId,
+ });
+ },
+ }),
+);
diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/RingtonesSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/RingtonesSettings.tsx
new file mode 100644
index 00000000000..85d4d7fee61
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/RingtonesSettings.tsx
@@ -0,0 +1,267 @@
+import { cn } from "@superset/ui/utils";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { HiBellSlash, HiCheck, HiPlay, HiStop } from "react-icons/hi2";
+import { trpcClient } from "renderer/lib/trpc-client";
+import {
+ AVAILABLE_RINGTONES,
+ type Ringtone,
+ useSelectedRingtoneId,
+ useSetRingtone,
+} from "renderer/stores";
+
+function formatDuration(seconds: number): string {
+ return `${seconds}s`;
+}
+
+interface RingtoneCardProps {
+ ringtone: Ringtone;
+ isSelected: boolean;
+ isPlaying: boolean;
+ onSelect: () => void;
+ onTogglePlay: () => void;
+}
+
+function RingtoneCard({
+ ringtone,
+ isSelected,
+ isPlaying,
+ onSelect,
+ onTogglePlay,
+}: RingtoneCardProps) {
+ const isSilent = ringtone.id === "none";
+
+ // Silent card has a distinct style
+ if (isSilent) {
+ return (
+
+ );
+ }
+
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: Using div with role="button" to allow nested play/stop button
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onSelect();
+ }
+ }}
+ className={cn(
+ "relative flex flex-col rounded-lg border-2 overflow-hidden transition-all text-left cursor-pointer",
+ isSelected
+ ? "border-primary ring-2 ring-primary/20"
+ : "border-border hover:border-muted-foreground/50",
+ )}
+ >
+ {/* Preview area */}
+
+ {/* Emoji */}
+ {ringtone.emoji}
+
+ {/* Duration badge */}
+ {ringtone.duration && (
+
+ {formatDuration(ringtone.duration)}
+
+ )}
+
+ {/* Play/Stop button */}
+
+
+
+ {/* Info */}
+
+
+
{ringtone.name}
+
+ {ringtone.description}
+
+
+ {isSelected && (
+
+
+
+ )}
+
+
+ );
+}
+
+export function RingtonesSettings() {
+ const selectedRingtoneId = useSelectedRingtoneId();
+ const setRingtone = useSetRingtone();
+ const [playingId, setPlayingId] = useState(null);
+ const previewTimerRef = useRef | null>(null);
+
+ // Clean up timer and stop any playing sound on unmount
+ useEffect(() => {
+ return () => {
+ if (previewTimerRef.current) {
+ clearTimeout(previewTimerRef.current);
+ }
+ // Stop any in-progress preview when navigating away
+ trpcClient.ringtone.stop.mutate().catch(() => {
+ // Ignore errors during cleanup
+ });
+ };
+ }, []);
+
+ const handleTogglePlay = useCallback(
+ async (ringtone: Ringtone) => {
+ if (ringtone.id === "none" || !ringtone.filename) {
+ return;
+ }
+
+ // Clear any pending timer
+ if (previewTimerRef.current) {
+ clearTimeout(previewTimerRef.current);
+ previewTimerRef.current = null;
+ }
+
+ // If this ringtone is already playing, stop it
+ if (playingId === ringtone.id) {
+ try {
+ await trpcClient.ringtone.stop.mutate();
+ } catch (error) {
+ console.error("Failed to stop ringtone:", error);
+ }
+ setPlayingId(null);
+ return;
+ }
+
+ // Stop any currently playing sound first
+ try {
+ await trpcClient.ringtone.stop.mutate();
+ } catch (error) {
+ console.error("Failed to stop ringtone:", error);
+ }
+
+ // Play the new sound
+ setPlayingId(ringtone.id);
+
+ try {
+ await trpcClient.ringtone.preview.mutate({
+ filename: ringtone.filename,
+ });
+ } catch (error) {
+ console.error("Failed to play ringtone:", error);
+ setPlayingId(null);
+ }
+
+ // Auto-reset after the ringtone's actual duration (with 500ms buffer)
+ const durationMs = ((ringtone.duration ?? 5) + 0.5) * 1000;
+ previewTimerRef.current = setTimeout(() => {
+ setPlayingId((current) => (current === ringtone.id ? null : current));
+ previewTimerRef.current = null;
+ }, durationMs);
+ },
+ [playingId],
+ );
+
+ const handleSelect = useCallback(
+ (ringtoneId: string) => {
+ setRingtone(ringtoneId);
+ },
+ [setRingtone],
+ );
+
+ return (
+
+
+
Ringtones
+
+ Choose the notification sound for completed tasks
+
+
+
+
+ {/* Ringtone Section */}
+
+
Notification Sound
+
+ {AVAILABLE_RINGTONES.map((ringtone) => (
+ handleSelect(ringtone.id)}
+ onTogglePlay={() => handleTogglePlay(ringtone)}
+ />
+ ))}
+
+
+
+ {/* Tip */}
+
+
+ Click the play button to preview a sound. Click stop or play another
+ to stop the current sound.
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/index.ts b/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/index.ts
new file mode 100644
index 00000000000..07f05ce9abc
--- /dev/null
+++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/RingtonesSettings/index.ts
@@ -0,0 +1 @@
+export { RingtonesSettings } from "./RingtonesSettings";
diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx
index 947c1c00b81..b5c85608158 100644
--- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsContent.tsx
@@ -3,6 +3,7 @@ import { AppearanceSettings } from "./AppearanceSettings";
import { KeyboardShortcutsSettings } from "./KeyboardShortcutsSettings";
import { PresetsSettings } from "./PresetsSettings";
import { ProjectSettings } from "./ProjectSettings";
+import { RingtonesSettings } from "./RingtonesSettings";
import { WorkspaceSettings } from "./WorkspaceSettings";
interface SettingsContentProps {
@@ -15,6 +16,7 @@ export function SettingsContent({ activeSection }: SettingsContentProps) {
{activeSection === "project" && }
{activeSection === "workspace" && }
{activeSection === "appearance" && }
+ {activeSection === "ringtones" && }
{activeSection === "keyboard" && }
{activeSection === "presets" && }
diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx
index c51082c02f3..afa75ffb669 100644
--- a/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/SettingsSidebar/GeneralSettings.tsx
@@ -1,5 +1,6 @@
import { cn } from "@superset/ui/utils";
import {
+ HiOutlineBell,
HiOutlineCog6Tooth,
HiOutlineCommandLine,
HiOutlinePaintBrush,
@@ -21,6 +22,11 @@ const GENERAL_SECTIONS: {
label: "Appearance",
icon: ,
},
+ {
+ id: "ringtones",
+ label: "Ringtones",
+ icon: ,
+ },
{
id: "keyboard",
label: "Keyboard Shortcuts",
diff --git a/apps/desktop/src/renderer/stores/app-state.ts b/apps/desktop/src/renderer/stores/app-state.ts
index d5291d2889e..1f7890321b1 100644
--- a/apps/desktop/src/renderer/stores/app-state.ts
+++ b/apps/desktop/src/renderer/stores/app-state.ts
@@ -7,7 +7,8 @@ export type SettingsSection =
| "workspace"
| "appearance"
| "keyboard"
- | "presets";
+ | "presets"
+ | "ringtones";
interface AppState {
currentView: AppView;
diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts
index eeef9957e68..f27ff6aefdc 100644
--- a/apps/desktop/src/renderer/stores/index.ts
+++ b/apps/desktop/src/renderer/stores/index.ts
@@ -5,6 +5,7 @@
*/
export * from "./app-state";
+export * from "./ringtone";
export * from "./sidebar-state";
export * from "./tabs"; // Now exports from tabs/index.ts
export * from "./theme";
diff --git a/apps/desktop/src/renderer/stores/ringtone/index.ts b/apps/desktop/src/renderer/stores/ringtone/index.ts
new file mode 100644
index 00000000000..f5990c259bc
--- /dev/null
+++ b/apps/desktop/src/renderer/stores/ringtone/index.ts
@@ -0,0 +1 @@
+export * from "./store";
diff --git a/apps/desktop/src/renderer/stores/ringtone/store.ts b/apps/desktop/src/renderer/stores/ringtone/store.ts
new file mode 100644
index 00000000000..52fa6c8cab4
--- /dev/null
+++ b/apps/desktop/src/renderer/stores/ringtone/store.ts
@@ -0,0 +1,103 @@
+import { create } from "zustand";
+import { devtools, persist } from "zustand/middleware";
+import {
+ DEFAULT_RINGTONE_ID,
+ RINGTONES,
+ type RingtoneData,
+} from "../../../shared/ringtones";
+import { trpcRingtoneStorage } from "../../lib/trpc-storage";
+
+// Re-export shared types and data for convenience
+export type Ringtone = RingtoneData;
+export const AVAILABLE_RINGTONES = RINGTONES;
+export { DEFAULT_RINGTONE_ID };
+
+interface RingtoneState {
+ /** Current selected ringtone ID */
+ selectedRingtoneId: string;
+
+ /** Set the active ringtone by ID */
+ setRingtone: (ringtoneId: string) => void;
+
+ /** Get the currently selected ringtone (always returns valid ringtone, falls back to default) */
+ getSelectedRingtone: () => Ringtone;
+}
+
+/** Check if a ringtone ID is valid */
+function isValidRingtoneId(id: string): boolean {
+ return AVAILABLE_RINGTONES.some((r) => r.id === id);
+}
+
+/** Get default ringtone (guaranteed to exist) */
+function getDefaultRingtone(): Ringtone {
+ const ringtone = AVAILABLE_RINGTONES.find(
+ (r) => r.id === DEFAULT_RINGTONE_ID,
+ );
+ if (!ringtone) {
+ throw new Error(`Default ringtone "${DEFAULT_RINGTONE_ID}" not found`);
+ }
+ return ringtone;
+}
+
+export const useRingtoneStore = create()(
+ devtools(
+ persist(
+ (set, get) => ({
+ selectedRingtoneId: DEFAULT_RINGTONE_ID,
+
+ setRingtone: (ringtoneId: string) => {
+ const ringtone = AVAILABLE_RINGTONES.find((r) => r.id === ringtoneId);
+ if (!ringtone) {
+ console.error(`Ringtone not found: ${ringtoneId}`);
+ return;
+ }
+ set({ selectedRingtoneId: ringtoneId });
+ },
+
+ getSelectedRingtone: () => {
+ const state = get();
+ const ringtone = AVAILABLE_RINGTONES.find(
+ (r) => r.id === state.selectedRingtoneId,
+ );
+ // Fall back to default if persisted ID is stale/invalid
+ if (!ringtone) {
+ set({ selectedRingtoneId: DEFAULT_RINGTONE_ID });
+ return getDefaultRingtone();
+ }
+ return ringtone;
+ },
+ }),
+ {
+ name: "ringtone-storage",
+ storage: trpcRingtoneStorage,
+ partialize: (state) => ({
+ selectedRingtoneId: state.selectedRingtoneId,
+ }),
+ onRehydrateStorage: () => (state) => {
+ // Validate persisted ringtone ID on rehydration
+ if (state && !isValidRingtoneId(state.selectedRingtoneId)) {
+ console.warn(
+ `[RingtoneStore] Invalid ringtone ID "${state.selectedRingtoneId}", resetting to default`,
+ );
+ state.selectedRingtoneId = DEFAULT_RINGTONE_ID;
+ }
+ },
+ },
+ ),
+ { name: "RingtoneStore" },
+ ),
+);
+
+// Convenience hooks
+export const useSelectedRingtoneId = () =>
+ useRingtoneStore((state) => state.selectedRingtoneId);
+export const useSetRingtone = () =>
+ useRingtoneStore((state) => state.setRingtone);
+export const useSelectedRingtone = () =>
+ useRingtoneStore((state) => {
+ const ringtone = AVAILABLE_RINGTONES.find(
+ (r) => r.id === state.selectedRingtoneId,
+ );
+ // Fall back to default if ID is invalid
+ return ringtone ?? getDefaultRingtone();
+ });
diff --git a/apps/desktop/src/resources/sounds/agentisdonewoman.mp3 b/apps/desktop/src/resources/sounds/agentisdonewoman.mp3
new file mode 100644
index 00000000000..da37f0b5e91
Binary files /dev/null and b/apps/desktop/src/resources/sounds/agentisdonewoman.mp3 differ
diff --git a/apps/desktop/src/resources/sounds/codecompleteafrican.mp3 b/apps/desktop/src/resources/sounds/codecompleteafrican.mp3
new file mode 100644
index 00000000000..e0aa187851a
Binary files /dev/null and b/apps/desktop/src/resources/sounds/codecompleteafrican.mp3 differ
diff --git a/apps/desktop/src/resources/sounds/codecompleteafrobeat.mp3 b/apps/desktop/src/resources/sounds/codecompleteafrobeat.mp3
new file mode 100644
index 00000000000..3e627a87be8
Binary files /dev/null and b/apps/desktop/src/resources/sounds/codecompleteafrobeat.mp3 differ
diff --git a/apps/desktop/src/resources/sounds/codecompleteedm.mp3 b/apps/desktop/src/resources/sounds/codecompleteedm.mp3
new file mode 100644
index 00000000000..85a370c65cb
Binary files /dev/null and b/apps/desktop/src/resources/sounds/codecompleteedm.mp3 differ
diff --git a/apps/desktop/src/resources/sounds/comebacktothecode.mp3 b/apps/desktop/src/resources/sounds/comebacktothecode.mp3
new file mode 100644
index 00000000000..75969f8c3b1
Binary files /dev/null and b/apps/desktop/src/resources/sounds/comebacktothecode.mp3 differ
diff --git a/apps/desktop/src/resources/sounds/shabalabadingdong.mp3 b/apps/desktop/src/resources/sounds/shabalabadingdong.mp3
new file mode 100644
index 00000000000..ca56b1900ae
Binary files /dev/null and b/apps/desktop/src/resources/sounds/shabalabadingdong.mp3 differ
diff --git a/apps/desktop/src/resources/sounds/supersetdoowap.mp3 b/apps/desktop/src/resources/sounds/supersetdoowap.mp3
new file mode 100644
index 00000000000..d29a8662779
Binary files /dev/null and b/apps/desktop/src/resources/sounds/supersetdoowap.mp3 differ
diff --git a/apps/desktop/src/resources/sounds/supersetquick.mp3 b/apps/desktop/src/resources/sounds/supersetquick.mp3
new file mode 100644
index 00000000000..01729118099
Binary files /dev/null and b/apps/desktop/src/resources/sounds/supersetquick.mp3 differ
diff --git a/apps/desktop/src/shared/ringtones.ts b/apps/desktop/src/shared/ringtones.ts
new file mode 100644
index 00000000000..4f6c0ce087e
--- /dev/null
+++ b/apps/desktop/src/shared/ringtones.ts
@@ -0,0 +1,129 @@
+/**
+ * Shared ringtone data used by both main and renderer processes.
+ * This is the single source of truth for ringtone metadata.
+ */
+
+export interface RingtoneData {
+ id: string;
+ name: string;
+ description: string;
+ filename: string;
+ emoji: string;
+ color: string;
+ /** Duration in seconds */
+ duration?: number;
+}
+
+/**
+ * Built-in ringtones available in the app.
+ * Files are located in src/resources/sounds/
+ */
+export const RINGTONES: RingtoneData[] = [
+ {
+ id: "default",
+ name: "Classic",
+ description: "Simple & clean",
+ filename: "notification.mp3",
+ emoji: "🔔",
+ color: "from-slate-500 to-slate-600",
+ duration: 1,
+ },
+ {
+ id: "quick",
+ name: "Quick Ping",
+ description: "Short & sweet",
+ filename: "supersetquick.mp3",
+ emoji: "⚡",
+ color: "from-yellow-400 to-orange-500",
+ duration: 3,
+ },
+ {
+ id: "doowap",
+ name: "Doo-Wap",
+ description: "Retro vibes",
+ filename: "supersetdoowap.mp3",
+ emoji: "🎷",
+ color: "from-purple-500 to-pink-500",
+ duration: 10,
+ },
+ {
+ id: "woman",
+ name: "Agent Complete",
+ description: "Your agent is done!",
+ filename: "agentisdonewoman.mp3",
+ emoji: "👩💻",
+ color: "from-cyan-400 to-blue-500",
+ duration: 8,
+ },
+ {
+ id: "african",
+ name: "African Beats",
+ description: "World music energy",
+ filename: "codecompleteafrican.mp3",
+ emoji: "🌍",
+ color: "from-amber-500 to-red-500",
+ duration: 9,
+ },
+ {
+ id: "afrobeat",
+ name: "Afrobeat",
+ description: "Groovy celebration",
+ filename: "codecompleteafrobeat.mp3",
+ emoji: "🥁",
+ color: "from-green-400 to-emerald-600",
+ duration: 9,
+ },
+ {
+ id: "edm",
+ name: "EDM Drop",
+ description: "Bass goes brrrr",
+ filename: "codecompleteedm.mp3",
+ emoji: "🎧",
+ color: "from-violet-500 to-fuchsia-500",
+ duration: 56,
+ },
+ {
+ id: "comeback",
+ name: "Come Back!",
+ description: "Code needs you",
+ filename: "comebacktothecode.mp3",
+ emoji: "📢",
+ color: "from-rose-400 to-red-500",
+ duration: 7,
+ },
+ {
+ id: "shabala",
+ name: "Shabalaba",
+ description: "Ding dong vibes",
+ filename: "shabalabadingdong.mp3",
+ emoji: "🎉",
+ color: "from-indigo-400 to-purple-600",
+ duration: 7,
+ },
+ {
+ id: "none",
+ name: "Silent",
+ description: "Notifications without sound",
+ filename: "",
+ emoji: "🔇",
+ color: "from-gray-400 to-gray-500",
+ },
+];
+
+export const DEFAULT_RINGTONE_ID = "default";
+
+/**
+ * Get a ringtone by ID
+ */
+export function getRingtoneById(id: string): RingtoneData | undefined {
+ return RINGTONES.find((r) => r.id === id);
+}
+
+/**
+ * Get the filename for a ringtone ID.
+ * Returns empty string for "none" (silent) or if not found.
+ */
+export function getRingtoneFilename(id: string): string {
+ const ringtone = getRingtoneById(id);
+ return ringtone?.filename ?? "";
+}
diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts
index 2a90f26fdf6..ca1768df626 100644
--- a/apps/desktop/test-setup.ts
+++ b/apps/desktop/test-setup.ts
@@ -48,21 +48,6 @@ const mockClassList = new Set();
// Electron Preload Mocks (exposed via contextBridge in real app)
// =============================================================================
-const mockStorage = new Map();
-
-global.window = {
- electronStore: {
- get: async (key: string) => mockStorage.get(key) || null,
- set: async (key: string, value: string) => {
- mockStorage.set(key, value);
- },
- delete: async (key: string) => {
- mockStorage.delete(key);
- },
- },
- // biome-ignore lint/suspicious/noExplicitAny: Test setup requires partial window mock
-} as any;
-
// trpc-electron expects this global for renderer-side communication
// biome-ignore lint/suspicious/noExplicitAny: Test setup requires extending globalThis
(globalThis as any).electronTRPC = {