From 95f4b4b92b0d3335f8de1e8c8eca00014e8c0dc9 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sat, 3 Oct 2026 17:45:31 +0900 Subject: [PATCH] fix(electron): decrypt keys after ready and harden navigation and IPC - Apply preset env after app ready, so Windows/Linux get decrypted keys - Never re-encrypt ciphertext; restore env when switching or removing presets - Block navigation away from the app, open external links in the browser, check IPC senders - Keep inherited proxy settings, default NO_PROXY for localhost - Serialize server start/restart, kill stuck processes, follow port changes - Atomic config writes, keep corrupt files as backups, remember the server port - Menu and settings window stay in sync; dev script gets the decrypted preset env - Use app.isPackaged, parse inline .env comments, drop .env files from the bundle --- electron/main/app-menu.ts | 83 +++++++++---- electron/main/config-manager.ts | 192 ++++++++++++++++++----------- electron/main/env-loader.ts | 16 ++- electron/main/index.ts | 68 +++++++--- electron/main/ipc-handlers.ts | 138 +++++++++++++-------- electron/main/next-server.ts | 111 ++++++++++------- electron/main/port-manager.ts | 51 +++++++- electron/main/proxy-manager.ts | 23 +++- electron/main/window-manager.ts | 39 +++++- electron/settings/settings.js | 13 +- scripts/electron-dev.mjs | 157 +++++++++++++---------- scripts/prepare-electron-build.mjs | 9 ++ 12 files changed, 607 insertions(+), 293 deletions(-) diff --git a/electron/main/app-menu.ts b/electron/main/app-menu.ts index bd778294..96838b2b 100644 --- a/electron/main/app-menu.ts +++ b/electron/main/app-menu.ts @@ -32,6 +32,55 @@ export function rebuildAppMenu(): void { buildAppMenu() } +/** + * Apply a preset and restart the server so it takes effect. + * If the restart fails, go back to the previous preset and restart again, + * so the running server always matches the saved current preset. + * Throws an error describing the outcome on failure. + */ +export async function switchPreset( + id: string, +): Promise> { + const previousPresetId = getCurrentPresetId() + const env = applyPresetToEnv(id) + if (!env) { + throw new Error("Preset not found") + } + rebuildAppMenu() + + // In development, scripts/electron-dev.mjs restarts the Next.js dev server + if (!app.isPackaged) { + return env + } + + try { + await restartNextServer() + return env + } catch (error) { + console.error("Failed to restart server:", error) + const reason = error instanceof Error ? error.message : String(error) + + // Revert to previous preset on failure + if (!previousPresetId || !applyPresetToEnv(previousPresetId)) { + setCurrentPreset(null) + } + // Rebuild menu to restore previous checkmark state + rebuildAppMenu() + + try { + await restartNextServer() + } catch (retryError) { + console.error("Failed to restart server again:", retryError) + throw new Error( + `The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`, + ) + } + throw new Error( + `The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`, + ) + } +} + /** * Get the menu template with translations */ @@ -192,32 +241,14 @@ function buildConfigMenu( type: "radio", checked: preset.id === currentPresetId, click: async () => { - const previousPresetId = getCurrentPresetId() - const env = applyPresetToEnv(preset.id) - - if (env) { - try { - await restartNextServer() - rebuildAppMenu() // Rebuild menu to update checkmarks - } catch (error) { - console.error("Failed to restart server:", error) - - // Revert to previous preset on failure - if (previousPresetId) { - applyPresetToEnv(previousPresetId) - } else { - setCurrentPreset(null) - } - - // Rebuild menu to restore previous checkmark state - rebuildAppMenu() - - // Show error dialog to notify user - dialog.showErrorBox( - "Configuration Error", - `Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`, - ) - } + try { + await switchPreset(preset.id) + } catch (error) { + // Show error dialog to notify user + dialog.showErrorBox( + "Configuration Error", + `Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`, + ) } }, })) diff --git a/electron/main/config-manager.ts b/electron/main/config-manager.ts index 234c79cc..38bd07a1 100644 --- a/electron/main/config-manager.ts +++ b/electron/main/config-manager.ts @@ -1,5 +1,11 @@ import { randomUUID } from "node:crypto" -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs" import path from "node:path" import { app, safeStorage } from "electron" @@ -30,7 +36,9 @@ let hasWarnedAboutPlaintext = false * Warns if encryption is not available (API key stored in plaintext) */ function encryptValue(value: string): string { - if (!value) { + // Already encrypted (a value that could not be decrypted): keep it as is + // instead of wrapping it in a second layer of encryption + if (!value || value.startsWith(ENCRYPTED_PREFIX)) { return value } @@ -61,6 +69,7 @@ function encryptValue(value: string): string { /** * Decrypt a sensitive value using safeStorage * Returns the original value if it's not encrypted or decryption fails + * (so saving writes the stored ciphertext back unchanged) */ function decryptValue(value: string): string { if (!value || !value.startsWith(ENCRYPTED_PREFIX)) { @@ -179,6 +188,15 @@ export function loadPresets(): ConfigPresetsFile { return data } catch (error) { console.error("Failed to load config presets:", error) + // Move the unreadable file aside so the next save can't overwrite + // the user's presets with an empty list + const backupPath = `${configPath}.corrupt-${Date.now()}` + try { + renameSync(configPath, backupPath) + console.error(`Unreadable config presets moved to ${backupPath}`) + } catch (renameError) { + console.error("Failed to back up config presets:", renameError) + } return { version: 1, currentPresetId: null, @@ -211,7 +229,11 @@ export function savePresets(data: ConfigPresetsFile): void { } try { - writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8") + // Write a temp file and rename it, so a crash mid-write can't leave + // a truncated config file + const tempPath = `${configPath}.tmp` + writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8") + renameSync(tempPath, configPath) } catch (error) { console.error("Failed to save config presets:", error) throw error @@ -307,9 +329,10 @@ export function deletePreset(id: string): boolean { data.presets.splice(index, 1) - // Clear current preset if it was deleted + // Clear current preset (and its env vars) if it was deleted if (data.currentPresetId === id) { data.currentPresetId = null + setPresetEnv(null) } savePresets(data) @@ -322,13 +345,15 @@ export function deletePreset(id: string): boolean { export function setCurrentPreset(id: string | null): boolean { const data = loadPresets() + let preset: ConfigPreset | null = null if (id !== null) { - const preset = data.presets.find((p) => p.id === id) + preset = data.presets.find((p) => p.id === id) || null if (!preset) { return false } } + setPresetEnv(preset) data.currentPresetId = id savePresets(data) return true @@ -365,78 +390,23 @@ const PROVIDER_ENV_MAP: Record = { } /** - * Apply preset environment variables to the current process - * Returns the environment variables that were applied - */ -export function applyPresetToEnv(id: string): Record | null { - const data = loadPresets() - const preset = data.presets.find((p) => p.id === id) - - if (!preset) { - return null - } - - const appliedEnv: Record = {} - const provider = preset.config.AI_PROVIDER?.toLowerCase() - - for (const [key, value] of Object.entries(preset.config)) { - if (value !== undefined && value !== "") { - // Map generic AI_API_KEY to provider-specific key - if ( - key === "AI_API_KEY" && - provider && - PROVIDER_ENV_MAP[provider] - ) { - const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey - if (providerApiKey) { - process.env[providerApiKey] = value - appliedEnv[providerApiKey] = value - } - } - // Map generic AI_BASE_URL to provider-specific key - else if ( - key === "AI_BASE_URL" && - provider && - PROVIDER_ENV_MAP[provider] - ) { - const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl - if (providerBaseUrl) { - process.env[providerBaseUrl] = value - appliedEnv[providerBaseUrl] = value - } - } - // Apply other env vars directly - else { - process.env[key] = value - appliedEnv[key] = value - } - } - } - - // Set as current preset - data.currentPresetId = id - savePresets(data) - - return appliedEnv -} - -/** - * Get environment variables from current preset + * Map a preset's config to environment variables * Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys */ -export function getCurrentPresetEnv(): Record { - const preset = getCurrentPreset() - if (!preset) { - return {} - } - +function presetToEnv(preset: ConfigPreset): Record { const env: Record = {} const provider = preset.config.AI_PROVIDER?.toLowerCase() for (const [key, value] of Object.entries(preset.config)) { if (value !== undefined && value !== "") { + // A key that could not be decrypted is useless to the server + if (value.startsWith(ENCRYPTED_PREFIX)) { + console.warn( + `Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`, + ) + } // Map generic AI_API_KEY to provider-specific key - if ( + else if ( key === "AI_API_KEY" && provider && PROVIDER_ENV_MAP[provider] @@ -466,6 +436,90 @@ export function getCurrentPresetEnv(): Record { return env } +/** + * Values that env vars had before a preset first set them + * (from the system or .env files), and the keys the active preset set + */ +const originalEnv: Record = {} +let presetEnvKeys: string[] = [] + +/** + * Replace the env vars of the previous preset with those of the given preset + * (null leaves no preset applied). Restoring first means switching presets + * never leaves the previous preset's base URL, model or key behind. + */ +function setPresetEnv(preset: ConfigPreset | null): Record { + for (const key of presetEnvKeys) { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } + } + + const env = preset ? presetToEnv(preset) : {} + for (const [key, value] of Object.entries(env)) { + if (!(key in originalEnv)) { + originalEnv[key] = process.env[key] + } + process.env[key] = value + } + presetEnvKeys = Object.keys(env) + + writeDevPresetEnv(env) + return env +} + +const DEV_ENV_FILE_NAME = "dev-preset-env.json" + +/** + * Development only: write the active preset's env vars (decrypted and mapped) + * for scripts/electron-dev.mjs, which restarts the Next.js dev server when + * this file changes. The dev server can't decrypt the config file itself. + */ +function writeDevPresetEnv(env: Record): void { + if (app.isPackaged) { + return + } + try { + const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME) + writeFileSync(filePath, JSON.stringify(env, null, 2), { + encoding: "utf-8", + mode: 0o600, + }) + } catch (error) { + console.error("Failed to write dev preset env:", error) + } +} + +/** + * Apply preset environment variables to the current process + * Returns the environment variables that were applied + */ +export function applyPresetToEnv(id: string): Record | null { + const data = loadPresets() + const preset = data.presets.find((p) => p.id === id) + + if (!preset) { + return null + } + + const appliedEnv = setPresetEnv(preset) + + // Set as current preset + data.currentPresetId = id + savePresets(data) + + return appliedEnv +} + +/** + * Apply the saved current preset's environment variables (used at startup) + */ +export function applyCurrentPresetToEnv(): void { + setPresetEnv(getCurrentPreset()) +} + /** * Get user's preferred locale from config * Returns undefined if not set diff --git a/electron/main/env-loader.ts b/electron/main/env-loader.ts index 8182b11a..f27ec0a0 100644 --- a/electron/main/env-loader.ts +++ b/electron/main/env-loader.ts @@ -48,12 +48,16 @@ function loadEnvFromFile(filePath: string): void { const key = trimmed.slice(0, equalIndex).trim() let value = trimmed.slice(equalIndex + 1).trim() - // Remove surrounding quotes - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1) + const quote = value[0] + const closingQuote = + quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1 + if (closingQuote > 0) { + // Quoted value: keep what's inside the quotes and drop + // anything after them (e.g. a comment) + value = value.slice(1, closingQuote) + } else { + // Unquoted value: drop an inline comment ("value # comment") + value = value.replace(/\s+#.*$/, "") } // Don't override existing environment variables diff --git a/electron/main/index.ts b/electron/main/index.ts index 6c613da2..e17ddb02 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,12 +1,17 @@ import { app, BrowserWindow, dialog, shell } from "electron" import { buildAppMenu } from "./app-menu" -import { getCurrentPresetEnv } from "./config-manager" +import { applyCurrentPresetToEnv } from "./config-manager" import { loadEnvFile } from "./env-loader" import { registerIpcHandlers } from "./ipc-handlers" import { startNextServer, stopNextServer } from "./next-server" import { applyProxyToEnv } from "./proxy-manager" import { registerSettingsWindowHandlers } from "./settings-window" -import { createWindow, getMainWindow } from "./window-manager" +import { + createWindow, + getAppUrl, + getMainWindow, + isAppUrl, +} from "./window-manager" // Single instance lock const gotTheLock = app.requestSingleInstanceLock() @@ -28,16 +33,14 @@ if (!gotTheLock) { // Apply proxy settings from saved config applyProxyToEnv() - // Apply saved preset environment variables (overrides .env) - const presetEnv = getCurrentPresetEnv() - for (const [key, value] of Object.entries(presetEnv)) { - process.env[key] = value - } - - const isDev = process.env.NODE_ENV === "development" - let serverUrl: string | null = null + const isDev = !app.isPackaged app.whenReady().then(async () => { + // Apply saved preset environment variables (overrides .env). + // Must run after ready: on Windows and Linux safeStorage can't + // decrypt the API key before that. + applyCurrentPresetToEnv() + // Register IPC handlers registerIpcHandlers() registerSettingsWindowHandlers() @@ -46,6 +49,7 @@ if (!gotTheLock) { buildAppMenu() try { + let serverUrl: string if (isDev) { // Development: use the dev server URL serverUrl = @@ -69,8 +73,9 @@ if (!gotTheLock) { app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { - if (serverUrl) { - createWindow(serverUrl) + const appUrl = getAppUrl() + if (appUrl) { + createWindow(appUrl) } } }) @@ -87,24 +92,47 @@ if (!gotTheLock) { stopNextServer() }) + // Pages allowed inside app windows: the app server and draw.io + const isInAppUrl = (url: string): boolean => { + if (isAppUrl(url)) return true + try { + const { hostname } = new URL(url) + return ["diagrams.net", "draw.io"].some( + (domain) => + hostname === domain || hostname.endsWith(`.${domain}`), + ) + } catch { + return false + } + } + + const isWebUrl = (url: string): boolean => + url.startsWith("http://") || url.startsWith("https://") + // Open external links in default browser app.on("web-contents-created", (_, contents) => { contents.setWindowOpenHandler(({ url }) => { - // Allow diagrams.net iframe - if ( - url.includes("diagrams.net") || - url.includes("draw.io") || - url.startsWith("http://localhost") || - url.startsWith("http://127.0.0.1") - ) { + if (isInAppUrl(url)) { return { action: "allow" } } // Open other links in external browser - if (url.startsWith("http://") || url.startsWith("https://")) { + if (isWebUrl(url)) { shell.openExternal(url) return { action: "deny" } } return { action: "allow" } }) + + // Clicking a plain link would otherwise replace the app page with + // an external site that keeps the preload API + contents.on("will-navigate", (event) => { + if (isInAppUrl(event.url)) { + return + } + event.preventDefault() + if (isWebUrl(event.url)) { + shell.openExternal(event.url) + } + }) }) } diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index cc4155f6..3e0f4aca 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -1,7 +1,12 @@ -import { app, BrowserWindow, dialog, ipcMain } from "electron" -import { rebuildAppMenu } from "./app-menu" import { - applyPresetToEnv, + app, + BrowserWindow, + dialog, + type IpcMainInvokeEvent, + ipcMain, +} from "electron" +import { rebuildAppMenu, switchPreset } from "./app-menu" +import { type ConfigPreset, createPreset, deletePreset, @@ -20,6 +25,7 @@ import { type ProxyConfig, saveProxyConfig, } from "./proxy-manager" +import { isAppUrl } from "./window-manager" /** * Allowed configuration keys for presets @@ -48,13 +54,32 @@ function sanitizePresetConfig( return sanitized } +/** + * Register an IPC handler that only answers the app's own pages + * (the main window on the app server, or the local settings page). + * A main window that somehow ends up on an external site still gets the + * preload API, so its calls must be rejected here. + */ +function handle( + channel: string, + listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown, +): void { + ipcMain.handle(channel, (event, ...args) => { + const url = event.senderFrame?.url + if (!isAppUrl(url) && !url?.startsWith("file://")) { + throw new Error(`Blocked "${channel}" from untrusted page: ${url}`) + } + return listener(event, ...(args as Args)) + }) +} + /** * Register all IPC handlers */ export function registerIpcHandlers(): void { // ==================== App Info ==================== - ipcMain.handle("get-version", () => { + handle("get-version", () => { return app.getVersion() }) @@ -81,7 +106,7 @@ export function registerIpcHandlers(): void { // ==================== File Dialogs ==================== - ipcMain.handle("dialog-open-file", async (event) => { + handle("dialog-open-file", async (event) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return null @@ -108,9 +133,9 @@ export function registerIpcHandlers(): void { } }) - ipcMain.handle("dialog-save-file", async (event, data: string) => { + handle("dialog-save-file", async (event, data: string) => { const win = BrowserWindow.fromWebContents(event.sender) - if (!win) return false + if (!win || typeof data !== "string") return false const result = await dialog.showSaveDialog(win, { filters: [ @@ -135,28 +160,28 @@ export function registerIpcHandlers(): void { // ==================== Config Presets ==================== - ipcMain.handle("config-presets:get-all", () => { + handle("config-presets:get-all", () => { return getAllPresets() }) - ipcMain.handle("config-presets:get-current", () => { + handle("config-presets:get-current", () => { return getCurrentPreset() }) - ipcMain.handle("config-presets:get-current-id", () => { + handle("config-presets:get-current-id", () => { return getCurrentPresetId() }) - ipcMain.handle( + handle( "config-presets:save", - ( + async ( _event, preset: Omit & { id?: string }, ) => { // Validate preset name - if (typeof preset.name !== "string" || !preset.name.trim()) { + if (typeof preset?.name !== "string" || !preset.name.trim()) { throw new Error("Invalid preset name") } @@ -165,42 +190,48 @@ export function registerIpcHandlers(): void { if (preset.id) { // Update existing preset - return updatePreset(preset.id, { + const updated = updatePreset(preset.id, { name: preset.name.trim(), config: sanitizedConfig, }) + // Re-apply the active preset so the edit takes effect + if (updated && updated.id === getCurrentPresetId()) { + await switchPreset(updated.id) + } else { + rebuildAppMenu() + } + return updated } // Create new preset - return createPreset({ + const created = createPreset({ name: preset.name.trim(), config: sanitizedConfig, }) + rebuildAppMenu() + return created }, ) - ipcMain.handle("config-presets:delete", (_event, id: string) => { - return deletePreset(id) + handle("config-presets:delete", async (_event, id: string) => { + const wasCurrent = id === getCurrentPresetId() + // Deleting the active preset also clears its env vars + const deleted = deletePreset(id) + rebuildAppMenu() + + // Restart so the server stops using the deleted preset + if (deleted && wasCurrent && app.isPackaged) { + await restartNextServer() + } + return deleted }) - ipcMain.handle("config-presets:apply", async (_event, id: string) => { - const env = applyPresetToEnv(id) - if (!env) { - return { success: false, error: "Preset not found" } - } - - const isDev = process.env.NODE_ENV === "development" - - if (isDev) { - // In development mode, the config file change will trigger - // the file watcher in electron-dev.mjs to restart Next.js - // We just need to save the preset (already done in applyPresetToEnv) - return { success: true, env, devMode: true } - } - - // Production mode: restart the Next.js server to apply new environment variables + handle("config-presets:apply", async (_event, id: string) => { try { - await restartNextServer() - return { success: true, env } + const env = await switchPreset(id) + // In development mode, electron-dev.mjs restarts Next.js + return app.isPackaged + ? { success: true, env } + : { success: true, env, devMode: true } } catch (error) { return { success: false, @@ -212,30 +243,39 @@ export function registerIpcHandlers(): void { } }) - ipcMain.handle( - "config-presets:set-current", - (_event, id: string | null) => { - return setCurrentPreset(id) - }, - ) + handle("config-presets:set-current", (_event, id: string | null) => { + return setCurrentPreset(id) + }) // ==================== Proxy Settings ==================== - ipcMain.handle("get-proxy", () => { + handle("get-proxy", () => { return getProxyConfig() }) - ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => { + handle("set-proxy", async (_event, config: ProxyConfig) => { + const isOptionalString = (value: unknown) => + value === undefined || typeof value === "string" + if ( + typeof config !== "object" || + config === null || + !isOptionalString(config.httpProxy) || + !isOptionalString(config.httpsProxy) + ) { + return { success: false, error: "Invalid proxy settings" } + } + try { // Save config to file - saveProxyConfig(config) + saveProxyConfig({ + httpProxy: config.httpProxy, + httpsProxy: config.httpsProxy, + }) // Apply to current process environment applyProxyToEnv() - const isDev = process.env.NODE_ENV === "development" - - if (isDev) { + if (!app.isPackaged) { // In development, env vars are already applied // Next.js dev server may need manual restart return { success: true, devMode: true } @@ -257,11 +297,11 @@ export function registerIpcHandlers(): void { // ==================== User Locale ==================== - ipcMain.handle("get-user-locale", () => { + handle("get-user-locale", () => { return getUserLocale() }) - ipcMain.handle("set-user-locale", (_event, locale: string) => { + handle("set-user-locale", (_event, locale: string) => { // Validate locale is one of the supported values if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) { return { success: false, error: "Invalid locale" } diff --git a/electron/main/next-server.ts b/electron/main/next-server.ts index 7ca82fc2..a9847947 100644 --- a/electron/main/next-server.ts +++ b/electron/main/next-server.ts @@ -6,10 +6,22 @@ import { getAllocatedPort, getServerUrl, isPortAvailable, + saveServerPort, } from "./port-manager" +import { setAppUrl } from "./window-manager" let serverProcess: UtilityProcess | null = null +// Start and restart run one at a time, so overlapping calls (e.g. two quick +// preset switches) can't leave two servers running +let serverQueue: Promise = Promise.resolve() + +function runExclusive(task: () => Promise): Promise { + const result = serverQueue.then(task) + serverQueue = result.catch(() => {}) + return result +} + /** * Get the path to the standalone server resources * In packaged app: resources/standalone @@ -45,7 +57,11 @@ async function waitForServer(url: string, timeout = 30000): Promise { * Start the Next.js standalone server using Electron's utilityProcess * This API is designed for running Node.js code in the background */ -export async function startNextServer(): Promise { +export function startNextServer(): Promise { + return runExclusive(startServer) +} + +async function startServer(): Promise { const resourcePath = getResourcePath() const serverPath = path.join(resourcePath, "server.js") @@ -73,6 +89,11 @@ export async function startNextServer(): Promise { NODE_USE_ENV_PROXY: "1", } + // Keep requests to local model servers (e.g. Ollama) off the proxy + if (!process.env.NO_PROXY && !process.env.no_proxy) { + env.NO_PROXY = "localhost,127.0.0.1,[::1]" + } + // Set cache directory to a writable location (user's app data folder) // This is necessary because the packaged app might be on a read-only volume if (app.isPackaged) { @@ -96,28 +117,33 @@ export async function startNextServer(): Promise { // Use Electron's utilityProcess API for running Node.js in background // This is the recommended way to run Node.js code in Electron - serverProcess = utilityProcess.fork(serverPath, [], { + const proc = utilityProcess.fork(serverPath, [], { cwd: resourcePath, env, stdio: "pipe", }) + serverProcess = proc - serverProcess.stdout?.on("data", (data) => { + proc.stdout?.on("data", (data) => { console.log(`[Next.js] ${data.toString().trim()}`) }) - serverProcess.stderr?.on("data", (data) => { + proc.stderr?.on("data", (data) => { console.error(`[Next.js Error] ${data.toString().trim()}`) }) - serverProcess.on("exit", (code) => { + proc.on("exit", (code) => { console.log(`Next.js server exited with code ${code}`) - serverProcess = null + // An old server can exit after a new one started; keep the new one + if (serverProcess === proc) { + serverProcess = null + } }) const url = getServerUrl() await waitForServer(url) console.log(`Next.js server started at ${url}`) + saveServerPort(port) return url } @@ -126,39 +152,36 @@ export async function startNextServer(): Promise { * Stop the Next.js server process and wait for it to exit */ export async function stopNextServer(): Promise { - if (serverProcess) { - console.log("Stopping Next.js server...") + const proc = serverProcess + if (!proc) { + return + } + console.log("Stopping Next.js server...") + serverProcess = null - // Create a promise that resolves when the process exits - const exitPromise = new Promise((resolve) => { - const proc = serverProcess - if (!proc) { - resolve() - return - } - - const onExit = () => { - resolve() - } - - proc.once("exit", onExit) - - // Timeout after 5 seconds - setTimeout(() => { - proc.removeListener("exit", onExit) - resolve() - }, 5000) + // Resolves true when the process exits, false after the timeout + const waitForExit = (ms: number) => + new Promise((resolve) => { + proc.once("exit", () => resolve(true)) + setTimeout(() => resolve(false), ms) }) - serverProcess.kill() - serverProcess = null + proc.kill() - // Wait for process to exit - await exitPromise - - // Additional wait for OS to release port - await new Promise((resolve) => setTimeout(resolve, 500)) + // Next.js waits for open requests (e.g. a streaming reply) before it + // exits, so force kill it if it is still running after 5 seconds + if (!(await waitForExit(5000)) && proc.pid) { + console.warn("Next.js server did not exit in time, force killing it") + try { + process.kill(proc.pid, "SIGKILL") + } catch (error) { + console.error("Failed to force kill Next.js server:", error) + } + await waitForExit(2000) } + + // Additional wait for OS to release port + await new Promise((resolve) => setTimeout(resolve, 500)) } /** @@ -184,15 +207,19 @@ async function waitForServerStop(timeout = 5000): Promise { /** * Restart the Next.js server with new environment variables */ -export async function restartNextServer(): Promise { - console.log("Restarting Next.js server...") +export function restartNextServer(): Promise { + return runExclusive(async () => { + console.log("Restarting Next.js server...") - // Stop the current server and wait for it to exit - await stopNextServer() + // Stop the current server and wait for it to exit + await stopNextServer() - // Wait for the port to be released - await waitForServerStop() + // Wait for the port to be released + await waitForServerStop() - // Start the server again - return startNextServer() + // Start the server again, and follow it if it moved to another port + const url = await startServer() + setAppUrl(url) + return url + }) } diff --git a/electron/main/port-manager.ts b/electron/main/port-manager.ts index 299ff427..f0c5c2ac 100644 --- a/electron/main/port-manager.ts +++ b/electron/main/port-manager.ts @@ -1,4 +1,6 @@ +import { readFileSync, writeFileSync } from "node:fs" import net from "node:net" +import path from "node:path" import { app } from "electron" /** @@ -23,6 +25,38 @@ const PORT_CONFIG = { */ let allocatedPort: number | null = null +/** + * File that remembers the production port from the last launch, so the app + * keeps the same origin (and its localStorage) instead of switching between + * the legacy and new port depending on which one is free at startup + */ +function getSavedPortPath(): string { + return path.join(app.getPath("userData"), "server-port.json") +} + +function loadSavedPort(): number | null { + try { + const { port } = JSON.parse(readFileSync(getSavedPortPath(), "utf-8")) + return Number.isInteger(port) ? port : null + } catch { + return null + } +} + +/** + * Remember the port the production server started on + */ +export function saveServerPort(port: number): void { + if (!app.isPackaged || port === loadSavedPort()) { + return + } + try { + writeFileSync(getSavedPortPath(), JSON.stringify({ port }), "utf-8") + } catch (error) { + console.error("Failed to save server port:", error) + } +} + /** * Check if a specific port is available */ @@ -44,7 +78,8 @@ export function isPortAvailable(port: number): Promise { /** * Find an available port * - In development: uses fixed port (6002) - * - In production: uses fixed port (13370) to preserve localStorage + * - In production: uses the port from the last launch, then the legacy + * port (61337), then 13370, to preserve localStorage * - Falls back to sequential ports if preferred port is unavailable * - Last resort: lets the OS assign a port (port 0) * @@ -69,6 +104,20 @@ export async function findAvailablePort(reuseExisting = true): Promise { allocatedPort = null } + // In production, use the port from the last launch first + if (!isDev) { + const savedPort = loadSavedPort() + if (savedPort !== null) { + if (await isPortAvailable(savedPort)) { + allocatedPort = savedPort + return savedPort + } + console.warn( + `Port ${savedPort} from the last launch is unavailable. Data saved under it will not show on the new port.`, + ) + } + } + // In production, try legacy port first to preserve existing users' localStorage if (!isDev) { const legacyPort = PORT_CONFIG.legacyProduction diff --git a/electron/main/proxy-manager.ts b/electron/main/proxy-manager.ts index cb41de56..3f4c2f8a 100644 --- a/electron/main/proxy-manager.ts +++ b/electron/main/proxy-manager.ts @@ -13,18 +13,22 @@ function getConfigPath(): string { /** * Load proxy configuration from JSON file + * Returns null if the user never saved proxy settings (or the file is invalid) */ -export function loadProxyConfig(): ProxyConfig { +export function loadProxyConfig(): ProxyConfig | null { try { const configPath = getConfigPath() if (fs.existsSync(configPath)) { - const data = fs.readFileSync(configPath, "utf-8") - return JSON.parse(data) as ProxyConfig + const data = JSON.parse(fs.readFileSync(configPath, "utf-8")) + if (data && typeof data === "object" && !Array.isArray(data)) { + return data as ProxyConfig + } + console.error("Ignoring invalid proxy config:", data) } } catch (error) { console.error("Failed to load proxy config:", error) } - return {} + return null } /** @@ -33,7 +37,11 @@ export function loadProxyConfig(): ProxyConfig { export function saveProxyConfig(config: ProxyConfig): void { try { const configPath = getConfigPath() - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8") + // Write a temp file and rename it, so a crash mid-write can't leave + // a truncated file + const tempPath = `${configPath}.tmp` + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8") + fs.renameSync(tempPath, configPath) } catch (error) { console.error("Failed to save proxy config:", error) throw error @@ -47,6 +55,11 @@ export function saveProxyConfig(config: ProxyConfig): void { export function applyProxyToEnv(): void { const config = loadProxyConfig() + // No saved settings: keep proxy vars inherited from the system or .env + if (!config) { + return + } + if (config.httpProxy) { process.env.HTTP_PROXY = config.httpProxy process.env.http_proxy = config.httpProxy diff --git a/electron/main/window-manager.ts b/electron/main/window-manager.ts index 7b515715..4660fb5d 100644 --- a/electron/main/window-manager.ts +++ b/electron/main/window-manager.ts @@ -3,6 +3,9 @@ import { app, BrowserWindow, screen } from "electron" let mainWindow: BrowserWindow | null = null +// URL of the app server the main window loads +let appUrl: string | null = null + /** * Get the icon path based on platform * Note: electron-builder converts icon.png during packaging, @@ -28,6 +31,7 @@ function getIconPath(): string | undefined { * Create the main application window */ export function createWindow(serverUrl: string): BrowserWindow { + appUrl = serverUrl const { width, height } = screen.getPrimaryDisplay().workAreaSize mainWindow = new BrowserWindow({ @@ -56,7 +60,7 @@ export function createWindow(serverUrl: string): BrowserWindow { }) // Open DevTools in development - if (process.env.NODE_ENV === "development") { + if (!app.isPackaged) { mainWindow.webContents.openDevTools() } @@ -93,3 +97,36 @@ export function createWindow(serverUrl: string): BrowserWindow { export function getMainWindow(): BrowserWindow | null { return mainWindow } + +/** + * Get the app server URL the main window loads + */ +export function getAppUrl(): string | null { + return appUrl +} + +/** + * Point the main window at a new app server URL + * (the restarted server can come up on a different port) + */ +export function setAppUrl(url: string): void { + if (url === appUrl) { + return + } + appUrl = url + mainWindow?.loadURL(url) +} + +/** + * Check if a URL belongs to the app server (same origin) + */ +export function isAppUrl(url: string | undefined): boolean { + if (!url || !appUrl) { + return false + } + try { + return new URL(url).origin === new URL(appUrl).origin + } catch { + return false + } +} diff --git a/electron/settings/settings.js b/electron/settings/settings.js index 235b2fe5..b71928de 100644 --- a/electron/settings/settings.js +++ b/electron/settings/settings.js @@ -213,6 +213,9 @@ async function savePreset() { } }) + // closeModal() clears editingPresetId, so remember it for the toast + const isEdit = Boolean(editingPresetId) + try { saveBtn.disabled = true saveBtn.innerHTML = '' @@ -220,10 +223,7 @@ async function savePreset() { await window.settingsAPI.savePreset(preset) await loadPresets() closeModal() - showToast( - editingPresetId ? "Preset updated" : "Preset created", - "success", - ) + showToast(isEdit ? "Preset updated" : "Preset created", "success") } catch (error) { console.error("Failed to save preset:", error) showToast("Failed to save preset", "error") @@ -265,8 +265,6 @@ async function applyPreset(id) { const result = await window.settingsAPI.applyPreset(id) if (result.success) { - currentPresetId = id - renderPresets() showToast("Preset applied, server restarting...", "success") } else { showToast(result.error || "Failed to apply preset", "error") @@ -274,6 +272,9 @@ async function applyPreset(id) { } catch (error) { console.error("Failed to apply preset:", error) showToast("Failed to apply preset", "error") + } finally { + // Reload to show the active preset and reset the Apply button + await loadPresets() } } diff --git a/scripts/electron-dev.mjs b/scripts/electron-dev.mjs index bf5386a0..98b2463a 100644 --- a/scripts/electron-dev.mjs +++ b/scripts/electron-dev.mjs @@ -2,7 +2,7 @@ /** * Development script for running Electron with Next.js - * 1. Reads preset configuration (if exists) + * 1. Reads the active preset's env vars (if any) * 2. Starts Next.js dev server with preset env vars * 3. Waits for it to be ready * 4. Compiles Electron TypeScript @@ -47,39 +47,41 @@ function getUserDataPath() { } /** - * Load preset configuration from config file + * File where the Electron main process (in development) writes the active + * preset's env vars, already decrypted and mapped to provider-specific keys + * (see writeDevPresetEnv in electron/main/config-manager.ts) */ -function loadPresetConfig() { - const configPath = path.join(getUserDataPath(), "config-presets.json") - - if (!existsSync(configPath)) { - console.log("šŸ“‹ No preset configuration found, using .env.local") - return null - } +const PRESET_ENV_FILE = "dev-preset-env.json" +/** + * Read the active preset's env vars as JSON text (null if not available) + */ +function readPresetEnvFile() { try { - const content = readFileSync(configPath, "utf-8") - const data = JSON.parse(content) - - if (!data.currentPresetId) { - console.log("šŸ“‹ No active preset, using .env.local") - return null - } - - const preset = data.presets.find((p) => p.id === data.currentPresetId) - if (!preset) { - console.log("šŸ“‹ Active preset not found, using .env.local") - return null - } - - console.log(`šŸ“‹ Using preset: "${preset.name}"`) - return preset.config - } catch (error) { - console.error("Failed to load preset config:", error.message) + const content = readFileSync( + path.join(getUserDataPath(), PRESET_ENV_FILE), + "utf-8", + ) + JSON.parse(content) // Ignore a half-written file + return content + } catch { return null } } +/** + * Load the active preset's env vars + */ +function loadPresetEnv(content) { + const env = content ? JSON.parse(content) : {} + if (Object.keys(env).length === 0) { + console.log("šŸ“‹ No active preset, using .env.local") + return null + } + console.log(`šŸ“‹ Using preset env: ${Object.keys(env).join(", ")}`) + return env +} + /** * Wait for the Next.js server to be ready */ @@ -128,6 +130,18 @@ function runCommand(command, args, options = {}) { }) } +/** + * Kill a process started with shell: true. On Windows, kill() only ends the + * cmd.exe wrapper and leaves next dev running, so kill the whole tree. + */ +function killProcess(proc) { + if (process.platform === "win32" && proc.pid) { + spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) + } else { + proc.kill() + } +} + /** * Start Next.js dev server with preset environment */ @@ -164,7 +178,8 @@ async function main() { console.log("šŸš€ Starting Electron development environment...\n") // Load preset configuration - const presetEnv = loadPresetConfig() + let presetEnvContent = readPresetEnvFile() + const presetEnv = loadPresetEnv(presetEnvContent) // Start Next.js dev server with preset env console.log("1. Starting Next.js development server...") @@ -176,7 +191,7 @@ async function main() { console.log("") } catch (err) { console.error("\nāŒ Next.js server failed to start:", err.message) - nextProcess.kill() + killProcess(nextProcess) process.exit(1) } @@ -186,7 +201,7 @@ async function main() { await runCommand("npm", ["run", "electron:compile"]) } catch (err) { console.error("āŒ Electron compilation failed:", err.message) - nextProcess.kill() + killProcess(nextProcess) process.exit(1) } @@ -203,76 +218,82 @@ async function main() { }, }) - // Watch for preset config changes - const configPath = path.join(getUserDataPath(), "config-presets.json") + // Watch for preset env changes + const userDataPath = getUserDataPath() let configWatcher = null let restartPending = false function setupConfigWatcher() { - if (!existsSync(path.dirname(configPath))) { + if (!existsSync(userDataPath)) { // Directory doesn't exist yet, check again later setTimeout(setupConfigWatcher, 5000) return } try { + // Watch the directory, since the file may not exist yet configWatcher = watch( - configPath, + userDataPath, { persistent: false }, - async (eventType) => { - if (eventType === "change" && !restartPending) { - restartPending = true + async (_eventType, filename) => { + if (filename !== PRESET_ENV_FILE || restartPending) return + + // Only restart when the preset env vars really changed + const newContent = readPresetEnvFile() + if (newContent === null || newContent === presetEnvContent) + return + + restartPending = true + presetEnvContent = newContent + console.log( + "\nšŸ”„ Preset configuration changed, restarting Next.js server...", + ) + + // Kill current Next.js process + killProcess(nextProcess) + + // Wait a bit for process to die + await new Promise((r) => setTimeout(r, 1000)) + + // Reload preset and restart + nextProcess = startNextServer(loadPresetEnv(newContent)) + + try { + await waitForServer(NEXT_URL) console.log( - "\nšŸ”„ Preset configuration changed, restarting Next.js server...", + "āœ… Next.js server restarted with new configuration\n", + ) + } catch (err) { + console.error( + "āŒ Failed to restart Next.js:", + err.message, ) - - // Kill current Next.js process - nextProcess.kill() - - // Wait a bit for process to die - await new Promise((r) => setTimeout(r, 1000)) - - // Reload preset and restart - const newPresetEnv = loadPresetConfig() - nextProcess = startNextServer(newPresetEnv) - - try { - await waitForServer(NEXT_URL) - console.log( - "āœ… Next.js server restarted with new configuration\n", - ) - } catch (err) { - console.error( - "āŒ Failed to restart Next.js:", - err.message, - ) - } - - restartPending = false } + + restartPending = false }, ) console.log("šŸ‘€ Watching for preset configuration changes...") } catch (_err) { - // File might not exist yet, that's ok + // Directory might not be ready yet, try again later setTimeout(setupConfigWatcher, 5000) } } - // Start watching after a delay (config file might not exist yet) + // Start watching after a delay (user data directory might not exist yet) setTimeout(setupConfigWatcher, 2000) electronProcess.on("close", (code) => { console.log(`\nElectron exited with code ${code}`) if (configWatcher) configWatcher.close() - nextProcess.kill() + killProcess(nextProcess) process.exit(code || 0) }) electronProcess.on("error", (err) => { console.error("Electron error:", err) if (configWatcher) configWatcher.close() - nextProcess.kill() + killProcess(nextProcess) process.exit(1) }) @@ -280,8 +301,8 @@ async function main() { const cleanup = () => { console.log("\nšŸ›‘ Shutting down...") if (configWatcher) configWatcher.close() - electronProcess.kill() - nextProcess.kill() + killProcess(electronProcess) + killProcess(nextProcess) process.exit(0) } diff --git a/scripts/prepare-electron-build.mjs b/scripts/prepare-electron-build.mjs index 47bcd214..eeb09b8c 100644 --- a/scripts/prepare-electron-build.mjs +++ b/scripts/prepare-electron-build.mjs @@ -73,6 +73,15 @@ mkdirSync(targetDir, { recursive: true }) console.log("Copying standalone directory...") copyDereferenced(standaloneDir, targetDir) +// Next.js copies the build machine's .env files into standalone; don't ship +// them, they can hold the builder's API keys +for (const entry of readdirSync(targetDir)) { + if (entry.startsWith(".env")) { + console.warn(`Removing ${entry} so it is not packaged into the app`) + rmSync(join(targetDir, entry)) + } +} + // Copy static files console.log("Copying static files...") const targetStaticDir = join(targetDir, ".next", "static")