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
This commit is contained in:
dayuan.jiang
2026-10-03 17:45:41 +09:00
parent 3193d20e00
commit 95f4b4b92b
12 changed files with 607 additions and 293 deletions
+57 -26
View File
@@ -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<Record<string, string>> {
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)}`,
)
}
},
}))
+123 -69
View File
@@ -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<string, { apiKey: string; baseUrl: string }> = {
}
/**
* Apply preset environment variables to the current process
* Returns the environment variables that were applied
*/
export function applyPresetToEnv(id: string): Record<string, string> | null {
const data = loadPresets()
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return null
}
const appliedEnv: Record<string, string> = {}
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<string, string> {
const preset = getCurrentPreset()
if (!preset) {
return {}
}
function presetToEnv(preset: ConfigPreset): Record<string, string> {
const env: Record<string, string> = {}
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<string, string> {
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<string, string | undefined> = {}
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<string, string> {
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<string, string>): 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<string, string> | 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
+10 -6
View File
@@ -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
+48 -20
View File
@@ -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)
}
})
})
}
+89 -49
View File
@@ -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<Args extends unknown[]>(
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<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
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" }
+69 -42
View File
@@ -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<unknown> = Promise.resolve()
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
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<void> {
* 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<string> {
export function startNextServer(): Promise<string> {
return runExclusive(startServer)
}
async function startServer(): Promise<string> {
const resourcePath = getResourcePath()
const serverPath = path.join(resourcePath, "server.js")
@@ -73,6 +89,11 @@ export async function startNextServer(): Promise<string> {
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<string> {
// 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<string> {
* Stop the Next.js server process and wait for it to exit
*/
export async function stopNextServer(): Promise<void> {
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<void>((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<boolean>((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<void> {
/**
* Restart the Next.js server with new environment variables
*/
export async function restartNextServer(): Promise<string> {
console.log("Restarting Next.js server...")
export function restartNextServer(): Promise<string> {
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
})
}
+50 -1
View File
@@ -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<boolean> {
/**
* 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<number> {
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
+18 -5
View File
@@ -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
+38 -1
View File
@@ -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
}
}
+7 -6
View File
@@ -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 = '<span class="loading"></span>'
@@ -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()
}
}
+89 -68
View File
@@ -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)
}
+9
View File
@@ -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")