diff --git a/apps/api/src/app/api/agent/[transport]/route.ts b/apps/api/src/app/api/agent/[transport]/route.ts index 149bd5114dc..8802aa48d73 100644 --- a/apps/api/src/app/api/agent/[transport]/route.ts +++ b/apps/api/src/app/api/agent/[transport]/route.ts @@ -8,6 +8,10 @@ async function verifyToken(req: Request, bearerToken?: string) { // 1. Try session auth const session = await auth.api.getSession({ headers: req.headers }); if (session?.session) { + if (!session.user) { + console.error("[mcp/auth] Session missing user"); + return undefined; + } const extendedSession = session.session as { activeOrganizationId?: string; }; diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 9fb84f56634..734a55b4bef 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -10,6 +10,7 @@ import pkg from "./package.json"; const currentYear = new Date().getFullYear(); const author = pkg.author?.name ?? pkg.author; const productName = pkg.productName; +const disableWinSigning = process.env.SUPERSET_DISABLE_WIN_SIGNING === "1"; const config: Configuration = { appId: "com.superset.desktop", @@ -56,32 +57,34 @@ const config: Configuration = { to: "resources/migrations", filter: ["**/*"], }, + // App icons used by Windows shortcuts (keep outside asar) + { + from: join(pkg.resources, "build/icons"), + to: "build/icons", + filter: ["**/*"], + }, ], files: [ - "dist/**/*", - "package.json", + { + filter: ["dist/**/*", "!dist/resources/migrations/**", "package.json"], + }, { from: pkg.resources, to: "resources", - filter: ["**/*"], + filter: ["**/*", "!build/**"], }, - // Native modules that can't be bundled by Vite. - // bun creates symlinks for direct deps in workspace node_modules. - // The copy:native-modules script replaces symlinks with real files - // before building (required for Bun 1.3+ isolated installs). + // Native modules rebuilt for Electron { from: "node_modules/better-sqlite3", to: "node_modules/better-sqlite3", filter: ["**/*"], }, - // better-sqlite3 uses `bindings` package to locate its native .node file { from: "node_modules/bindings", to: "node_modules/bindings", filter: ["**/*"], }, - // `bindings` requires `file-uri-to-path` for file:// URL handling { from: "node_modules/file-uri-to-path", to: "node_modules/file-uri-to-path", @@ -101,7 +104,7 @@ const config: Configuration = { "!**/.DS_Store", ], - // Rebuild native modules for Electron's Node.js version + // Rebuild native modules for Electron platform during packaging npmRebuild: true, // macOS @@ -153,12 +156,27 @@ const config: Configuration = { }, ], artifactName: `${productName}-${pkg.version}-\${arch}.\${ext}`, + signAndEditExecutable: disableWinSigning ? false : undefined, + asarUnpack: ["**/node_modules/@lydell/node-pty-win32-x64/**/*"], + files: [ + { + from: "node_modules/@lydell/node-pty-win32-x64", + to: "node_modules/@lydell/node-pty-win32-x64", + filter: ["**/*"], + }, + ], }, // NSIS installer (Windows) nsis: { oneClick: false, allowToChangeInstallationDirectory: true, + createDesktopShortcut: true, + createStartMenuShortcut: true, + shortcutName: productName, + installerIcon: join(pkg.resources, "build/icons/icon.ico"), + uninstallerIcon: join(pkg.resources, "build/icons/icon.ico"), + include: join(pkg.resources, "build/installer.nsh"), }, }; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ca85bffa36e..38628296a56 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@superset/desktop", "productName": "Superset", "description": "The last developer tool you'll ever need", - "version": "0.0.63", + "version": "0.0.64", "main": "./dist/main/index.js", "resources": "src/resources", "repository": { @@ -21,11 +21,10 @@ "compile:app": "cross-env NODE_OPTIONS=--max-old-space-size=8192 electron-vite build", "copy:native-modules": "bun run scripts/copy-native-modules.ts", "prebuild": "bun run clean:dev && bun run compile:app && bun run copy:native-modules", - "build": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --publish never", - "prepackage": "bun run copy:native-modules", - "package": "electron-builder --config electron-builder.ts", + "build": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --publish never --config electron-builder.ts", + "package": "bun run copy:native-modules && electron-builder --config electron-builder.ts", "install:deps": "electron-builder install-app-deps", - "release": "electron-builder --publish always", + "release": "bun run copy:native-modules && electron-builder --publish always --config electron-builder.ts", "clean:dev": "rimraf ./node_modules/.dev", "generate:routes": "tsr generate", "pretypecheck": "bun run generate:routes", @@ -132,7 +131,7 @@ "monaco-editor": "^0.55.1", "nanoid": "^5.1.6", "node-addon-api": "^7.1.0", - "node-pty": "1.1.0-beta30", + "node-pty": "npm:@lydell/node-pty@^1.0.1", "os-locale": "^6.0.2", "pidtree": "^0.6.0", "posthog-js": "1.310.1", diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index 0f28a56b4bd..cf2757546cf 100644 --- a/apps/desktop/scripts/copy-native-modules.ts +++ b/apps/desktop/scripts/copy-native-modules.ts @@ -13,15 +13,48 @@ * This is safe because bun install will recreate the symlinks on next install. */ -import { cpSync, existsSync, lstatSync, realpathSync, rmSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + realpathSync, + readdirSync, + rmSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; // Native modules that must exist for the app to work +// Made optional for Windows builds where native compilation may fail const NATIVE_MODULES = ["better-sqlite3", "node-pty"] as const; // Dependencies of native modules that need to be copied (may be hoisted or symlinked) const NATIVE_MODULE_DEPS = ["bindings", "file-uri-to-path"] as const; +const desktopDir = dirname(import.meta.dirname); +const desktopNodeModulesDir = join(desktopDir, "node_modules"); +const repoRootDir = resolve(desktopDir, "..", ".."); +const repoNodeModulesDir = join(repoRootDir, "node_modules"); +const bunStoreDir = join(repoNodeModulesDir, ".bun"); + +const OPTIONAL_PLATFORM_MODULES = ["@lydell/node-pty-win32-x64"] as const; + +function findBunModulePath(moduleName: string): string | null { + if (!existsSync(bunStoreDir)) return null; + + const bunPrefix = moduleName.startsWith("@") + ? moduleName.replace("/", "+") + : moduleName; + const matches = readdirSync(bunStoreDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith(`${bunPrefix}@`)) + .map((entry) => entry.name) + .sort((a, b) => b.localeCompare(a)); + + if (matches.length === 0) return null; + + return join(bunStoreDir, matches[0], "node_modules", moduleName); +} + function copyModuleIfSymlink( nodeModulesDir: string, moduleName: string, @@ -31,8 +64,9 @@ function copyModuleIfSymlink( if (!existsSync(modulePath)) { if (required) { - console.error(` [ERROR] ${moduleName} not found at ${modulePath}`); - process.exit(1); + // On Windows, native modules may not compile - warn but don't fail + console.warn(` [WARN] ${moduleName} not found at ${modulePath} - continuing without it`); + return false; } console.log(` ${moduleName}: not found (skipping)`); return false; @@ -46,8 +80,8 @@ function copyModuleIfSymlink( console.log(` ${moduleName}: symlink -> replacing with real files`); console.log(` Real path: ${realPath}`); - // Remove the symlink - rmSync(modulePath); + // Remove the symlink/junction safely on Windows + rmSync(modulePath, { recursive: true, force: true }); // Copy the actual files cpSync(realPath, modulePath, { recursive: true }); @@ -63,18 +97,60 @@ function copyModuleIfSymlink( function prepareNativeModules() { console.log("Preparing native modules for electron-builder..."); - // bun creates symlinks for direct dependencies in the workspace's node_modules - const nodeModulesDir = join(dirname(import.meta.dirname), "node_modules"); + // bun creates symlinks for direct dependencies in the workspace's node_modules. + // If the workspace doesn't have its own node_modules, fall back to the repo root. + if (!existsSync(desktopNodeModulesDir)) { + mkdirSync(desktopNodeModulesDir, { recursive: true }); + } + + function ensureLocalModuleCopy(moduleName: string, required: boolean) { + const desktopModulePath = join(desktopNodeModulesDir, moduleName); + if (existsSync(desktopModulePath)) { + return copyModuleIfSymlink(desktopNodeModulesDir, moduleName, required); + } + + const repoModulePath = join(repoNodeModulesDir, moduleName); + if (!existsSync(repoModulePath)) { + const bunModulePath = findBunModulePath(moduleName); + if (bunModulePath && existsSync(bunModulePath)) { + console.log(` ${moduleName}: copying from bun store`); + mkdirSync(dirname(desktopModulePath), { recursive: true }); + cpSync(bunModulePath, desktopModulePath, { recursive: true }); + return true; + } + if (required) { + console.warn( + ` [WARN] ${moduleName} not found in desktop or repo node_modules - continuing without it`, + ); + return false; + } + console.log(` ${moduleName}: not found (skipping)`); + return false; + } + + const repoStats = lstatSync(repoModulePath); + const sourcePath = repoStats.isSymbolicLink() + ? realpathSync(repoModulePath) + : repoModulePath; + console.log(` ${moduleName}: copying from repo node_modules`); + cpSync(sourcePath, desktopModulePath, { recursive: true }); + return true; + } - // Copy required native modules + // Copy native modules (not required on Windows if compilation failed) for (const moduleName of NATIVE_MODULES) { - copyModuleIfSymlink(nodeModulesDir, moduleName, true); + ensureLocalModuleCopy(moduleName, false); } // Copy native module dependencies (not required but needed if present) console.log("\nPreparing native module dependencies..."); for (const moduleName of NATIVE_MODULE_DEPS) { - copyModuleIfSymlink(nodeModulesDir, moduleName, false); + ensureLocalModuleCopy(moduleName, false); + } + + console.log("\nPreparing platform-specific optional modules..."); + for (const moduleName of OPTIONAL_PLATFORM_MODULES) { + ensureLocalModuleCopy(moduleName, false); } console.log("\nDone!"); diff --git a/apps/desktop/src/lib/electron-app/factories/windows/create.ts b/apps/desktop/src/lib/electron-app/factories/windows/create.ts index f8628add0ad..927fd4176ca 100644 --- a/apps/desktop/src/lib/electron-app/factories/windows/create.ts +++ b/apps/desktop/src/lib/electron-app/factories/windows/create.ts @@ -1,10 +1,37 @@ +import { existsSync } from "node:fs"; import { join } from "node:path"; -import { BrowserWindow, shell } from "electron"; +import { app, BrowserWindow, shell } from "electron"; import { registerRoute } from "lib/window-loader"; import type { WindowProps } from "shared/types"; +function getDefaultWindowIcon(): string | undefined { + if (process.platform !== "win32") { + return undefined; + } + + const candidates = app.isPackaged + ? [ + join(process.resourcesPath, "build", "icons", "icon.ico"), + ] + : [ + join(app.getAppPath(), "src", "resources", "build", "icons", "icon.ico"), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return undefined; +} + export function createWindow({ id, ...settings }: WindowProps) { - const window = new BrowserWindow(settings); + const icon = settings.icon ?? getDefaultWindowIcon(); + const window = new BrowserWindow({ + ...settings, + ...(icon ? { icon } : {}), + }); // Open external URLs in the system browser instead of Electron window.webContents.setWindowOpenHandler(({ url }) => { diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts index a7b4197132c..43886fc26c9 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts @@ -1,9 +1,16 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { getAppCommand, resolvePath, stripPathWrappers } from "./helpers"; -describe("getAppCommand", () => { +const isDarwin = process.platform === "darwin"; +const isWindows = process.platform === "win32"; + +const describeDarwin = isDarwin ? describe : describe.skip; +const describeWindows = isWindows ? describe : describe.skip; + +describeDarwin("getAppCommand (darwin)", () => { test("returns null for finder (handled specially)", () => { expect(getAppCommand("finder", "/path/to/file")).toBeNull(); }); @@ -123,6 +130,65 @@ describe("getAppCommand", () => { }); }); +describeWindows("getAppCommand (windows)", () => { + const originalEnv = { + LOCALAPPDATA: process.env.LOCALAPPDATA, + ProgramFiles: process.env.ProgramFiles, + ProgramFilesX86: process.env["ProgramFiles(x86)"], + }; + let tempRoot: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "superset-win-")); + process.env.LOCALAPPDATA = path.join(tempRoot, "LocalAppData"); + process.env.ProgramFiles = path.join(tempRoot, "ProgramFiles"); + process.env["ProgramFiles(x86)"] = path.join(tempRoot, "ProgramFilesX86"); + fs.mkdirSync(process.env.LOCALAPPDATA, { recursive: true }); + fs.mkdirSync(process.env.ProgramFiles, { recursive: true }); + fs.mkdirSync(process.env["ProgramFiles(x86)"], { recursive: true }); + }); + + afterEach(() => { + process.env.LOCALAPPDATA = originalEnv.LOCALAPPDATA; + process.env.ProgramFiles = originalEnv.ProgramFiles; + process.env["ProgramFiles(x86)"] = originalEnv.ProgramFilesX86; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + test("resolves install path for VS Code", () => { + const exePath = path.join( + process.env.LOCALAPPDATA ?? "", + "Programs", + "Microsoft VS Code", + "Code.exe", + ); + fs.mkdirSync(path.dirname(exePath), { recursive: true }); + fs.writeFileSync(exePath, ""); + const result = getAppCommand("vscode", "C:\\path\\file.ts"); + expect(result).toEqual({ + command: exePath, + args: ["C:\\path\\file.ts"], + }); + }); + + test("resolves JetBrains install path for IntelliJ", () => { + const exePath = path.join( + process.env.ProgramFiles ?? "", + "JetBrains", + "IntelliJ IDEA 2024.1", + "bin", + "idea64.exe", + ); + fs.mkdirSync(path.dirname(exePath), { recursive: true }); + fs.writeFileSync(exePath, ""); + const result = getAppCommand("intellij", "C:\\path\\project"); + expect(result).toEqual({ + command: exePath, + args: ["C:\\path\\project"], + }); + }); +}); + describe("resolvePath", () => { const homedir = os.homedir(); const originalHome = process.env.HOME; diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.ts index 6e8a09d5c0a..d00d634e94f 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import fs from "node:fs"; import nodePath from "node:path"; import { EXTERNAL_APPS, type ExternalApp } from "@superset/local-db"; @@ -29,6 +30,220 @@ const APP_NAMES: Record = { rustrover: "RustRover", }; +type WindowsAppConfig = { + cli?: string; + exeNames?: string[]; + installDirs?: string[]; + jetBrainsExeNames?: string[]; + args?: (targetPath: string) => string[]; +}; + +const resolveTerminalTarget = (targetPath: string) => { + try { + if (fs.existsSync(targetPath) && fs.statSync(targetPath).isFile()) { + return nodePath.dirname(targetPath); + } + } catch { + // Fallback to original path + } + return targetPath; +}; + +const WINDOWS_APP_CONFIG: Record = { + finder: {}, + vscode: { + cli: "code", + exeNames: ["Code.exe"], + installDirs: ["Microsoft VS Code"], + }, + "vscode-insiders": { + cli: "code-insiders", + exeNames: ["Code - Insiders.exe"], + installDirs: ["Microsoft VS Code Insiders"], + }, + cursor: { + cli: "cursor", + exeNames: ["Cursor.exe"], + installDirs: ["Cursor"], + }, + zed: { + cli: "zed", + exeNames: ["Zed.exe"], + installDirs: ["Zed"], + }, + xcode: {}, + iterm: {}, + warp: { + cli: "warp", + exeNames: ["Warp.exe"], + installDirs: ["Warp"], + }, + terminal: { + cli: "wt", + exeNames: ["wt.exe", "WindowsTerminal.exe"], + args: (targetPath) => ["-d", resolveTerminalTarget(targetPath)], + }, + ghostty: { + cli: "ghostty", + exeNames: ["Ghostty.exe"], + installDirs: ["Ghostty"], + }, + sublime: { + cli: "subl", + exeNames: ["sublime_text.exe"], + installDirs: ["Sublime Text", "Sublime Text 3"], + }, + intellij: { cli: "idea64", jetBrainsExeNames: ["idea64.exe"] }, + webstorm: { cli: "webstorm64", jetBrainsExeNames: ["webstorm64.exe"] }, + pycharm: { cli: "pycharm64", jetBrainsExeNames: ["pycharm64.exe"] }, + phpstorm: { cli: "phpstorm64", jetBrainsExeNames: ["phpstorm64.exe"] }, + rubymine: { cli: "rubymine64", jetBrainsExeNames: ["rubymine64.exe"] }, + goland: { cli: "goland64", jetBrainsExeNames: ["goland64.exe"] }, + clion: { cli: "clion64", jetBrainsExeNames: ["clion64.exe"] }, + rider: { cli: "rider64", jetBrainsExeNames: ["rider64.exe"] }, + datagrip: { cli: "datagrip64", jetBrainsExeNames: ["datagrip64.exe"] }, + appcode: {}, + fleet: { cli: "fleet", jetBrainsExeNames: ["fleet.exe", "fleet64.exe"] }, + rustrover: { cli: "rustrover64", jetBrainsExeNames: ["rustrover64.exe"] }, +}; + +const getWindowsProgramRoots = (): string[] => { + const roots: string[] = []; + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + roots.push(nodePath.join(localAppData, "Programs")); + } + if (process.env.ProgramFiles) { + roots.push(process.env.ProgramFiles); + } + if (process.env["ProgramFiles(x86)"]) { + roots.push(process.env["ProgramFiles(x86)"]); + } + return roots; +}; + +const findExistingPath = (candidates: string[]): string | null => { + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } + return null; +}; + +const buildWindowsExeCandidates = (config: WindowsAppConfig): string[] => { + if (!config.exeNames?.length || !config.installDirs?.length) { + return []; + } + const roots = getWindowsProgramRoots(); + const candidates: string[] = []; + for (const root of roots) { + for (const dir of config.installDirs) { + for (const exeName of config.exeNames) { + candidates.push(nodePath.join(root, dir, exeName)); + } + } + } + return candidates; +}; + +const findJetBrainsExeInRoot = ( + root: string, + exeName: string, +): string | null => { + try { + if (!fs.existsSync(root)) return null; + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidate = nodePath.join(root, entry.name, "bin", exeName); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // Ignore lookup errors and fall back + } + return null; +}; + +const findJetBrainsToolboxExe = ( + toolboxRoot: string, + exeName: string, +): string | null => { + try { + if (!fs.existsSync(toolboxRoot)) return null; + const products = fs.readdirSync(toolboxRoot, { withFileTypes: true }); + for (const product of products) { + if (!product.isDirectory()) continue; + const productDir = nodePath.join(toolboxRoot, product.name); + const channels = fs.readdirSync(productDir, { withFileTypes: true }); + for (const channel of channels) { + if (!channel.isDirectory() || !channel.name.startsWith("ch-")) { + continue; + } + const channelDir = nodePath.join(productDir, channel.name); + const builds = fs.readdirSync(channelDir, { withFileTypes: true }); + const buildNames = builds + .filter((build) => build.isDirectory()) + .map((build) => build.name) + .sort() + .reverse(); + for (const buildName of buildNames) { + const candidate = nodePath.join( + channelDir, + buildName, + "bin", + exeName, + ); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + } + } catch { + // Ignore lookup errors and fall back + } + return null; +}; + +const findJetBrainsExe = (exeNames: string[]): string | null => { + const roots: string[] = []; + if (process.env.ProgramFiles) { + roots.push(nodePath.join(process.env.ProgramFiles, "JetBrains")); + } + if (process.env["ProgramFiles(x86)"]) { + roots.push(nodePath.join(process.env["ProgramFiles(x86)"], "JetBrains")); + } + const localAppData = process.env.LOCALAPPDATA; + if (localAppData) { + roots.push(nodePath.join(localAppData, "Programs", "JetBrains")); + } + + for (const exeName of exeNames) { + for (const root of roots) { + const match = findJetBrainsExeInRoot(root, exeName); + if (match) return match; + } + } + + if (localAppData) { + const toolboxRoot = nodePath.join( + localAppData, + "JetBrains", + "Toolbox", + "apps", + ); + for (const exeName of exeNames) { + const match = findJetBrainsToolboxExe(toolboxRoot, exeName); + if (match) return match; + } + } + + return null; +}; + /** * Get the command and args to open a path in the specified app. * Uses `open -a` for macOS apps to avoid PATH issues in production builds. @@ -37,6 +252,24 @@ export function getAppCommand( app: ExternalApp, targetPath: string, ): { command: string; args: string[] } | null { + if (process.platform === "win32") { + const config = WINDOWS_APP_CONFIG[app]; + if (!config) return null; + const args = config.args ? config.args(targetPath) : [targetPath]; + const exePath = findExistingPath(buildWindowsExeCandidates(config)); + if (exePath) return { command: exePath, args }; + if (config.jetBrainsExeNames?.length) { + const jetBrainsExe = findJetBrainsExe(config.jetBrainsExeNames); + if (jetBrainsExe) return { command: jetBrainsExe, args }; + } + if (config.cli) return { command: config.cli, args }; + return null; + } + + if (process.platform !== "darwin") { + return null; + } + const appName = APP_NAMES[app]; if (!appName) return null; return { command: "open", args: ["-a", appName, targetPath] }; diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 13d4450e672..dde75a79272 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -25,8 +25,15 @@ async function openPathInApp( const cmd = getAppCommand(app, filePath); if (cmd) { - await spawnAsync(cmd.command, cmd.args); - return; + try { + await spawnAsync(cmd.command, cmd.args); + return; + } catch (error) { + console.warn( + "[external/openInApp] Failed to launch app, falling back to default:", + error, + ); + } } await shell.openPath(filePath); diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts index c1868dbd1c4..4f11cf45028 100644 --- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -1,6 +1,9 @@ import type { ChildProcess } from "node:child_process"; import { execFile } from "node:child_process"; +import { EventEmitter } from "node:events"; import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { observable } from "@trpc/server/observable"; import { z } from "zod"; import { getSoundPath, @@ -8,6 +11,84 @@ import { } from "../../../../main/lib/sound-paths"; import { publicProcedure, router } from "../.."; +function getWindowsPowerShellPath(): string { + const systemRoot = process.env.SystemRoot ?? "C:\\Windows"; + const powershellPath = join( + systemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + return existsSync(powershellPath) ? powershellPath : "powershell.exe"; +} + +type RingtoneEvent = + | { type: "play"; filename: string } + | { type: "stop" }; + +const ringtoneEvents = new EventEmitter(); + +function sendRingtoneEvent(params: { + channel: "ringtone-play" | "ringtone-stop"; + filename?: string; +}): boolean { + if (params.channel === "ringtone-play") { + if (!params.filename) { + return false; // Invalid play request - no filename provided + } + return ringtoneEvents.emit("ringtone-event", { type: "play", filename: params.filename }); + } + + if (params.channel === "ringtone-stop") { + return ringtoneEvents.emit("ringtone-event", { type: "stop" }); + } + + return false; +} + +function buildWindowsPlayerArgs(soundPath: string): string[] { + const escapedPath = soundPath.replace(/'/g, "''"); + const script = [ + "$ErrorActionPreference = 'Stop'", + "try {", + "$player = New-Object -ComObject WMPlayer.OCX.7", + `$player.URL = '${escapedPath}'`, + "$player.controls.play()", + "$started = $false", + "for ($i = 0; $i -lt 300; $i++) {", + " if ($player.playState -eq 3) { $started = $true }", + " if ($started -and ($player.playState -eq 1 -or $player.playState -eq 8)) { break }", + " Start-Sleep -Milliseconds 200", + "}", + "} catch {", + " try {", + " Add-Type -AssemblyName PresentationCore", + " $media = New-Object System.Windows.Media.MediaPlayer", + ` $media.Open([System.Uri]::new('${escapedPath}'))`, + " $media.Volume = 1.0", + " $media.Play()", + " Start-Sleep -Milliseconds 200", + " while ($media.NaturalDuration.HasTimeSpan -and $media.Position -lt $media.NaturalDuration.TimeSpan) {", + " Start-Sleep -Milliseconds 200", + " }", + " $media.Close()", + " } catch {", + " exit 1", + " }", + "}", + ].join("; "); + + return [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]; +} + /** * Track current playing session to handle race conditions. * Each play operation gets a unique session ID. When stop is called, @@ -57,10 +138,19 @@ function playSoundFile(soundPath: string): void { } }); } else if (process.platform === "win32") { + const powershellPath = getWindowsPowerShellPath(); currentSession.process = execFile( - "powershell", - ["-c", `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`], - () => { + powershellPath, + buildWindowsPlayerArgs(soundPath), + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) { + console.warn( + "[ringtone/play] Windows playback failed:", + error.message, + stderr ? `\nstderr: ${stderr}` : "", + ); + } if (currentSession?.id === sessionId) { currentSession = null; } @@ -96,25 +186,60 @@ export const createRingtoneRouter = () => { /** * 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 }; - } + 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 = getSoundPath(input.filename); - playSoundFile(soundPath); + if (process.platform === "win32") { + const sent = sendRingtoneEvent({ channel: "ringtone-play", filename: input.filename }); + if (!sent) { + // Fallback to direct playback if no renderer window available + const soundPath = getSoundPath(input.filename); + playSoundFile(soundPath); + } return { success: true as const }; - }), + } + + const soundPath = getSoundPath(input.filename); + playSoundFile(soundPath); + return { success: true as const }; + }), + + /** + * Stop the currently playing ringtone preview + */ + stop: publicProcedure.mutation(() => { + // Always stop any fallback playback process first + stopCurrentSound(); + + // On Windows, also notify the renderer to stop its playback + if (process.platform === "win32") { + sendRingtoneEvent({ channel: "ringtone-stop" }); + } + + return { success: true as const }; + }), /** - * Stop the currently playing ringtone preview + * Subscribe to ringtone play/stop events. + * Emits events when ringtones are played or stopped on Windows. */ - stop: publicProcedure.mutation(() => { - stopCurrentSound(); - return { success: true as const }; + subscribe: publicProcedure.subscription(() => { + return observable((emit) => { + const handleEvent = (event: RingtoneEvent) => { + emit.next(event); + }; + + ringtoneEvents.on("ringtone-event", handleEvent); + + return () => { + ringtoneEvents.off("ringtone-event", handleEvent); + }; + }); }), /** @@ -149,6 +274,16 @@ export function playNotificationRingtone(filename: string): void { return; // No sound for "none" option } + if (process.platform === "win32") { + const sent = sendRingtoneEvent({ channel: "ringtone-play", filename }); + if (!sent) { + // Fallback to direct playback if no renderer window available + const soundPath = getSoundPath(filename); + playSoundFile(soundPath); + } + return; + } + const soundPath = getSoundPath(filename); playSoundFile(soundPath); } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2a46c20f4e5..ff4eb7df456 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -132,8 +132,9 @@ app.on("before-quit", async (event) => { if (isQuitting) return; const isDev = process.env.NODE_ENV === "development"; + const isMac = process.platform === "darwin"; const shouldConfirm = - !skipConfirmation && !isDev && getConfirmOnQuitSetting(); + isMac && !skipConfirmation && !isDev && getConfirmOnQuitSetting(); if (shouldConfirm) { event.preventDefault(); diff --git a/apps/desktop/src/main/lib/notification-sound.ts b/apps/desktop/src/main/lib/notification-sound.ts index 34388550a5c..c7fa852d3f0 100644 --- a/apps/desktop/src/main/lib/notification-sound.ts +++ b/apps/desktop/src/main/lib/notification-sound.ts @@ -1,6 +1,8 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; +import { join } from "node:path"; import { settings } from "@superset/local-db"; +import { BrowserWindow } from "electron"; import { DEFAULT_RINGTONE_ID, getRingtoneFilename, @@ -8,6 +10,71 @@ import { import { localDb } from "./local-db"; import { getSoundPath } from "./sound-paths"; +function getWindowsPowerShellPath(): string { + const systemRoot = process.env.SystemRoot ?? "C:\\Windows"; + const powershellPath = join( + systemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + return existsSync(powershellPath) ? powershellPath : "powershell.exe"; +} + +function buildWindowsPlayerArgs(soundPath: string): string[] { + const escapedPath = soundPath.replace(/'/g, "''"); + const script = [ + "$ErrorActionPreference = 'Stop'", + "try {", + "$player = New-Object -ComObject WMPlayer.OCX.7", + `$player.URL = '${escapedPath}'`, + "$player.controls.play()", + "$started = $false", + "for ($i = 0; $i -lt 300; $i++) {", + " if ($player.playState -eq 3) { $started = $true }", + " if ($started -and ($player.playState -eq 1 -or $player.playState -eq 8)) { break }", + " Start-Sleep -Milliseconds 200", + "}", + "} catch {", + " try {", + " Add-Type -AssemblyName PresentationCore", + " $media = New-Object System.Windows.Media.MediaPlayer", + ` $media.Open([System.Uri]::new('${escapedPath}'))`, + " $media.Volume = 1.0", + " $media.Play()", + " Start-Sleep -Milliseconds 200", + " while ($media.NaturalDuration.HasTimeSpan -and $media.Position -lt $media.NaturalDuration.TimeSpan) {", + " Start-Sleep -Milliseconds 200", + " }", + " $media.Close()", + " } catch {", + " exit 1", + " }", + "}", + ].join("; "); + + return [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]; +} + +function sendRingtoneEvent(filename: string): boolean { + const window = BrowserWindow.getAllWindows().find( + (browserWindow) => !browserWindow.isDestroyed(), + ); + if (!window) { + return false; + } + window.webContents.send("ringtone-play", filename); + return true; +} + /** * Checks if notification sounds are muted. */ @@ -56,10 +123,20 @@ function playSoundFile(soundPath: string): void { if (process.platform === "darwin") { execFile("afplay", [soundPath]); } else if (process.platform === "win32") { - execFile("powershell", [ - "-c", - `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`, - ]); + const powershellPath = getWindowsPowerShellPath(); + execFile( + powershellPath, + buildWindowsPlayerArgs(soundPath), + { windowsHide: true }, + (error) => { + if (error) { + console.warn( + "[notification-sound] Windows playback failed:", + error.message, + ); + } + }, + ); } else { // Linux - try common audio players execFile("paplay", [soundPath], (error) => { @@ -87,6 +164,10 @@ export function playNotificationSound(): void { return; } + if (process.platform === "win32" && sendRingtoneEvent(filename)) { + return; + } + const soundPath = getSoundPath(filename); playSoundFile(soundPath); } diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index 6bbbdc8cf3e..b9cf8668221 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -24,7 +24,6 @@ import { writeFileSync, } from "node:fs"; import { connect, type Socket } from "node:net"; -import { homedir } from "node:os"; import { join } from "node:path"; import { app } from "electron"; import { @@ -48,6 +47,7 @@ import { type TerminalExitEvent, type WriteRequest, } from "./types"; +import { TERMINAL_HOST_PATHS } from "./paths"; // ============================================================================= // Connection State @@ -65,15 +65,16 @@ enum ConnectionState { const DEBUG_CLIENT = process.env.SUPERSET_TERMINAL_DEBUG === "1"; -const SUPERSET_DIR_NAME = - process.env.NODE_ENV === "development" ? ".superset-dev" : ".superset"; -const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); - -const SOCKET_PATH = join(SUPERSET_HOME_DIR, "terminal-host.sock"); -const TOKEN_PATH = join(SUPERSET_HOME_DIR, "terminal-host.token"); -const PID_PATH = join(SUPERSET_HOME_DIR, "terminal-host.pid"); -const SPAWN_LOCK_PATH = join(SUPERSET_HOME_DIR, "terminal-host.spawn.lock"); -const SCRIPT_MTIME_PATH = join(SUPERSET_HOME_DIR, "terminal-host.mtime"); +const { + IS_WINDOWS, + SUPERSET_DIR_NAME, + SUPERSET_HOME_DIR, + SOCKET_PATH, + TOKEN_PATH, + PID_PATH, + SPAWN_LOCK_PATH, + SCRIPT_MTIME_PATH, +} = TERMINAL_HOST_PATHS; // Connection timeouts const CONNECT_TIMEOUT_MS = 5000; @@ -428,7 +429,7 @@ export class TerminalHostClient extends EventEmitter { private async tryConnectControl(): Promise { return new Promise((resolve) => { - if (!existsSync(SOCKET_PATH)) { + if (!IS_WINDOWS && !existsSync(SOCKET_PATH)) { resolve(false); return; } @@ -468,7 +469,7 @@ export class TerminalHostClient extends EventEmitter { private async tryConnectStream(): Promise { return new Promise((resolve) => { - if (!existsSync(SOCKET_PATH)) { + if (!IS_WINDOWS && !existsSync(SOCKET_PATH)) { resolve(false); return; } @@ -813,7 +814,7 @@ export class TerminalHostClient extends EventEmitter { }: { killSessions?: boolean; } = {}): Promise { - if (!existsSync(SOCKET_PATH)) return; + if (!IS_WINDOWS && !existsSync(SOCKET_PATH)) return; const token = this.readAuthToken(); @@ -908,7 +909,7 @@ export class TerminalHostClient extends EventEmitter { const timeoutMs = 2000; while (Date.now() - startTime < timeoutMs) { - if (!existsSync(SOCKET_PATH)) return; + if (!IS_WINDOWS && !existsSync(SOCKET_PATH)) return; const live = await this.isSocketLive(); if (!live) return; await this.sleep(100); @@ -925,7 +926,7 @@ export class TerminalHostClient extends EventEmitter { */ private isSocketLive(): Promise { return new Promise((resolve) => { - if (!existsSync(SOCKET_PATH)) { + if (!IS_WINDOWS && !existsSync(SOCKET_PATH)) { resolve(false); return; } @@ -1007,7 +1008,7 @@ export class TerminalHostClient extends EventEmitter { private async spawnDaemon(): Promise { // Check if socket is live first - this is the authoritative check // PID file can be stale if daemon crashed and PID was reused by another process - if (existsSync(SOCKET_PATH)) { + if (IS_WINDOWS || existsSync(SOCKET_PATH)) { const isLive = await this.isSocketLive(); if (isLive) { if (DEBUG_CLIENT) { @@ -1017,13 +1018,15 @@ export class TerminalHostClient extends EventEmitter { } // Socket exists but not responsive - safe to remove - if (DEBUG_CLIENT) { - console.log("[TerminalHostClient] Removing stale socket file"); - } - try { - unlinkSync(SOCKET_PATH); - } catch { - // Ignore - might not have permission + if (!IS_WINDOWS) { + if (DEBUG_CLIENT) { + console.log("[TerminalHostClient] Removing stale socket file"); + } + try { + unlinkSync(SOCKET_PATH); + } catch { + // Ignore - might not have permission + } } } @@ -1175,11 +1178,8 @@ export class TerminalHostClient extends EventEmitter { const startTime = Date.now(); while (Date.now() - startTime < SPAWN_WAIT_MS) { - if (existsSync(SOCKET_PATH)) { - // Give it a moment to start listening - await this.sleep(200); - return; - } + const live = await this.isSocketLive(); + if (live) return; await this.sleep(100); } diff --git a/apps/desktop/src/main/lib/terminal-host/paths.ts b/apps/desktop/src/main/lib/terminal-host/paths.ts new file mode 100644 index 00000000000..51a97d3565e --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-host/paths.ts @@ -0,0 +1,28 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +const IS_WINDOWS = process.platform === "win32"; + +const SUPERSET_DIR_NAME = + process.env.NODE_ENV === "development" ? ".superset-dev" : ".superset"; +const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); + +const PIPE_SUFFIX = (process.env.USERNAME ?? process.env.USER ?? "user").replace( + /[^a-zA-Z0-9_.-]/g, + "_", +); + +const SOCKET_PATH = IS_WINDOWS + ? `\\\\.\\pipe\\superset-terminal-host-${PIPE_SUFFIX}` + : join(SUPERSET_HOME_DIR, "terminal-host.sock"); + +export const TERMINAL_HOST_PATHS = { + IS_WINDOWS, + SUPERSET_DIR_NAME, + SUPERSET_HOME_DIR, + SOCKET_PATH, + TOKEN_PATH: join(SUPERSET_HOME_DIR, "terminal-host.token"), + PID_PATH: join(SUPERSET_HOME_DIR, "terminal-host.pid"), + SPAWN_LOCK_PATH: join(SUPERSET_HOME_DIR, "terminal-host.spawn.lock"), + SCRIPT_MTIME_PATH: join(SUPERSET_HOME_DIR, "terminal-host.mtime"), +}; diff --git a/apps/desktop/src/main/lib/terminal/dev-reset.ts b/apps/desktop/src/main/lib/terminal/dev-reset.ts index 2d95e5f8634..d63eb15c46f 100644 --- a/apps/desktop/src/main/lib/terminal/dev-reset.ts +++ b/apps/desktop/src/main/lib/terminal/dev-reset.ts @@ -7,10 +7,10 @@ import { disposeTerminalHostClient, getTerminalHostClient, } from "main/lib/terminal-host/client"; +import { TERMINAL_HOST_PATHS } from "main/lib/terminal-host/paths"; const TERMINAL_STATE_PATHS = [ "terminal-history", - "terminal-host.sock", "terminal-host.token", "terminal-host.pid", "terminal-host.spawn.lock", @@ -18,6 +18,10 @@ const TERMINAL_STATE_PATHS = [ "daemon.log", ] as const; +const TERMINAL_STATE_PATHS_WITH_SOCKET = TERMINAL_HOST_PATHS.IS_WINDOWS + ? TERMINAL_STATE_PATHS + : (["terminal-host.sock", ...TERMINAL_STATE_PATHS] as const); + export async function resetTerminalStateDev(): Promise { console.log("[dev/reset-terminal-state] Resetting terminal state…"); @@ -33,7 +37,7 @@ export async function resetTerminalStateDev(): Promise { disposeTerminalHostClient(); } - for (const relativePath of TERMINAL_STATE_PATHS) { + for (const relativePath of TERMINAL_STATE_PATHS_WITH_SOCKET) { const fullPath = join(SUPERSET_HOME_DIR, relativePath); await rm(fullPath, { recursive: true, force: true }).catch((error) => { console.warn( diff --git a/apps/desktop/src/main/terminal-host/daemon.test.ts b/apps/desktop/src/main/terminal-host/daemon.test.ts index edafcee28ec..4130c74ad27 100644 --- a/apps/desktop/src/main/terminal-host/daemon.test.ts +++ b/apps/desktop/src/main/terminal-host/daemon.test.ts @@ -13,21 +13,22 @@ import type { ChildProcess } from "node:child_process"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { connect, type Socket } from "node:net"; -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; import { type HelloResponse, type IpcRequest, type IpcResponse, PROTOCOL_VERSION, } from "../lib/terminal-host/types"; +import { TERMINAL_HOST_PATHS } from "../lib/terminal-host/paths"; -// Test uses development paths -const SUPERSET_DIR_NAME = ".superset-dev"; -const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); -const SOCKET_PATH = join(SUPERSET_HOME_DIR, "terminal-host.sock"); -const TOKEN_PATH = join(SUPERSET_HOME_DIR, "terminal-host.token"); -const PID_PATH = join(SUPERSET_HOME_DIR, "terminal-host.pid"); +const { + IS_WINDOWS, + SUPERSET_HOME_DIR, + SOCKET_PATH, + TOKEN_PATH, + PID_PATH, +} = TERMINAL_HOST_PATHS; // Path to the daemon source file const DAEMON_PATH = resolve(__dirname, "index.ts"); @@ -56,7 +57,7 @@ describe("Terminal Host Daemon", () => { } // Remove socket file - if (existsSync(SOCKET_PATH)) { + if (!IS_WINDOWS && existsSync(SOCKET_PATH)) { try { rmSync(SOCKET_PATH); } catch { diff --git a/apps/desktop/src/main/terminal-host/index.ts b/apps/desktop/src/main/terminal-host/index.ts index 20d4a78a7b0..cab0be04d6d 100644 --- a/apps/desktop/src/main/terminal-host/index.ts +++ b/apps/desktop/src/main/terminal-host/index.ts @@ -7,8 +7,8 @@ * Run with: ELECTRON_RUN_AS_NODE=1 electron dist/main/terminal-host.js * * IPC Protocol: - * - Uses NDJSON (newline-delimited JSON) over Unix domain socket - * - Socket: ~/.superset/terminal-host.sock + * - Uses NDJSON (newline-delimited JSON) over a local socket + * - Socket: ~/.superset/terminal-host.sock (Unix) or \\.\pipe\superset-terminal-host- (Windows) * - Auth token: ~/.superset/terminal-host.token */ @@ -22,8 +22,6 @@ import { writeFileSync, } from "node:fs"; import { createServer, type Server, Socket } from "node:net"; -import { homedir } from "node:os"; -import { join } from "node:path"; import { type ClearScrollbackRequest, type CreateOrAttachRequest, @@ -44,6 +42,7 @@ import { type TerminalExitEvent, type WriteRequest, } from "../lib/terminal-host/types"; +import { TERMINAL_HOST_PATHS } from "../lib/terminal-host/paths"; import { TerminalHost } from "./terminal-host"; // ============================================================================= @@ -53,14 +52,14 @@ import { TerminalHost } from "./terminal-host"; const DAEMON_VERSION = "1.0.0"; // Determine superset directory based on NODE_ENV -const SUPERSET_DIR_NAME = - process.env.NODE_ENV === "development" ? ".superset-dev" : ".superset"; -const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); - -// Socket and token paths -const SOCKET_PATH = join(SUPERSET_HOME_DIR, "terminal-host.sock"); -const TOKEN_PATH = join(SUPERSET_HOME_DIR, "terminal-host.token"); -const PID_PATH = join(SUPERSET_HOME_DIR, "terminal-host.pid"); +const { + IS_WINDOWS, + SUPERSET_DIR_NAME, + SUPERSET_HOME_DIR, + SOCKET_PATH, + TOKEN_PATH, + PID_PATH, +} = TERMINAL_HOST_PATHS; // ============================================================================= // Logging @@ -636,11 +635,6 @@ function handleConnection(socket: Socket) { */ function isSocketLive(): Promise { return new Promise((resolve) => { - if (!existsSync(SOCKET_PATH)) { - resolve(false); - return; - } - const testSocket = new Socket(); const timeout = setTimeout(() => { testSocket.destroy(); @@ -678,14 +672,14 @@ async function startServer(): Promise { // Check if socket is live before removing it // This prevents orphaning a running daemon - if (existsSync(SOCKET_PATH)) { - const isLive = await isSocketLive(); - if (isLive) { - log("error", "Another daemon is already running and responsive"); - throw new Error("Another daemon is already running"); - } + const isLive = await isSocketLive(); + if (isLive) { + log("error", "Another daemon is already running and responsive"); + throw new Error("Another daemon is already running"); + } - // Socket exists but not responsive - safe to remove + // Socket exists but not responsive - safe to remove (non-Windows only) + if (!IS_WINDOWS && existsSync(SOCKET_PATH)) { try { unlinkSync(SOCKET_PATH); log("info", "Removed stale socket file"); @@ -742,10 +736,12 @@ async function startServer(): Promise { newServer.listen(SOCKET_PATH, () => { // Set socket permissions (readable/writable by owner only) - try { - chmodSync(SOCKET_PATH, 0o600); - } catch { - // May fail on some systems, that's okay - directory permissions protect us + if (!IS_WINDOWS) { + try { + chmodSync(SOCKET_PATH, 0o600); + } catch { + // May fail on some systems, that's okay - directory permissions protect us + } } // Write PID file @@ -778,7 +774,7 @@ function stopServer(): Promise { // Clean up socket and PID files try { - if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH); + if (!IS_WINDOWS && existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH); if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch { // Best effort cleanup diff --git a/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts b/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts index 747b985d451..b6994022cbb 100644 --- a/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts +++ b/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts @@ -15,8 +15,7 @@ import type { ChildProcess } from "node:child_process"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { connect, type Socket } from "node:net"; -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { resolve } from "node:path"; import { type CreateOrAttachRequest, type CreateOrAttachResponse, @@ -27,13 +26,15 @@ import { PROTOCOL_VERSION, type TerminalDataEvent, } from "../lib/terminal-host/types"; +import { TERMINAL_HOST_PATHS } from "../lib/terminal-host/paths"; -// Test uses development paths -const SUPERSET_DIR_NAME = ".superset-dev"; -const SUPERSET_HOME_DIR = join(homedir(), SUPERSET_DIR_NAME); -const SOCKET_PATH = join(SUPERSET_HOME_DIR, "terminal-host.sock"); -const TOKEN_PATH = join(SUPERSET_HOME_DIR, "terminal-host.token"); -const PID_PATH = join(SUPERSET_HOME_DIR, "terminal-host.pid"); +const { + IS_WINDOWS, + SUPERSET_HOME_DIR, + SOCKET_PATH, + TOKEN_PATH, + PID_PATH, +} = TERMINAL_HOST_PATHS; // Path to the daemon source file const DAEMON_PATH = resolve(__dirname, "index.ts"); @@ -60,7 +61,7 @@ describe("Terminal Host Session Lifecycle", () => { } } - if (existsSync(SOCKET_PATH)) { + if (!IS_WINDOWS && existsSync(SOCKET_PATH)) { try { rmSync(SOCKET_PATH); } catch { diff --git a/apps/desktop/src/renderer/components/RingtonePlayer/RingtonePlayer.tsx b/apps/desktop/src/renderer/components/RingtonePlayer/RingtonePlayer.tsx new file mode 100644 index 00000000000..ef98db20e63 --- /dev/null +++ b/apps/desktop/src/renderer/components/RingtonePlayer/RingtonePlayer.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getRingtoneUrl } from "renderer/lib/ringtone-urls"; + +export function RingtonePlayer() { + const audioRef = useRef(null); + + const stopPlayback = () => { + if (!audioRef.current) return; + audioRef.current.pause(); + audioRef.current.currentTime = 0; + }; + + const playPlayback = (filename: string) => { + const url = getRingtoneUrl(filename); + if (!url) { + console.warn("[ringtone] Missing URL for filename:", filename); + return; + } + + if (!audioRef.current) { + audioRef.current = new Audio(); + } + + const audio = audioRef.current; + audio.pause(); + audio.src = url; + audio.currentTime = 0; + audio.volume = 1; + audio.play().catch((error) => { + console.warn("[ringtone] Failed to play audio:", error); + }); + }; + + // Subscribe to ringtone events via tRPC + electronTrpc.ringtone.subscribe.useSubscription(undefined, { + onData: (event) => { + if (event.type === "play") { + playPlayback(event.filename); + } else if (event.type === "stop") { + stopPlayback(); + } + }, + }); + + useEffect(() => { + return () => { + stopPlayback(); + }; + }, []); + + return null; +} diff --git a/apps/desktop/src/renderer/components/RingtonePlayer/index.ts b/apps/desktop/src/renderer/components/RingtonePlayer/index.ts new file mode 100644 index 00000000000..b967af1b2c0 --- /dev/null +++ b/apps/desktop/src/renderer/components/RingtonePlayer/index.ts @@ -0,0 +1 @@ +export { RingtonePlayer } from "./RingtonePlayer"; diff --git a/apps/desktop/src/renderer/lib/ringtone-urls.ts b/apps/desktop/src/renderer/lib/ringtone-urls.ts new file mode 100644 index 00000000000..b4cc02e65e6 --- /dev/null +++ b/apps/desktop/src/renderer/lib/ringtone-urls.ts @@ -0,0 +1,13 @@ +import { RINGTONES } from "shared/ringtones"; + +const ringtoneUrlMap = new Map( + RINGTONES.map((ringtone) => [ + ringtone.filename, + new URL(`../../resources/sounds/${ringtone.filename}`, import.meta.url) + .toString(), + ]), +); + +export function getRingtoneUrl(filename: string): string | null { + return ringtoneUrlMap.get(filename) ?? null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WindowControls/WindowControls.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WindowControls/WindowControls.tsx index a7f0a36fb12..745cc3ae96b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WindowControls/WindowControls.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WindowControls/WindowControls.tsx @@ -19,24 +19,24 @@ export function WindowControls() { }; return ( -
+