diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 57ccb6a07cc..30624931388 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -30,9 +30,13 @@ const config: Configuration = { buildResources: join(pkg.resources, "build"), }, - // ASAR configuration for native modules + // ASAR configuration for native modules and external resources asar: true, - asarUnpack: ["**/node_modules/node-pty/**/*"], + asarUnpack: [ + "**/node_modules/node-pty/**/*", + // Sound files must be unpacked so external audio players (afplay, paplay, etc.) can access them + "**/resources/sounds/**/*", + ], files: [ "dist/**/*", diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index c5fe5576d3a..4acce85b791 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -1,3 +1,4 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { dirname, normalize, resolve } from "node:path"; import tailwindcss from "@tailwindcss/vite"; import reactPlugin from "@vitejs/plugin-react"; @@ -5,6 +6,7 @@ import { codeInspectorPlugin } from "code-inspector-plugin"; import { config } from "dotenv"; import { defineConfig, externalizeDepsPlugin } from "electron-vite"; import injectProcessEnvPlugin from "rollup-plugin-inject-process-env"; +import type { Plugin } from "vite"; import tsconfigPathsPlugin from "vite-tsconfig-paths"; import { main, resources } from "./package.json"; @@ -22,9 +24,35 @@ const tsconfigPaths = tsconfigPathsPlugin({ projects: [resolve("tsconfig.json")], }); +/** + * Plugin to copy resources (like sounds) to the dist folder for preview mode. + * In preview mode, __dirname resolves relative to dist/main, so resources + * need to be at dist/resources/sounds for the main process to access them. + * + * Cleans the destination first to avoid stale files from previous builds. + */ +function copyResourcesPlugin(): Plugin { + return { + name: "copy-resources", + writeBundle() { + const srcDir = resolve(resources, "sounds"); + const destDir = resolve(devPath, "resources/sounds"); + + if (existsSync(srcDir)) { + // Clean destination to avoid stale files + if (existsSync(destDir)) { + rmSync(destDir, { recursive: true }); + } + mkdirSync(destDir, { recursive: true }); + cpSync(srcDir, destDir, { recursive: true }); + } + }, + }; +} + export default defineConfig({ main: { - plugins: [tsconfigPaths], + plugins: [tsconfigPaths, copyResourcesPlugin()], build: { rollupOptions: { diff --git a/apps/desktop/src/lib/electron-router-dom.ts b/apps/desktop/src/lib/electron-router-dom.ts index fa36f2f7aba..f8d49a4569f 100644 --- a/apps/desktop/src/lib/electron-router-dom.ts +++ b/apps/desktop/src/lib/electron-router-dom.ts @@ -1,9 +1,46 @@ +import type { BrowserWindow } from "electron"; import { createElectronRouter } from "electron-router-dom"; import { PORTS } from "shared/constants"; -export const { Router, registerRoute, settings } = createElectronRouter({ +const electronRouter = createElectronRouter({ port: PORTS.VITE_DEV_SERVER, types: { ids: ["main", "about"], }, }); + +export const { Router, settings } = electronRouter; + +/** Window IDs defined in the router configuration */ +type WindowId = "main" | "about"; + +/** + * Custom registerRoute that uses NODE_ENV instead of app.isPackaged. + * This allows `electron-vite preview` (bun start) to load built files + * instead of trying to connect to a dev server. + * + * - Development (NODE_ENV=development): loads from dev server + * - Preview/Production: loads from built HTML file + */ +export function registerRoute(props: { + id: WindowId; + browserWindow: BrowserWindow; + htmlFile: string; + query?: Record; +}): void { + const isDev = process.env.NODE_ENV === "development"; + + if (isDev) { + // Development: use the library's default behavior (loads from dev server) + electronRouter.registerRoute(props); + } else { + // Preview or Production: load from file with hash routing + const windowId = props.id || "main"; + let url = `/${windowId}`; + if (props.query) { + const query = new URLSearchParams(props.query).toString(); + url = `${url}?${query}`; + } + props.browserWindow.loadFile(props.htmlFile, { hash: url }); + } +} diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts index ea41aba3612..c1868dbd1c4 100644 --- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -1,9 +1,11 @@ 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 { + getSoundPath, + getSoundsDirectory, +} from "../../../../main/lib/sound-paths"; import { publicProcedure, router } from "../.."; /** @@ -17,31 +19,6 @@ let currentSession: { } | 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 */ @@ -127,7 +104,7 @@ export const createRingtoneRouter = () => { return { success: true as const }; } - const soundPath = getRingtonePath(input.filename); + const soundPath = getSoundPath(input.filename); playSoundFile(soundPath); return { success: true as const }; }), @@ -144,7 +121,7 @@ export const createRingtoneRouter = () => { * Get the list of available ringtone files from the sounds directory */ list: publicProcedure.query(() => { - const ringtonesDir = getRingtonesDirectory(); + const ringtonesDir = getSoundsDirectory(); const files: string[] = []; // Add ringtones from the sounds directory if it exists @@ -172,6 +149,6 @@ export function playNotificationRingtone(filename: string): void { return; // No sound for "none" option } - const soundPath = getRingtonePath(filename); + const soundPath = getSoundPath(filename); playSoundFile(soundPath); } diff --git a/apps/desktop/src/main/lib/notification-sound.ts b/apps/desktop/src/main/lib/notification-sound.ts index 08ce0b92757..a8d1f5faf1f 100644 --- a/apps/desktop/src/main/lib/notification-sound.ts +++ b/apps/desktop/src/main/lib/notification-sound.ts @@ -1,25 +1,11 @@ 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 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); -} +import { getSoundPath } from "./sound-paths"; /** * Gets the selected ringtone filename from the database. @@ -83,6 +69,6 @@ export function playNotificationSound(): void { return; } - const soundPath = getRingtonePath(filename); + const soundPath = getSoundPath(filename); playSoundFile(soundPath); } diff --git a/apps/desktop/src/main/lib/sound-paths.ts b/apps/desktop/src/main/lib/sound-paths.ts new file mode 100644 index 00000000000..d09ed660d5a --- /dev/null +++ b/apps/desktop/src/main/lib/sound-paths.ts @@ -0,0 +1,58 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { app } from "electron"; + +/** + * Gets the path to a ringtone sound file. + * + * Path resolution strategy: + * - Production (packaged .app): app.asar.unpacked/resources/sounds/ + * - Development (NODE_ENV=development): src/resources/sounds/ + * - Preview (electron-vite preview): dist/resources/sounds/ (relative to __dirname) + * + * Sound files are unpacked from asar so external audio players (afplay, etc.) can access them. + */ +export function getSoundPath(filename: string): string { + const dir = getSoundsDirectory(); + return join(dir, filename); +} + +/** + * Gets the directory containing ringtone sound files. + * + * In preview mode, uses __dirname (dist/main) to reliably resolve to dist/resources/sounds, + * avoiding dependency on app.getAppPath() or process.cwd() which may vary. + */ +export function getSoundsDirectory(): string { + if (app.isPackaged) { + // Production: unpacked from asar for external audio players + return join(process.resourcesPath, "app.asar.unpacked/resources/sounds"); + } + + const isDev = process.env.NODE_ENV === "development"; + + if (isDev) { + // Development: source files in project + return join(app.getAppPath(), "src/resources/sounds"); + } + + // Preview mode: __dirname is dist/main, so go up one level to dist/resources/sounds + // This is the most reliable path in preview since it's relative to the bundle location + const previewPath = join(__dirname, "../resources/sounds"); + if (existsSync(previewPath)) { + return previewPath; + } + + // Fallback: try source directory (in case sounds weren't copied to dist) + const srcPath = join(app.getAppPath(), "src/resources/sounds"); + if (existsSync(srcPath)) { + console.warn( + "[sound-paths] Using src/resources/sounds as fallback - sounds may not have been copied to dist", + ); + return srcPath; + } + + // Return the expected preview path even if missing (will fail gracefully in playback) + console.warn(`[sound-paths] Sounds directory not found at: ${previewPath}`); + return previewPath; +}