From de67831569d2e9f8c0563a0a124735b7e4648780 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 23 Feb 2026 09:07:51 -0800 Subject: [PATCH 1/4] feat(desktop): support custom notification audio files --- apps/desktop/src/lib/trpc/routers/index.ts | 2 +- .../src/lib/trpc/routers/ringtone/index.ts | 115 +++++++--- .../src/lib/trpc/routers/settings/index.ts | 23 +- apps/desktop/src/main/lib/custom-ringtones.ts | 210 ++++++++++++++++++ .../src/main/lib/notification-sound.ts | 22 +- .../RingtonesSettings/RingtonesSettings.tsx | 58 ++++- .../src/renderer/stores/ringtone/store.ts | 12 +- apps/desktop/src/shared/ringtones.ts | 6 + 8 files changed, 392 insertions(+), 56 deletions(-) create mode 100644 apps/desktop/src/main/lib/custom-ringtones.ts diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 5afa9709478..6ecd17c5bbe 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -50,7 +50,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { settings: createSettingsRouter(), config: createConfigRouter(), uiState: createUiStateRouter(), - ringtone: createRingtoneRouter(), + ringtone: createRingtoneRouter(getWindow), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts index c1868dbd1c4..7348109136f 100644 --- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -1,11 +1,21 @@ import type { ChildProcess } from "node:child_process"; import { execFile } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { z } from "zod"; +import { existsSync } from "node:fs"; +import { TRPCError } from "@trpc/server"; +import { dialog } from "electron"; +import type { BrowserWindow, OpenDialogOptions } from "electron"; +import { + getCustomRingtoneInfo, + getCustomRingtonePath, + importCustomRingtoneFromPath, +} from "main/lib/custom-ringtones"; +import { getSoundPath } from "main/lib/sound-paths"; import { - getSoundPath, - getSoundsDirectory, -} from "../../../../main/lib/sound-paths"; + CUSTOM_RINGTONE_ID, + getRingtoneFilename, + isBuiltInRingtoneId, +} from "shared/ringtones"; +import { z } from "zod"; import { publicProcedure, router } from "../.."; /** @@ -88,23 +98,43 @@ function playSoundFile(soundPath: string): void { } } +function getRingtoneSoundPath(ringtoneId: string): string | null { + if (!ringtoneId || ringtoneId === "") { + return null; + } + + if (ringtoneId === CUSTOM_RINGTONE_ID) { + return getCustomRingtonePath(); + } + + if (!isBuiltInRingtoneId(ringtoneId)) { + return null; + } + + const filename = getRingtoneFilename(ringtoneId); + if (!filename) { + return null; + } + + return getSoundPath(filename); +} + /** * Ringtone router for audio preview and playback operations */ -export const createRingtoneRouter = () => { +export const createRingtoneRouter = (getWindow: () => BrowserWindow | null) => { return router({ /** - * Preview a ringtone sound by filename + * Preview a ringtone by ringtone ID. */ preview: publicProcedure - .input(z.object({ filename: z.string() })) + .input(z.object({ ringtoneId: z.string() })) .mutation(({ input }) => { - // Handle "none" case - no sound - if (!input.filename || input.filename === "") { + const soundPath = getRingtoneSoundPath(input.ringtoneId); + if (!soundPath) { return { success: true as const }; } - const soundPath = getSoundPath(input.filename); playSoundFile(soundPath); return { success: true as const }; }), @@ -118,24 +148,49 @@ export const createRingtoneRouter = () => { }), /** - * Get the list of available ringtone files from the sounds directory + * Returns metadata for the imported custom ringtone, if one exists. */ - list: publicProcedure.query(() => { - const ringtonesDir = getSoundsDirectory(); - 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); + getCustom: publicProcedure.query(() => { + return getCustomRingtoneInfo(); + }), + + /** + * Imports a custom ringtone file from disk and stores it in the Superset home assets directory. + */ + importCustom: publicProcedure.mutation(async () => { + const window = getWindow(); + const openDialogOptions: OpenDialogOptions = { + properties: ["openFile"], + title: "Select Notification Sound", + filters: [ + { + name: "Audio", + extensions: ["mp3", "wav", "ogg"], + }, + ], + }; + const result = window + ? await dialog.showOpenDialog(window, openDialogOptions) + : await dialog.showOpenDialog(openDialogOptions); + + if (result.canceled || result.filePaths.length === 0) { + return { canceled: true as const, ringtone: null }; } - return files; + try { + const ringtone = await importCustomRingtoneFromPath( + result.filePaths[0], + ); + return { canceled: false as const, ringtone }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to import custom ringtone", + }); + } }), }); }; @@ -144,11 +199,11 @@ export const createRingtoneRouter = () => { * 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 +export function playNotificationRingtone(ringtoneId: string): void { + const soundPath = getRingtoneSoundPath(ringtoneId); + if (!soundPath) { + return; } - const soundPath = getSoundPath(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 5094fbc9bd7..f52214a6d43 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -9,6 +9,7 @@ import { import { TRPCError } from "@trpc/server"; import { app } from "electron"; import { quitWithoutConfirmation } from "main/index"; +import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { localDb } from "main/lib/local-db"; import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET, @@ -19,7 +20,11 @@ import { DEFAULT_SHOW_RESOURCE_MONITOR, DEFAULT_TERMINAL_LINK_BEHAVIOR, } from "shared/constants"; -import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; +import { + CUSTOM_RINGTONE_ID, + DEFAULT_RINGTONE_ID, + isBuiltInRingtoneId, +} from "shared/ringtones"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getGitAuthorName, getGitHubUsername } from "../workspaces/utils/git"; @@ -28,7 +33,17 @@ import { transformFontSettings, } from "./font-settings.utils"; -const VALID_RINGTONE_IDS = RINGTONES.map((r) => r.id); +function isValidRingtoneId(ringtoneId: string): boolean { + if (isBuiltInRingtoneId(ringtoneId)) { + return true; + } + + if (ringtoneId === CUSTOM_RINGTONE_ID) { + return hasCustomRingtone(); + } + + return false; +} function getSettings() { let row = localDb.select().from(settings).get(); @@ -357,7 +372,7 @@ export const createSettingsRouter = () => { return DEFAULT_RINGTONE_ID; } - if (VALID_RINGTONE_IDS.includes(storedId)) { + if (isValidRingtoneId(storedId)) { return storedId; } @@ -378,7 +393,7 @@ export const createSettingsRouter = () => { setSelectedRingtoneId: publicProcedure .input(z.object({ ringtoneId: z.string() })) .mutation(({ input }) => { - if (!VALID_RINGTONE_IDS.includes(input.ringtoneId)) { + if (!isValidRingtoneId(input.ringtoneId)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid ringtone ID: ${input.ringtoneId}`, diff --git a/apps/desktop/src/main/lib/custom-ringtones.ts b/apps/desktop/src/main/lib/custom-ringtones.ts new file mode 100644 index 00000000000..d63f8fc8d59 --- /dev/null +++ b/apps/desktop/src/main/lib/custom-ringtones.ts @@ -0,0 +1,210 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { copyFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; +import { CUSTOM_RINGTONE_ID } from "shared/ringtones"; +import { + SUPERSET_HOME_DIR, + SUPERSET_HOME_DIR_MODE, + SUPERSET_SENSITIVE_FILE_MODE, +} from "./app-environment"; + +const RINGTONES_ASSETS_DIR = join(SUPERSET_HOME_DIR, "assets", "ringtones"); +const CUSTOM_RINGTONE_FILE_STEM = "notification-custom"; +const CUSTOM_RINGTONE_METADATA_PATH = join( + RINGTONES_ASSETS_DIR, + `${CUSTOM_RINGTONE_FILE_STEM}.json`, +); +const MAX_CUSTOM_RINGTONE_SIZE_BYTES = 20 * 1024 * 1024; +const ALLOWED_AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".ogg"]); + +interface CustomRingtoneMetadata { + name?: string; + importedAt?: number; +} + +export interface CustomRingtoneInfo { + id: string; + name: string; + description: string; + emoji: string; +} + +function isAllowedAudioExtension(filePath: string): boolean { + return ALLOWED_AUDIO_EXTENSIONS.has(extname(filePath).toLowerCase()); +} + +function getCustomRingtoneFilename(): string | null { + if (!existsSync(RINGTONES_ASSETS_DIR)) { + return null; + } + + const candidates = readdirSync(RINGTONES_ASSETS_DIR).filter((file) => { + return ( + file.startsWith(`${CUSTOM_RINGTONE_FILE_STEM}.`) && + isAllowedAudioExtension(file) + ); + }); + + if (candidates.length === 0) { + return null; + } + + candidates.sort((a, b) => { + const aMtime = statSync(join(RINGTONES_ASSETS_DIR, a)).mtimeMs; + const bMtime = statSync(join(RINGTONES_ASSETS_DIR, b)).mtimeMs; + return bMtime - aMtime; + }); + + return candidates[0] ?? null; +} + +function removeExistingCustomRingtoneFiles(): void { + if (!existsSync(RINGTONES_ASSETS_DIR)) { + return; + } + + for (const file of readdirSync(RINGTONES_ASSETS_DIR)) { + if ( + file.startsWith(`${CUSTOM_RINGTONE_FILE_STEM}.`) && + isAllowedAudioExtension(file) + ) { + unlinkSync(join(RINGTONES_ASSETS_DIR, file)); + } + } +} + +function sanitizeDisplayName(filename: string): string { + const stripped = filename.replace(/\.[^/.]+$/, "").trim(); + if (!stripped) { + return "Custom Audio"; + } + return stripped.slice(0, 80); +} + +function readCustomRingtoneMetadata(): CustomRingtoneMetadata { + if (!existsSync(CUSTOM_RINGTONE_METADATA_PATH)) { + return {}; + } + + try { + const raw = readFileSync(CUSTOM_RINGTONE_METADATA_PATH, "utf-8"); + const parsed = JSON.parse(raw) as CustomRingtoneMetadata; + return parsed ?? {}; + } catch { + return {}; + } +} + +function writeCustomRingtoneMetadata(name: string): void { + writeFileSync( + CUSTOM_RINGTONE_METADATA_PATH, + JSON.stringify({ + name, + importedAt: Date.now(), + }), + "utf-8", + ); + + try { + chmodSync(CUSTOM_RINGTONE_METADATA_PATH, SUPERSET_SENSITIVE_FILE_MODE); + } catch { + // Best effort only. + } +} + +export function ensureCustomRingtonesDir(): void { + if (!existsSync(RINGTONES_ASSETS_DIR)) { + mkdirSync(RINGTONES_ASSETS_DIR, { + recursive: true, + mode: SUPERSET_HOME_DIR_MODE, + }); + } + + try { + chmodSync(RINGTONES_ASSETS_DIR, SUPERSET_HOME_DIR_MODE); + } catch { + // Best effort only. + } +} + +export function hasCustomRingtone(): boolean { + return getCustomRingtoneFilename() !== null; +} + +export function getCustomRingtonePath(): string | null { + const filename = getCustomRingtoneFilename(); + if (!filename) { + return null; + } + return join(RINGTONES_ASSETS_DIR, filename); +} + +export function getCustomRingtoneInfo(): CustomRingtoneInfo | null { + if (!hasCustomRingtone()) { + return null; + } + + const metadata = readCustomRingtoneMetadata(); + + return { + id: CUSTOM_RINGTONE_ID, + name: metadata.name?.trim() || "Custom Audio", + description: "Imported from your local machine", + emoji: "SFX", + }; +} + +export async function importCustomRingtoneFromPath( + sourcePath: string, +): Promise { + if (!isAllowedAudioExtension(sourcePath)) { + throw new Error("Only .mp3, .wav, and .ogg files are supported"); + } + + const sourceStat = statSync(sourcePath); + if (!sourceStat.isFile()) { + throw new Error("Selected path is not a file"); + } + + if (sourceStat.size > MAX_CUSTOM_RINGTONE_SIZE_BYTES) { + throw new Error( + `Audio file is too large (${Math.round(sourceStat.size / 1024 / 1024)}MB). Maximum is 20MB.`, + ); + } + + ensureCustomRingtonesDir(); + removeExistingCustomRingtoneFiles(); + + const ext = extname(sourcePath).toLowerCase(); + const destinationPath = join( + RINGTONES_ASSETS_DIR, + `${CUSTOM_RINGTONE_FILE_STEM}${ext}`, + ); + + await copyFile(sourcePath, destinationPath); + + try { + chmodSync(destinationPath, SUPERSET_SENSITIVE_FILE_MODE); + } catch { + // Best effort only. + } + + const displayName = sanitizeDisplayName(basename(sourcePath)); + writeCustomRingtoneMetadata(displayName); + + return { + id: CUSTOM_RINGTONE_ID, + name: displayName, + description: "Imported from your local machine", + emoji: "SFX", + }; +} diff --git a/apps/desktop/src/main/lib/notification-sound.ts b/apps/desktop/src/main/lib/notification-sound.ts index 34388550a5c..869559ee711 100644 --- a/apps/desktop/src/main/lib/notification-sound.ts +++ b/apps/desktop/src/main/lib/notification-sound.ts @@ -2,9 +2,11 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { settings } from "@superset/local-db"; import { + CUSTOM_RINGTONE_ID, DEFAULT_RINGTONE_ID, getRingtoneFilename, } from "../../shared/ringtones"; +import { getCustomRingtonePath } from "./custom-ringtones"; import { localDb } from "./local-db"; import { getSoundPath } from "./sound-paths"; @@ -21,11 +23,12 @@ function areNotificationSoundsMuted(): boolean { } /** - * Gets the selected ringtone filename from the database. + * Gets the selected ringtone path from the database. * Falls back to default ringtone if the stored ID is invalid/stale. */ -function getSelectedRingtoneFilename(): string { +function getSelectedRingtonePath(): string | null { const defaultFilename = getRingtoneFilename(DEFAULT_RINGTONE_ID); + const defaultPath = getSoundPath(defaultFilename); try { const settingsRow = localDb.select().from(settings).get(); @@ -33,14 +36,18 @@ function getSelectedRingtoneFilename(): string { // Legacy: "none" was previously used before the muted toggle existed if (selectedId === "none") { - return ""; + return null; + } + + if (selectedId === CUSTOM_RINGTONE_ID) { + return getCustomRingtonePath() ?? defaultPath; } const filename = getRingtoneFilename(selectedId); // Fall back to default if stored ID is stale/unknown - return filename || defaultFilename; + return filename ? getSoundPath(filename) : defaultPath; } catch { - return defaultFilename; + return defaultPath; } } @@ -80,13 +87,12 @@ export function playNotificationSound(): void { return; } - const filename = getSelectedRingtoneFilename(); + const soundPath = getSelectedRingtonePath(); // No sound if "none" is selected - if (!filename) { + if (!soundPath) { return; } - const soundPath = getSoundPath(filename); playSoundFile(soundPath); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/RingtonesSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/RingtonesSettings.tsx index 21aafee90fe..f8f62482c18 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/RingtonesSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/RingtonesSettings.tsx @@ -1,8 +1,9 @@ +import { Button } from "@superset/ui/button"; import { Label } from "@superset/ui/label"; import { Switch } from "@superset/ui/switch"; import { cn } from "@superset/ui/utils"; import { useCallback, useEffect, useRef, useState } from "react"; -import { HiCheck, HiPlay, HiStop } from "react-icons/hi2"; +import { HiCheck, HiPlay, HiPlus, HiStop } from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { @@ -11,6 +12,7 @@ import { useSelectedRingtoneId, useSetRingtone, } from "renderer/stores"; +import { CUSTOM_RINGTONE_ID } from "shared/ringtones"; import { isItemVisible, SETTING_ITEM_ID, @@ -129,9 +131,21 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { const previewTimerRef = useRef | null>(null); const utils = electronTrpc.useUtils(); + const { data: customRingtoneData } = + electronTrpc.ringtone.getCustom.useQuery(); const { data: isMutedData, isLoading: isMutedLoading } = electronTrpc.settings.getNotificationSoundsMuted.useQuery(); const isMuted = isMutedData ?? false; + const customRingtone: Ringtone | null = customRingtoneData + ? { + ...customRingtoneData, + filename: "", + color: "from-slate-400 to-slate-500", + } + : null; + const ringtoneOptions = customRingtone + ? [...AVAILABLE_RINGTONES, customRingtone] + : AVAILABLE_RINGTONES; const setMuted = electronTrpc.settings.setNotificationSoundsMuted.useMutation( { @@ -151,11 +165,27 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { }, }, ); + const importCustomRingtone = electronTrpc.ringtone.importCustom.useMutation({ + onError: (error) => { + console.error("Failed to import custom ringtone:", error); + }, + onSuccess: async (result) => { + if (result.canceled) { + return; + } + await utils.ringtone.getCustom.invalidate(); + setRingtone(CUSTOM_RINGTONE_ID); + }, + }); const handleMutedToggle = (enabled: boolean) => { setMuted.mutate({ muted: !enabled }); }; + const handleImportCustomRingtone = useCallback(() => { + importCustomRingtone.mutate(); + }, [importCustomRingtone]); + // Clean up timer and stop any playing sound on unmount useEffect(() => { return () => { @@ -171,10 +201,6 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { const handleTogglePlay = useCallback( async (ringtone: Ringtone) => { - if (!ringtone.filename) { - return; - } - // Clear any pending timer if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); @@ -204,7 +230,7 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { try { await electronTrpcClient.ringtone.preview.mutate({ - filename: ringtone.filename, + ringtoneId: ringtone.id, }); } catch (error) { console.error("Failed to play ringtone:", error); @@ -264,9 +290,21 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { {/* Ringtone Section */} {showNotification && !isMuted && (
-

Notification Sound

+
+

Notification Sound

+ +
- {AVAILABLE_RINGTONES.map((ringtone) => ( + {ringtoneOptions.map((ringtone) => (

- Click the play button to preview a sound. Click stop or play - another to stop the current sound. + Click the play button to preview a sound. Use Add Custom Audio to + import your own .mp3, .wav, or .ogg file.

)} diff --git a/apps/desktop/src/renderer/stores/ringtone/store.ts b/apps/desktop/src/renderer/stores/ringtone/store.ts index bcbe1768579..e2961961e89 100644 --- a/apps/desktop/src/renderer/stores/ringtone/store.ts +++ b/apps/desktop/src/renderer/stores/ringtone/store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { + CUSTOM_RINGTONE_ID, DEFAULT_RINGTONE_ID, RINGTONES, type RingtoneData, @@ -25,7 +26,9 @@ interface RingtoneState { /** Check if a ringtone ID is valid */ function isValidRingtoneId(id: string): boolean { - return AVAILABLE_RINGTONES.some((r) => r.id === id); + return ( + id === CUSTOM_RINGTONE_ID || AVAILABLE_RINGTONES.some((r) => r.id === id) + ); } /** Get default ringtone (guaranteed to exist) */ @@ -46,8 +49,7 @@ export const useRingtoneStore = create()( selectedRingtoneId: DEFAULT_RINGTONE_ID, setRingtone: (ringtoneId: string) => { - const ringtone = AVAILABLE_RINGTONES.find((r) => r.id === ringtoneId); - if (!ringtone) { + if (!isValidRingtoneId(ringtoneId)) { console.error(`Ringtone not found: ${ringtoneId}`); return; } @@ -59,6 +61,10 @@ export const useRingtoneStore = create()( const ringtone = AVAILABLE_RINGTONES.find( (r) => r.id === state.selectedRingtoneId, ); + if (state.selectedRingtoneId === CUSTOM_RINGTONE_ID) { + // Custom ringtones are resolved by backend file state, not the built-in list. + return getDefaultRingtone(); + } // Fall back to default if persisted ID is stale/invalid if (!ringtone) { set({ selectedRingtoneId: DEFAULT_RINGTONE_ID }); diff --git a/apps/desktop/src/shared/ringtones.ts b/apps/desktop/src/shared/ringtones.ts index 5f312f931f1..d2c6ed7b99b 100644 --- a/apps/desktop/src/shared/ringtones.ts +++ b/apps/desktop/src/shared/ringtones.ts @@ -14,6 +14,8 @@ export interface RingtoneData { duration?: number; } +export const CUSTOM_RINGTONE_ID = "custom"; + /** * Built-in ringtones available in the app. * Files are located in src/resources/sounds/ @@ -129,6 +131,10 @@ export function getRingtoneById(id: string): RingtoneData | undefined { return RINGTONES.find((r) => r.id === id); } +export function isBuiltInRingtoneId(id: string): boolean { + return RINGTONES.some((r) => r.id === id); +} + /** * Get the filename for a ringtone ID. * Returns empty string if not found. From 1bc0fe6d7470adb345ff59eae950afd879200bd8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 23 Feb 2026 09:40:28 -0800 Subject: [PATCH 2/4] fix(desktop): harden custom ringtone import and selection rollback --- apps/desktop/src/main/lib/custom-ringtones.ts | 56 +++++++++++++++++-- apps/desktop/src/renderer/lib/trpc-storage.ts | 24 ++++++++ .../src/renderer/stores/ringtone/store.ts | 14 ++++- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/lib/custom-ringtones.ts b/apps/desktop/src/main/lib/custom-ringtones.ts index d63f8fc8d59..83f454a6ecc 100644 --- a/apps/desktop/src/main/lib/custom-ringtones.ts +++ b/apps/desktop/src/main/lib/custom-ringtones.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { chmodSync, existsSync, @@ -8,8 +9,8 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { copyFile } from "node:fs/promises"; -import { basename, extname, join } from "node:path"; +import { copyFile, rename, unlink } from "node:fs/promises"; +import { basename, extname, join, resolve } from "node:path"; import { CUSTOM_RINGTONE_ID } from "shared/ringtones"; import { SUPERSET_HOME_DIR, @@ -90,6 +91,17 @@ function sanitizeDisplayName(filename: string): string { return stripped.slice(0, 80); } +function normalizePathForComparison(filePath: string): string { + const resolved = resolve(filePath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function areSamePath(pathA: string, pathB: string): boolean { + return ( + normalizePathForComparison(pathA) === normalizePathForComparison(pathB) + ); +} + function readCustomRingtoneMetadata(): CustomRingtoneMetadata { if (!existsSync(CUSTOM_RINGTONE_METADATA_PATH)) { return {}; @@ -182,15 +194,50 @@ export async function importCustomRingtoneFromPath( } ensureCustomRingtonesDir(); - removeExistingCustomRingtoneFiles(); const ext = extname(sourcePath).toLowerCase(); const destinationPath = join( RINGTONES_ASSETS_DIR, `${CUSTOM_RINGTONE_FILE_STEM}${ext}`, ); + const displayName = sanitizeDisplayName(basename(sourcePath)); + + // Re-importing the same file path should not delete the active ringtone. + if (areSamePath(sourcePath, destinationPath) && existsSync(destinationPath)) { + try { + chmodSync(destinationPath, SUPERSET_SENSITIVE_FILE_MODE); + } catch { + // Best effort only. + } + writeCustomRingtoneMetadata(displayName); + return { + id: CUSTOM_RINGTONE_ID, + name: displayName, + description: "Imported from your local machine", + emoji: "SFX", + }; + } + + const tempPath = join( + RINGTONES_ASSETS_DIR, + `.tmp-${CUSTOM_RINGTONE_FILE_STEM}-${randomUUID()}${ext}`, + ); - await copyFile(sourcePath, destinationPath); + try { + // Copy first so existing ringtone remains intact if this step fails. + await copyFile(sourcePath, tempPath); + removeExistingCustomRingtoneFiles(); + await rename(tempPath, destinationPath); + } catch (error) { + if (existsSync(tempPath)) { + try { + await unlink(tempPath); + } catch { + // Best effort cleanup only. + } + } + throw error; + } try { chmodSync(destinationPath, SUPERSET_SENSITIVE_FILE_MODE); @@ -198,7 +245,6 @@ export async function importCustomRingtoneFromPath( // Best effort only. } - const displayName = sanitizeDisplayName(basename(sourcePath)); writeCustomRingtoneMetadata(displayName); return { diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts index 71b95fded62..5cd309a4196 100644 --- a/apps/desktop/src/renderer/lib/trpc-storage.ts +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -20,6 +20,7 @@ export function setSkipNextHotkeysPersist(skip: boolean): void { interface TrpcStorageConfig { get: () => Promise; set: (input: unknown) => Promise; + onSetError?: (error: unknown) => Promise | void; } function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { @@ -51,6 +52,7 @@ function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { await config.set(parsed.state); } catch (error) { console.error("[trpc-storage] Failed to set state:", error); + await config.onSetError?.(error); } }, removeItem: async (_name: string): Promise => { @@ -103,6 +105,15 @@ export const trpcHotkeysStorage = createJSONStorage(() => }), ); +type RingtonePersistErrorHandler = (canonicalRingtoneId: string) => void; +let ringtonePersistErrorHandler: RingtonePersistErrorHandler | null = null; + +export function setRingtonePersistErrorHandler( + handler: RingtonePersistErrorHandler | null, +): void { + ringtonePersistErrorHandler = handler; +} + /** * Zustand storage adapter for ringtone state using tRPC. * Only the selectedRingtoneId is persisted. @@ -120,5 +131,18 @@ export const trpcRingtoneStorage = createJSONStorage(() => ringtoneId: state.selectedRingtoneId, }); }, + onSetError: async () => { + if (!ringtonePersistErrorHandler) { + return; + } + + try { + const canonicalRingtoneId = + await electronTrpcClient.settings.getSelectedRingtoneId.query(); + ringtonePersistErrorHandler(canonicalRingtoneId); + } catch { + // Ignore secondary failures while already handling persistence failure. + } + }, }), ); diff --git a/apps/desktop/src/renderer/stores/ringtone/store.ts b/apps/desktop/src/renderer/stores/ringtone/store.ts index e2961961e89..74234e89d60 100644 --- a/apps/desktop/src/renderer/stores/ringtone/store.ts +++ b/apps/desktop/src/renderer/stores/ringtone/store.ts @@ -6,7 +6,10 @@ import { RINGTONES, type RingtoneData, } from "../../../shared/ringtones"; -import { trpcRingtoneStorage } from "../../lib/trpc-storage"; +import { + setRingtonePersistErrorHandler, + trpcRingtoneStorage, +} from "../../lib/trpc-storage"; // Re-export shared types and data for convenience export type Ringtone = RingtoneData; @@ -94,6 +97,15 @@ export const useRingtoneStore = create()( ), ); +setRingtonePersistErrorHandler((canonicalRingtoneId) => { + const current = useRingtoneStore.getState().selectedRingtoneId; + if (current === canonicalRingtoneId) { + return; + } + + useRingtoneStore.setState({ selectedRingtoneId: canonicalRingtoneId }); +}); + // Convenience hooks export const useSelectedRingtoneId = () => useRingtoneStore((state) => state.selectedRingtoneId); From b6438aec34bdd2da775cd15078758945135f8822 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 23 Feb 2026 09:43:30 -0800 Subject: [PATCH 3/4] refactor(desktop): isolate ringtone persist rollback logic --- apps/desktop/src/renderer/lib/trpc-storage.ts | 24 ------ .../src/renderer/stores/ringtone/store.ts | 78 ++++++++++++++++--- 2 files changed, 68 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts index 5cd309a4196..71b95fded62 100644 --- a/apps/desktop/src/renderer/lib/trpc-storage.ts +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -20,7 +20,6 @@ export function setSkipNextHotkeysPersist(skip: boolean): void { interface TrpcStorageConfig { get: () => Promise; set: (input: unknown) => Promise; - onSetError?: (error: unknown) => Promise | void; } function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { @@ -52,7 +51,6 @@ function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { await config.set(parsed.state); } catch (error) { console.error("[trpc-storage] Failed to set state:", error); - await config.onSetError?.(error); } }, removeItem: async (_name: string): Promise => { @@ -105,15 +103,6 @@ export const trpcHotkeysStorage = createJSONStorage(() => }), ); -type RingtonePersistErrorHandler = (canonicalRingtoneId: string) => void; -let ringtonePersistErrorHandler: RingtonePersistErrorHandler | null = null; - -export function setRingtonePersistErrorHandler( - handler: RingtonePersistErrorHandler | null, -): void { - ringtonePersistErrorHandler = handler; -} - /** * Zustand storage adapter for ringtone state using tRPC. * Only the selectedRingtoneId is persisted. @@ -131,18 +120,5 @@ export const trpcRingtoneStorage = createJSONStorage(() => ringtoneId: state.selectedRingtoneId, }); }, - onSetError: async () => { - if (!ringtonePersistErrorHandler) { - return; - } - - try { - const canonicalRingtoneId = - await electronTrpcClient.settings.getSelectedRingtoneId.query(); - ringtonePersistErrorHandler(canonicalRingtoneId); - } catch { - // Ignore secondary failures while already handling persistence failure. - } - }, }), ); diff --git a/apps/desktop/src/renderer/stores/ringtone/store.ts b/apps/desktop/src/renderer/stores/ringtone/store.ts index 74234e89d60..1e3d13d6e0d 100644 --- a/apps/desktop/src/renderer/stores/ringtone/store.ts +++ b/apps/desktop/src/renderer/stores/ringtone/store.ts @@ -1,15 +1,17 @@ import { create } from "zustand"; -import { devtools, persist } from "zustand/middleware"; +import { + createJSONStorage, + devtools, + persist, + type StateStorage, +} from "zustand/middleware"; import { CUSTOM_RINGTONE_ID, DEFAULT_RINGTONE_ID, RINGTONES, type RingtoneData, } from "../../../shared/ringtones"; -import { - setRingtonePersistErrorHandler, - trpcRingtoneStorage, -} from "../../lib/trpc-storage"; +import { electronTrpcClient } from "../../lib/trpc-client"; // Re-export shared types and data for convenience export type Ringtone = RingtoneData; @@ -27,6 +29,10 @@ interface RingtoneState { getSelectedRingtone: () => Ringtone; } +interface PersistedRingtoneState { + selectedRingtoneId: string; +} + /** Check if a ringtone ID is valid */ function isValidRingtoneId(id: string): boolean { return ( @@ -45,6 +51,60 @@ function getDefaultRingtone(): Ringtone { return ringtone; } +let applyCanonicalRingtoneId: ((ringtoneId: string) => void) | null = null; + +const ringtoneStorage = createJSONStorage( + (): StateStorage => ({ + getItem: async (name: string): Promise => { + try { + const ringtoneId = + await electronTrpcClient.settings.getSelectedRingtoneId.query(); + const version = Number.parseInt( + localStorage.getItem(`${name}:version`) ?? "0", + 10, + ); + return JSON.stringify({ + state: { + selectedRingtoneId: ringtoneId, + } satisfies PersistedRingtoneState, + version, + }); + } catch (error) { + console.error("[ringtone-store] Failed to load ringtone state:", error); + return null; + } + }, + setItem: async (name: string, value: string): Promise => { + try { + const parsed = JSON.parse(value) as { + state: PersistedRingtoneState; + version: number; + }; + localStorage.setItem(`${name}:version`, String(parsed.version)); + await electronTrpcClient.settings.setSelectedRingtoneId.mutate({ + ringtoneId: parsed.state.selectedRingtoneId, + }); + } catch (error) { + console.error( + "[ringtone-store] Failed to persist ringtone state:", + error, + ); + + try { + const canonicalRingtoneId = + await electronTrpcClient.settings.getSelectedRingtoneId.query(); + applyCanonicalRingtoneId?.(canonicalRingtoneId); + } catch { + // Ignore secondary failures while already handling persistence failure. + } + } + }, + removeItem: async (): Promise => { + // Reset to defaults is handled by store logic. + }, + }), +); + export const useRingtoneStore = create()( devtools( persist( @@ -78,7 +138,7 @@ export const useRingtoneStore = create()( }), { name: "ringtone-storage", - storage: trpcRingtoneStorage, + storage: ringtoneStorage, partialize: (state) => ({ selectedRingtoneId: state.selectedRingtoneId, }), @@ -96,15 +156,13 @@ export const useRingtoneStore = create()( { name: "RingtoneStore" }, ), ); - -setRingtonePersistErrorHandler((canonicalRingtoneId) => { +applyCanonicalRingtoneId = (canonicalRingtoneId) => { const current = useRingtoneStore.getState().selectedRingtoneId; if (current === canonicalRingtoneId) { return; } - useRingtoneStore.setState({ selectedRingtoneId: canonicalRingtoneId }); -}); +}; // Convenience hooks export const useSelectedRingtoneId = () => From 321ce5f085feb36b41c4526b8934c9d262c09140 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 23 Feb 2026 09:47:45 -0800 Subject: [PATCH 4/4] Lint --- apps/desktop/src/lib/trpc/routers/ringtone/index.ts | 2 +- bun.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts index 7348109136f..098cc239099 100644 --- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -2,8 +2,8 @@ import type { ChildProcess } from "node:child_process"; import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { TRPCError } from "@trpc/server"; -import { dialog } from "electron"; import type { BrowserWindow, OpenDialogOptions } from "electron"; +import { dialog } from "electron"; import { getCustomRingtoneInfo, getCustomRingtonePath, diff --git a/bun.lock b/bun.lock index 76bfb45c4c8..7f0d0f70de4 100644 --- a/bun.lock +++ b/bun.lock @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.82", + "version": "0.0.83", "dependencies": { "@ai-sdk/react": "^3.0.0", "@better-auth/stripe": "1.4.18",