diff --git a/packages/electron-app/.gitignore b/packages/electron-app/.gitignore index 2dfa475b..3b43e35f 100644 --- a/packages/electron-app/.gitignore +++ b/packages/electron-app/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ release/ .vite/ +electron/resources/server/ diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 3e88adf2..10e4b3c6 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" import fs from "fs" +import { requestMicrophoneAccess } from "./permissions" import type { CliProcessManager, CliStatus } from "./process-manager" let wakeLockId: number | null = null @@ -111,6 +112,11 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return { enabled: false } }) + ipcMain.handle( + "media:requestMicrophoneAccess", + async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }), + ) + ipcMain.handle( "notifications:show", async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 80038b02..13e1d7a7 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -6,6 +6,7 @@ import { dirname, join } from "path" import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" import { setupCliIPC } from "./ipc" +import { configureMediaPermissionHandlers } from "./permissions" import { CliProcessManager } from "./process-manager" const mainFilename = fileURLToPath(import.meta.url) @@ -489,6 +490,7 @@ app.whenReady().then(() => { if (isMac) { session.defaultSession.setSpellCheckerEnabled(false) + configureMediaPermissionHandlers(getAllowedRendererOrigins) app.on("browser-window-created", (_, window) => { window.webContents.session.setSpellCheckerEnabled(false) }) diff --git a/packages/electron-app/electron/main/permissions.ts b/packages/electron-app/electron/main/permissions.ts new file mode 100644 index 00000000..28652321 --- /dev/null +++ b/packages/electron-app/electron/main/permissions.ts @@ -0,0 +1,58 @@ +import { session, systemPreferences } from "electron" + +const isMac = process.platform === "darwin" + +export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean { + if (!origin) { + return false + } + + try { + const normalized = new URL(origin).origin + return allowedOrigins.includes(normalized) + } catch { + return false + } +} + +export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) { + const isAudioMediaRequest = (permission: string, details?: unknown) => { + if (permission !== "media") { + return false + } + + const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? [] + return mediaTypes.length === 0 || mediaTypes.includes("audio") + } + + session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { + if (!isAudioMediaRequest(permission, details)) { + return false + } + + return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()) + }) + + session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + if (!isAudioMediaRequest(permission, details)) { + callback(false) + return + } + + const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL() + callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())) + }) +} + +export async function requestMicrophoneAccess(): Promise { + if (!isMac) { + return true + } + + const status = systemPreferences.getMediaAccessStatus("microphone") + if (status === "granted") { + return true + } + + return systemPreferences.askForMediaAccess("microphone") +} diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index de00d1f5..f5221017 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -1,14 +1,17 @@ import { spawn, spawnSync, type ChildProcess } from "child_process" -import { app } from "electron" +import { app, utilityProcess, type UtilityProcess } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" import { existsSync, readFileSync } from "fs" import os from "os" import path from "path" +import { fileURLToPath } from "url" import { parse as parseYaml } from "yaml" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" const nodeRequire = createRequire(import.meta.url) +const mainFilename = fileURLToPath(import.meta.url) +const mainDirname = path.dirname(mainFilename) const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" @@ -38,6 +41,9 @@ interface CliEntryResolution { runnerPath?: string } +type ManagedChild = ChildProcess | UtilityProcess +type ChildLaunchMode = "spawn" | "utility" + const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" function isYamlPath(filePath: string): boolean { @@ -117,7 +123,8 @@ export declare interface CliProcessManager { } export class CliProcessManager extends EventEmitter { - private child?: ChildProcess + private child?: ManagedChild + private childLaunchMode: ChildLaunchMode = "spawn" private status: CliStatus = { state: "stopped" } private stdoutBuffer = "" private stderrBuffer = "" @@ -135,33 +142,63 @@ export class CliProcessManager extends EventEmitter { this.requestedStop = false this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) - const cliEntry = this.resolveCliEntry(options) const listeningMode = this.resolveListeningMode() const host = resolveHostForMode(listeningMode) const args = this.buildCliArgs(options, host) - console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, - ) + let child: ManagedChild - const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } - env.ELECTRON_RUN_AS_NODE = "1" + if (this.shouldUsePackagedShellSupervisor(options)) { + const runtimePath = this.resolveShellNodeCommand() + const entryPath = this.resolveBundledProdEntry() + const supervisorPath = this.resolveCliSupervisorPath() + const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env } + const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`) + const supervisorPayload = JSON.stringify({ + command: shellCommand.command, + args: shellCommand.args, + cwd: process.cwd(), + }) - const spawnDetails = supportsUserShell() - ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) - : this.buildDirectSpawn(cliEntry, args) + console.info( + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`, + ) + console.info(`[cli] utility supervisor: ${supervisorPath}`) + console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`) - const detached = process.platform !== "win32" - const child = spawn(spawnDetails.command, spawnDetails.args, { - cwd: process.cwd(), - stdio: ["ignore", "pipe", "pipe"], - env, - shell: false, - detached, - }) + child = utilityProcess.fork(supervisorPath, [supervisorPayload], { + env: shellEnv, + stdio: "pipe", + serviceName: "CodeNomad CLI Supervisor", + }) + this.childLaunchMode = "utility" + } else { + const cliEntry = this.resolveCliEntry(options) + console.info( + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, + ) - console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) - if (!child.pid) { + const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } + env.ELECTRON_RUN_AS_NODE = "1" + + const spawnDetails = supportsUserShell() + ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) + : this.buildDirectSpawn(cliEntry, args) + + const detached = process.platform !== "win32" + child = spawn(spawnDetails.command, spawnDetails.args, { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + env, + shell: false, + detached, + }) + + console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) + this.childLaunchMode = "spawn" + } + + if (this.childLaunchMode === "spawn" && !child.pid) { console.error("[cli] spawn failed: no pid") } @@ -176,23 +213,48 @@ export class CliProcessManager extends EventEmitter { this.handleStream(data.toString(), "stderr") }) - child.on("error", (error) => { - console.error("[cli] failed to start CLI:", error) - this.updateStatus({ state: "error", error: error.message }) - this.emit("error", error) - }) + if (this.childLaunchMode === "utility") { + const utilityChild = child as UtilityProcess - child.on("exit", (code, signal) => { - const failed = this.status.state !== "ready" - const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined - console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) - this.updateStatus({ state: failed ? "error" : "stopped", error }) - if (failed && error) { - this.emit("error", new Error(error)) - } - this.emit("exit", this.status) - this.child = undefined - }) + utilityChild.on("error", (error) => { + const message = this.describeUtilityProcessError(error) + console.error("[cli] utility supervisor failed:", error) + this.updateStatus({ state: "error", error: message }) + this.emit("error", new Error(message)) + }) + + utilityChild.on("exit", (code) => { + const failed = this.status.state !== "ready" + const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined + console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`) + this.updateStatus({ state: failed ? "error" : "stopped", error }) + if (failed && error) { + this.emit("error", new Error(error)) + } + this.emit("exit", this.status) + this.child = undefined + }) + } else { + const spawnedChild = child as ChildProcess + + spawnedChild.on("error", (error) => { + console.error("[cli] failed to start CLI:", error) + this.updateStatus({ state: "error", error: error.message }) + this.emit("error", error) + }) + + spawnedChild.on("exit", (code, signal) => { + const failed = this.status.state !== "ready" + const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined + console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) + this.updateStatus({ state: failed ? "error" : "stopped", error }) + if (failed && error) { + this.emit("error", new Error(error)) + } + this.emit("exit", this.status) + this.child = undefined + }) + } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -219,16 +281,22 @@ export class CliProcessManager extends EventEmitter { return } + if (this.childLaunchMode === "utility") { + return this.stopUtilityChild(child as UtilityProcess) + } + + const spawnedChild = child as ChildProcess + this.requestedStop = true - const pid = child.pid + const pid = spawnedChild.pid if (!pid) { this.child = undefined this.updateStatus({ state: "stopped" }) return } - const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null + const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null const tryKillPosixGroup = (signal: NodeJS.Signals) => { try { @@ -304,7 +372,7 @@ export class CliProcessManager extends EventEmitter { sendStopSignal("SIGKILL") }, 30000) - child.on("exit", () => { + spawnedChild.on("exit", () => { clearTimeout(killTimeout) this.child = undefined console.info("[cli] CLI process exited") @@ -324,6 +392,46 @@ export class CliProcessManager extends EventEmitter { }) } + private stopUtilityChild(child: UtilityProcess): Promise { + this.requestedStop = true + + const pid = child.pid + if (!pid) { + this.child = undefined + this.updateStatus({ state: "stopped" }) + return Promise.resolve() + } + + return new Promise((resolve) => { + const killTimeout = setTimeout(() => { + console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`) + try { + process.kill(pid, "SIGKILL") + } catch { + // no-op + } + }, 30000) + + child.once("exit", () => { + clearTimeout(killTimeout) + this.child = undefined + console.info("[cli] CLI process exited") + this.updateStatus({ state: "stopped" }) + resolve() + }) + + if (child.pid === undefined) { + clearTimeout(killTimeout) + this.child = undefined + this.updateStatus({ state: "stopped" }) + resolve() + return + } + + child.kill() + }) + } + getStatus(): CliStatus { return { ...this.status } } @@ -335,14 +443,22 @@ export class CliProcessManager extends EventEmitter { private handleTimeout() { if (this.child) { const pid = this.child.pid - if (pid && process.platform !== "win32") { + if (this.childLaunchMode === "utility") { + if (pid) { + try { + process.kill(pid, "SIGKILL") + } catch { + // no-op + } + } + } else if (pid && process.platform !== "win32") { try { process.kill(-pid, "SIGKILL") } catch { - this.child.kill("SIGKILL") + ;(this.child as ChildProcess).kill("SIGKILL") } } else { - this.child.kill("SIGKILL") + ;(this.child as ChildProcess).kill("SIGKILL") } this.child = undefined } @@ -449,6 +565,10 @@ export class CliProcessManager extends EventEmitter { return parts.join(" ") } + private buildExecutableCommand(command: string, args: string[]): string { + return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ") + } + private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) { if (cliEntry.runner === "tsx") { return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] } @@ -519,4 +639,58 @@ export class CliProcessManager extends EventEmitter { } throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.") } + + private shouldUsePackagedShellSupervisor(options: StartOptions): boolean { + return !options.dev && app.isPackaged && process.platform === "darwin" + } + + private resolveCliSupervisorPath(): string { + const candidates = [ + path.join(process.resourcesPath, "cli-supervisor.cjs"), + path.join(mainDirname, "../resources/cli-supervisor.cjs"), + ] + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + + throw new Error("Unable to locate CodeNomad CLI supervisor script.") + } + + private resolveShellNodeCommand(): string { + const configured = process.env.NODE_BINARY?.trim() + return configured && configured.length > 0 ? configured : "node" + } + + private resolveBundledProdEntry(): string { + const candidates = [ + path.join(process.resourcesPath, "server", "dist", "bin.js"), + path.join(mainDirname, "../resources/server/dist/bin.js"), + ] + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + + throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.") + } + + private describeUtilityProcessError(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message + } + + if (error && typeof error === "object") { + const typed = error as { type?: unknown; location?: unknown } + if (typeof typed.type === "string") { + return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type + } + } + + return String(error) + } } diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 75bad994..06cb9cad 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -20,6 +20,7 @@ const electronAPI = { return null } }, + requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), } diff --git a/packages/electron-app/electron/resources/cli-supervisor.cjs b/packages/electron-app/electron/resources/cli-supervisor.cjs new file mode 100644 index 00000000..3ac319e3 --- /dev/null +++ b/packages/electron-app/electron/resources/cli-supervisor.cjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +const { spawn } = require("child_process") + +const SHUTDOWN_GRACE_MS = 30_000 + +let child = null +let shutdownTimer = null + +function log(message, error) { + if (error) { + console.error(`[cli-supervisor] ${message}`, error) + return + } + console.log(`[cli-supervisor] ${message}`) +} + +function clearShutdownTimer() { + if (shutdownTimer) { + clearTimeout(shutdownTimer) + shutdownTimer = null + } +} + +function forwardStream(stream, target) { + if (!stream) return + stream.on("data", (chunk) => { + target.write(chunk) + }) +} + +function terminateChild(force) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return + } + + try { + child.kill(force ? "SIGKILL" : "SIGTERM") + } catch { + // no-op + } +} + +function requestShutdown(force = false) { + if (!child) { + process.exit(force ? 1 : 0) + return + } + + terminateChild(force) + if (force) { + process.exit(1) + return + } + + clearShutdownTimer() + shutdownTimer = setTimeout(() => { + log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`) + terminateChild(true) + }, SHUTDOWN_GRACE_MS) + shutdownTimer.unref() +} + +function installShutdownHandlers() { + process.on("SIGTERM", () => requestShutdown(false)) + process.on("SIGINT", () => requestShutdown(false)) + process.on("disconnect", () => requestShutdown(false)) + process.on("uncaughtException", (error) => { + log("uncaught exception", error) + requestShutdown(true) + }) + process.on("unhandledRejection", (error) => { + log("unhandled rejection", error) + requestShutdown(true) + }) +} + +function parsePayload() { + const raw = process.argv[2] + if (!raw) { + throw new Error("Supervisor payload is required") + } + + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== "object") { + throw new Error("Supervisor payload must be an object") + } + if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) { + throw new Error("Supervisor payload command is required") + } + if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) { + throw new Error("Supervisor payload args must be a string array") + } + + return { + command: parsed.command, + args: parsed.args, + cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(), + } +} + +function main() { + installShutdownHandlers() + + const payload = parsePayload() + log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`) + + child = spawn(payload.command, payload.args, { + cwd: payload.cwd, + env: process.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }) + + forwardStream(child.stdout, process.stdout) + forwardStream(child.stderr, process.stderr) + + child.on("error", (error) => { + log("failed to spawn shell command", error) + process.exit(1) + }) + + child.on("exit", (code, signal) => { + clearShutdownTimer() + log(`child exited code=${code ?? ""} signal=${signal ?? ""}`) + process.exitCode = typeof code === "number" ? code : signal ? 1 : 0 + process.exit() + }) +} + +main() diff --git a/packages/electron-app/electron/resources/entitlements.mac.plist b/packages/electron-app/electron/resources/entitlements.mac.plist new file mode 100644 index 00000000..53fdf0fc --- /dev/null +++ b/packages/electron-app/electron/resources/entitlements.mac.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.audio-input + + + diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 07008f33..91062ec5 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -20,6 +20,8 @@ "dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev", "dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts", + "prepare:resources": "node scripts/prepare-resources.js", + "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", "preview": "electron-vite preview", @@ -33,8 +35,11 @@ "build:linux-arm64": "node scripts/build.js linux-arm64", "build:linux-rpm": "node scripts/build.js linux-rpm", "build:all": "node scripts/build.js all", + "prepackage:mac": "npm run prepare:resources", "package:mac": "electron-builder --mac", + "prepackage:win": "npm run prepare:resources", "package:win": "electron-builder --win", + "prepackage:linux": "npm run prepare:resources", "package:linux": "electron-builder --linux" }, "dependencies": { @@ -82,6 +87,12 @@ } ], "mac": { + "entitlements": "electron/resources/entitlements.mac.plist", + "entitlementsInherit": "electron/resources/entitlements.mac.plist", + "extendInfo": { + "NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.", + "NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services." + }, "category": "public.app-category.developer-tools", "target": [ { diff --git a/packages/electron-app/scripts/build.js b/packages/electron-app/scripts/build.js index 4cc52ce7..636170d8 100644 --- a/packages/electron-app/scripts/build.js +++ b/packages/electron-app/scripts/build.js @@ -111,6 +111,12 @@ async function build(platform) { env: { NODE_PATH: workspaceNodeModulesPath }, }) + console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n") + await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], { + cwd: workspaceRoot, + env: { NODE_PATH: workspaceNodeModulesPath }, + }) + console.log("\n📦 Step 2/3: Building Electron app...\n") await run(npmCmd, ["run", "build"]) diff --git a/packages/electron-app/scripts/prepare-resources.js b/packages/electron-app/scripts/prepare-resources.js new file mode 100644 index 00000000..b677a1dc --- /dev/null +++ b/packages/electron-app/scripts/prepare-resources.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +import fs from "fs" +import path, { join } from "path" +import { execFileSync } from "child_process" +import { fileURLToPath } from "url" + +const __dirname = fileURLToPath(new URL(".", import.meta.url)) +const appDir = join(__dirname, "..") +const workspaceRoot = join(appDir, "..", "..") +const serverRoot = join(appDir, "..", "server") +const resourcesRoot = join(appDir, "electron", "resources") +const serverDest = join(resourcesRoot, "server") +const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" + +const serverSources = ["dist", "public", "node_modules", "package.json"] +const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json") + +function log(message) { + console.log(`[prepare-resources] ${message}`) +} + +function ensureServerBuild() { + const distPath = join(serverRoot, "dist") + const publicPath = join(serverRoot, "public") + if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) { + throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.") + } +} + +function ensureServerDependencies() { + if (fs.existsSync(serverDepsMarker)) { + return + } + + log("installing production server dependencies") + execFileSync( + npmCmd, + [ + "install", + "--omit=dev", + "--ignore-scripts", + "--workspaces=false", + "--package-lock=false", + "--install-strategy=shallow", + "--fund=false", + "--audit=false", + ], + { + cwd: serverRoot, + stdio: "inherit", + env: { + ...process.env, + PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + ) +} + +function copyServerArtifacts() { + fs.rmSync(serverDest, { recursive: true, force: true }) + fs.mkdirSync(serverDest, { recursive: true }) + + for (const name of serverSources) { + const from = join(serverRoot, name) + const to = join(serverDest, name) + if (!fs.existsSync(from)) { + throw new Error(`Missing required server artifact: ${from}`) + } + fs.cpSync(from, to, { recursive: true, dereference: true }) + log(`copied ${name} to Electron resources`) + } +} + +function stripNodeModuleBins() { + const root = join(serverDest, "node_modules") + if (!fs.existsSync(root)) { + return + } + + const stack = [root] + let removed = 0 + + while (stack.length > 0) { + const current = stack.pop() + if (!current) break + + let entries + try { + entries = fs.readdirSync(current, { withFileTypes: true }) + } catch { + continue + } + + for (const entry of entries) { + const full = join(current, entry.name) + if (entry.name === ".bin") { + fs.rmSync(full, { recursive: true, force: true }) + removed += 1 + continue + } + + if (entry.isDirectory()) { + stack.push(full) + } + } + } + + if (removed > 0) { + log(`removed ${removed} node_modules/.bin directories`) + } +} + +async function main() { + ensureServerBuild() + ensureServerDependencies() + copyServerArtifacts() + stripNodeModuleBins() +} + +main().catch((error) => { + console.error("[prepare-resources] failed:", error) + process.exit(1) +}) diff --git a/packages/electron-app/tsconfig.json b/packages/electron-app/tsconfig.json index af517920..0953e045 100644 --- a/packages/electron-app/tsconfig.json +++ b/packages/electron-app/tsconfig.json @@ -14,5 +14,5 @@ "noEmit": true }, "include": ["electron/**/*.ts", "electron.vite.config.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "electron/resources/server"] } diff --git a/packages/server/src/speech/providers/openai-compatible.ts b/packages/server/src/speech/providers/openai-compatible.ts index 0db8da7f..bc4cffb8 100644 --- a/packages/server/src/speech/providers/openai-compatible.ts +++ b/packages/server/src/speech/providers/openai-compatible.ts @@ -147,19 +147,49 @@ export class OpenAICompatibleSpeechProvider { } const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1")) - const response = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${settings.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: settings.ttsModel, - voice: settings.ttsVoice, - input: text, - response_format: format, - }), - }) + let response: Response + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${settings.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: settings.ttsModel, + voice: settings.ttsVoice, + input: text, + response_format: format, + }), + }) + } catch (error) { + const detailedError = error as Error & { + cause?: unknown + code?: string + errno?: number | string + syscall?: string + address?: string + port?: number + } + this.options.logger.error( + { + err: error, + endpoint: endpoint.toString(), + baseUrl: settings.baseUrl, + model: settings.ttsModel, + voice: settings.ttsVoice, + format, + cause: detailedError.cause, + code: detailedError.code, + errno: detailedError.errno, + syscall: detailedError.syscall, + address: detailedError.address, + port: detailedError.port, + }, + "speech.synthesize fetch failed", + ) + throw error + } if (!response.ok) { const detail = await response.text() diff --git a/packages/tauri-app/src-tauri/Info.plist b/packages/tauri-app/src-tauri/Info.plist new file mode 100644 index 00000000..dd135cbc --- /dev/null +++ b/packages/tauri-app/src-tauri/Info.plist @@ -0,0 +1,10 @@ + + + + + NSMicrophoneUsageDescription + CodeNomad needs microphone access for speech-to-text prompt input. + NSLocalNetworkUsageDescription + CodeNomad needs local network access to connect to locally hosted AI and speech services. + + diff --git a/packages/ui/src/components/prompt-input/usePromptVoiceInput.ts b/packages/ui/src/components/prompt-input/usePromptVoiceInput.ts index 9a2713b8..0b8f93f8 100644 --- a/packages/ui/src/components/prompt-input/usePromptVoiceInput.ts +++ b/packages/ui/src/components/prompt-input/usePromptVoiceInput.ts @@ -3,6 +3,7 @@ import { showAlertDialog } from "../../stores/alerts" import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech" import { serverApi } from "../../lib/api-client" import { useI18n } from "../../lib/i18n" +import { isElectronHost } from "../../lib/runtime-env" interface UsePromptVoiceInputOptions { prompt: Accessor @@ -88,6 +89,14 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) { try { recordedChunks = [] shouldTranscribe = true + + if (isElectronHost()) { + const granted = await (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.requestMicrophoneAccess?.() + if (granted && !granted.granted) { + throw new Error(t("promptInput.voiceInput.error.permissionDenied")) + } + } + mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }) mediaRecorder = createRecorder(mediaStream) diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index 49e82226..93e07cb4 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -156,6 +156,7 @@ export const messagingMessages = { "promptInput.voiceInput.transcribing.title": "Transcribing audio", "promptInput.voiceInput.error.title": "Voice input failed", "promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.", + "promptInput.voiceInput.error.permissionDenied": "Microphone access was denied by macOS.", "promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.", "promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index 9a8297d6..2fbedea7 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -158,6 +158,7 @@ export const messagingMessages = { "promptInput.voiceInput.transcribing.title": "Transcribiendo audio", "promptInput.voiceInput.error.title": "La entrada de voz falló", "promptInput.voiceInput.error.permission": "Se requiere acceso al micrófono para grabar la entrada de voz.", + "promptInput.voiceInput.error.permissionDenied": "macOS denegó el acceso al micrófono.", "promptInput.voiceInput.error.unsupported": "La entrada de voz no es compatible con este navegador.", "promptInput.voiceInput.error.transcribe": "No se pudo transcribir el audio grabado.", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index 02f2d1e4..6c3a8751 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -158,6 +158,7 @@ export const messagingMessages = { "promptInput.voiceInput.transcribing.title": "Transcription de l'audio", "promptInput.voiceInput.error.title": "Échec de la saisie vocale", "promptInput.voiceInput.error.permission": "L'accès au microphone est requis pour enregistrer la saisie vocale.", + "promptInput.voiceInput.error.permissionDenied": "macOS a refusé l'accès au microphone.", "promptInput.voiceInput.error.unsupported": "La saisie vocale n'est pas prise en charge dans ce navigateur.", "promptInput.voiceInput.error.transcribe": "Impossible de transcrire l'audio enregistré.", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/messaging.ts b/packages/ui/src/lib/i18n/messages/he/messaging.ts index fa0c98ff..54777f9a 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -156,6 +156,7 @@ export const messagingMessages = { "promptInput.voiceInput.transcribing.title": "מתמלל אודיו", "promptInput.voiceInput.error.title": "קלט קולי נכשל", "promptInput.voiceInput.error.permission": "נדרשת גישה למיקרופון כדי להקליט קלט קולי.", + "promptInput.voiceInput.error.permissionDenied": "הגישה למיקרופון נדחתה על ידי macOS.", "promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.", "promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index 566e5f4a..2c3cb6c3 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -158,6 +158,7 @@ export const messagingMessages = { "promptInput.voiceInput.transcribing.title": "音声を文字起こし中", "promptInput.voiceInput.error.title": "音声入力に失敗しました", "promptInput.voiceInput.error.permission": "音声入力を録音するにはマイクへのアクセスが必要です。", + "promptInput.voiceInput.error.permissionDenied": "macOS によりマイクへのアクセスが拒否されました。", "promptInput.voiceInput.error.unsupported": "このブラウザーでは音声入力はサポートされていません。", "promptInput.voiceInput.error.transcribe": "録音した音声を文字起こしできませんでした。", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index 0b2019aa..0635a95a 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -158,6 +158,7 @@ export const messagingMessages = { "promptInput.voiceInput.transcribing.title": "Идёт расшифровка аудио", "promptInput.voiceInput.error.title": "Сбой голосового ввода", "promptInput.voiceInput.error.permission": "Для записи голосового ввода требуется доступ к микрофону.", + "promptInput.voiceInput.error.permissionDenied": "macOS запретила доступ к микрофону.", "promptInput.voiceInput.error.unsupported": "Голосовой ввод не поддерживается в этом браузере.", "promptInput.voiceInput.error.transcribe": "Не удалось расшифровать записанное аудио.", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index b047e36e..0f653b57 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -158,6 +158,7 @@ export const messagingMessages = { "promptInput.voiceInput.transcribing.title": "正在转写音频", "promptInput.voiceInput.error.title": "语音输入失败", "promptInput.voiceInput.error.permission": "录制语音输入需要麦克风访问权限。", + "promptInput.voiceInput.error.permissionDenied": "macOS 已拒绝麦克风访问。", "promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。", "promptInput.voiceInput.error.transcribe": "无法转写录制的音频。", } as const diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index ac9265e1..258f85fe 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -29,6 +29,7 @@ declare global { openDialog?: (options: ElectronDialogOptions) => Promise getDirectoryPaths?: (paths: string[]) => Promise getPathForFile?: (file: File) => string | null + requestMicrophoneAccess?: () => Promise<{ granted: boolean }> setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>