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
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,6 +33,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
settings: createSettingsRouter(),
config: createConfigRouter(),
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(),
});
};

Expand Down
177 changes: 177 additions & 0 deletions apps/desktop/src/lib/trpc/routers/ringtone/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Comment on lines +48 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SIGKILL doesn't exist on Windows.

SIGKILL is a Unix signal and will not terminate processes on Windows. The kill() method on Windows requires SIGTERM or no argument (defaults to SIGTERM), and even then behavior differs.

Consider using a cross-platform approach:

 function stopCurrentSound(): void {
 	if (currentPlayingProcess) {
-		// Use SIGKILL for immediate termination (afplay doesn't always respond to SIGTERM)
-		currentPlayingProcess.kill("SIGKILL");
+		// Use platform-appropriate signal for termination
+		if (process.platform === "win32") {
+			currentPlayingProcess.kill(); // Windows defaults to SIGTERM-like behavior
+		} else {
+			currentPlayingProcess.kill("SIGKILL"); // Unix: immediate termination
+		}
 		currentPlayingProcess = null;
 	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function stopCurrentSound(): void {
if (currentPlayingProcess) {
// Use SIGKILL for immediate termination (afplay doesn't always respond to SIGTERM)
currentPlayingProcess.kill("SIGKILL");
currentPlayingProcess = null;
}
}
function stopCurrentSound(): void {
if (currentPlayingProcess) {
// Use platform-appropriate signal for termination
if (process.platform === "win32") {
currentPlayingProcess.kill(); // Windows defaults to SIGTERM-like behavior
} else {
currentPlayingProcess.kill("SIGKILL"); // Unix: immediate termination
}
currentPlayingProcess = null;
}
}
🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/ringtone-ipcs.ts around lines 38–44, the code calls
currentPlayingProcess.kill("SIGKILL") which is invalid on Windows; replace with
a cross-platform termination: if on win32, call taskkill via
child_process.spawnSync or use the tree-kill package to force-kill the PID
(e.g., treeKill(pid, 'SIGKILL') under the hood), otherwise use
currentPlayingProcess.kill('SIGKILL') for Unix; ensure you check
currentPlayingProcess.pid, handle errors, and set currentPlayingProcess = null
after successful termination.


/**
* 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;
}
},
);
Comment on lines +82 to +91
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential command injection via PowerShell.

The soundPath is interpolated directly into the PowerShell command string. If the path contains single quotes or special characters, it could break execution or allow injection.

Consider escaping the path or using a safer invocation pattern:

 	} else if (process.platform === "win32") {
+		// Escape single quotes in path for PowerShell
+		const escapedPath = soundPath.replace(/'/g, "''");
 		currentPlayingProcess = execFile(
 			"powershell",
-			["-c", `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`],
+			["-c", `(New-Object Media.SoundPlayer '${escapedPath}').PlaySync()`],
 			() => {
 				currentPlayingProcess = null;
 			},
 		);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (process.platform === "win32") {
currentPlayingProcess = execFile(
"powershell",
["-c", `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`],
() => {
currentPlayingProcess = null;
},
);
} else if (process.platform === "win32") {
// Escape single quotes in path for PowerShell
const escapedPath = soundPath.replace(/'/g, "''");
currentPlayingProcess = execFile(
"powershell",
["-c", `(New-Object Media.SoundPlayer '${escapedPath}').PlaySync()`],
() => {
currentPlayingProcess = null;
},
);
🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/ringtone-ipcs.ts around lines 62 to 69, the
PowerShell command interpolates soundPath directly into a single-quoted command
which can be broken or exploited if the path contains quotes or special chars;
change to a safe invocation by passing the path as a separate argument (avoid
inline string interpolation) or properly escape/encode the path for PowerShell
(e.g., use parameterized invocation or --%/StopParsing where appropriate), and
ensure execFile receives the path as an argument array element rather than
concatenating it into the command string so special characters cannot cause
command injection; finally, preserve the existing callback behavior to clear
currentPlayingProcess.

} 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;
}
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* 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);
}
42 changes: 42 additions & 0 deletions apps/desktop/src/lib/trpc/routers/settings/index.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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 };
}),
});
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/lib/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export interface Settings {
lastActiveWorkspaceId?: string;
lastUsedApp?: ExternalApp;
terminalPresets?: TerminalPreset[];
selectedRingtoneId?: string;
}

export interface Database {
Expand Down
59 changes: 50 additions & 9 deletions apps/desktop/src/main/lib/notification-sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
20 changes: 20 additions & 0 deletions apps/desktop/src/renderer/lib/trpc-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
},
}),
);
Loading
Loading