diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 98ef693e..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 0566d3f4..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: CI - -on: - push: - branches: - - main - pull_request: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - ci: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: 'npm' - - - name: Install dependencies - run: npm install - - - name: Type check - run: npx tsc --noEmit - - - name: Lint check - run: npm run check - - - name: Build - run: npm run build - diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 66ac4e7d..46f9ea6b 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -4,14 +4,11 @@ on: push: branches: - main - - master - - dev tags: - 'v*' pull_request: branches: - main - - master env: REGISTRY: ghcr.io diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc2d7798..6ec47895 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,13 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: lint-and-unit: name: Lint & Unit Tests @@ -16,12 +23,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "24" cache: "npm" - name: Install dependencies run: npm ci + - name: Type check + run: npx tsc --noEmit + - name: Run lint run: npm run check @@ -57,7 +67,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "24" cache: "npm" - name: Install dependencies diff --git a/electron/electron.d.ts b/electron/electron.d.ts index 4c54bdcc..3a129089 100644 --- a/electron/electron.d.ts +++ b/electron/electron.d.ts @@ -2,29 +2,6 @@ * Type declarations for Electron API exposed via preload script */ -/** Configuration preset interface */ -interface ConfigPreset { - id: string - name: string - createdAt: number - updatedAt: number - config: { - AI_PROVIDER?: string - AI_MODEL?: string - AI_API_KEY?: string - AI_BASE_URL?: string - TEMPERATURE?: string - [key: string]: string | undefined - } -} - -/** Result of applying a preset */ -interface ApplyPresetResult { - success: boolean - error?: string - env?: Record -} - /** Proxy configuration interface */ interface ProxyConfig { httpProxy?: string @@ -35,7 +12,6 @@ interface ProxyConfig { interface SetProxyResult { success: boolean error?: string - devMode?: boolean } /** Result of setting user locale */ @@ -48,31 +24,17 @@ declare global { interface Window { /** Main window Electron API */ electronAPI?: { - /** Current platform (darwin, win32, linux) */ - platform: NodeJS.Platform /** Whether running in Electron environment */ isElectron: boolean - /** Get application version */ - getVersion: () => Promise - /** Minimize the window */ - minimize: () => void - /** Maximize/restore the window */ - maximize: () => void - /** Close the window */ - close: () => void /** Get proxy configuration */ getProxy: () => Promise /** Set proxy configuration (saves and restarts server) */ setProxy: (config: ProxyConfig) => Promise - /** Get user's preferred locale */ - getUserLocale: () => Promise< - "en" | "zh" | "ja" | "zh-Hant" | undefined - > /** Set user's preferred locale */ setUserLocale: (locale: string) => Promise /** - * Call back after the server restarted on the same port (another - * preset); returns a function that stops the calls + * Call back after the server restarted on the same port (proxy + * change); returns a function that stops the calls */ onServerRestarted?: (callback: () => void) => () => void /** A chat was saved: open this port next launch */ @@ -80,35 +42,7 @@ declare global { /** The page loaded with this many chats */ chatsLoaded?: (count: number) => Promise } - - /** Settings window Electron API */ - settingsAPI?: { - /** Get all configuration presets */ - getPresets: () => Promise - /** Get current preset ID */ - getCurrentPresetId: () => Promise - /** Get current preset */ - getCurrentPreset: () => Promise - /** Save (create or update) a preset */ - savePreset: (preset: { - id?: string - name: string - config: Record - }) => Promise - /** Delete a preset */ - deletePreset: (id: string) => Promise - /** Apply a preset (sets environment variables and restarts server) */ - applyPreset: (id: string) => Promise - /** Close settings window */ - close: () => void - } } } -export type { - ApplyPresetResult, - ConfigPreset, - ProxyConfig, - SetProxyResult, - SetUserLocaleResult, -} +export type { ProxyConfig } diff --git a/electron/main/app-menu.ts b/electron/main/app-menu.ts index bb38ab9d..57433941 100644 --- a/electron/main/app-menu.ts +++ b/electron/main/app-menu.ts @@ -1,23 +1,9 @@ -import { - app, - BrowserWindow, - dialog, - Menu, - type MenuItemConstructorOptions, - shell, -} from "electron" -import { - applyPresetToEnv, - getAllPresets, - getCurrentPresetId, - setCurrentPreset, -} from "./config-manager" +import { app, Menu, type MenuItemConstructorOptions, shell } from "electron" import { getMenuTranslations, getPreferredLocale } from "./menu-i18n" -import { restartNextServer } from "./next-server" -import { showSettingsWindow } from "./settings-window" /** * Build and set the application menu with i18n support + * (call again when the language changes) */ export function buildAppMenu(): void { const template = getMenuTemplate() @@ -25,75 +11,6 @@ export function buildAppMenu(): void { Menu.setApplicationMenu(menu) } -/** - * Rebuild the menu (call this when presets change or language changes) - */ -export function rebuildAppMenu(): void { - buildAppMenu() -} - -// Number of the latest preset switch -let lastSwitch = 0 - -/** - * 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") - } - const switchNumber = ++lastSwitch - 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) - - // A newer switch started meanwhile (also of this same preset), or - // the preset was deleted: its own restart follows, and undoing - // would lose that choice - if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) { - throw new Error( - `The server could not be restarted.\n\nError: ${reason}`, - ) - } - - // 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 */ @@ -113,15 +30,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] { submenu: [ { role: "about" }, // System-translated { type: "separator" }, - { - label: t.settings, - accelerator: "CmdOrCtrl+,", - click: () => { - const win = BrowserWindow.getFocusedWindow() - showSettingsWindow(win || undefined) - }, - }, - { type: "separator" }, { role: "services" }, // System-translated { type: "separator" }, { role: "hide" }, // System-translated @@ -137,19 +45,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] { template.push({ label: t.file, submenu: [ - ...(isMac - ? [] - : [ - { - label: t.settings, - accelerator: "CmdOrCtrl+,", - click: () => { - const win = BrowserWindow.getFocusedWindow() - showSettingsWindow(win || undefined) - }, - }, - { type: "separator" } as MenuItemConstructorOptions, - ]), isMac ? { role: "close" } : { role: "quit" }, // System-translated ], }) @@ -196,9 +91,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] { ], }) - // Configuration menu with presets - template.push(buildConfigMenu(t)) - // Window menu template.push({ label: t.window, @@ -239,54 +131,3 @@ function getMenuTemplate(): MenuItemConstructorOptions[] { return template } - -/** - * Build the Configuration menu with presets - */ -function buildConfigMenu( - t: ReturnType, -): MenuItemConstructorOptions { - const presets = getAllPresets() - const currentPresetId = getCurrentPresetId() - - const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({ - label: preset.name, - type: "radio", - checked: preset.id === currentPresetId, - click: async () => { - 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)}`, - ) - } - }, - })) - - return { - label: t.configuration, - submenu: [ - ...(presetItems.length > 0 - ? [ - { label: t.switchPreset, enabled: false }, - { type: "separator" } as MenuItemConstructorOptions, - ...presetItems, - { type: "separator" } as MenuItemConstructorOptions, - ] - : []), - { - label: - presetItems.length > 0 - ? t.managePresets - : t.addConfigurationPreset, - click: () => { - const win = BrowserWindow.getFocusedWindow() - showSettingsWindow(win || undefined) - }, - }, - ], - } -} diff --git a/electron/main/config-manager.ts b/electron/main/config-manager.ts index 1c3499f7..101fe770 100644 --- a/electron/main/config-manager.ts +++ b/electron/main/config-manager.ts @@ -1,570 +1,57 @@ -import { randomUUID } from "node:crypto" -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - writeFileSync, -} from "node:fs" -import path from "node:path" -import { app, safeStorage } from "electron" +import { app } from "electron" +import * as fs from "fs" +import * as path from "path" -/** - * Fields that contain sensitive data and should be encrypted - */ -const SENSITIVE_FIELDS = ["AI_API_KEY"] as const +export const USER_LOCALES = ["en", "zh", "ja", "zh-Hant"] as const +export type UserLocale = (typeof USER_LOCALES)[number] -/** - * Prefix to identify encrypted values - */ -const ENCRYPTED_PREFIX = "encrypted:" +// Earlier versions kept their configuration presets in this file too; the +// name stays so the saved language survives the upgrade +const CONFIG_FILE = "config-presets.json" -/** - * Check if safeStorage encryption is available - */ -function isEncryptionAvailable(): boolean { - return safeStorage.isEncryptionAvailable() +function getConfigPath(): string { + return path.join(app.getPath("userData"), CONFIG_FILE) } /** - * Track if we've already warned about plaintext storage + * Load the config file as an object ({} if it is missing or invalid) */ -let hasWarnedAboutPlaintext = false - -/** - * Encrypt a sensitive value using safeStorage - * Warns if encryption is not available (API key stored in plaintext) - */ -function encryptValue(value: string): string { - // 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 - } - - if (!isEncryptionAvailable()) { - if (!hasWarnedAboutPlaintext) { - console.warn( - "⚠️ SECURITY WARNING: safeStorage not available. " + - "API keys will be stored in PLAINTEXT. " + - "On Linux, install gnome-keyring or similar for secure storage.", - ) - hasWarnedAboutPlaintext = true - } - return value - } - +function loadConfig(): Record { try { - const encrypted = safeStorage.encryptString(value) - return ENCRYPTED_PREFIX + encrypted.toString("base64") - } catch (error) { - console.error("Encryption failed:", error) - // Fail secure: don't store if encryption fails - throw new Error( - "Failed to encrypt API key. Cannot securely store credentials.", - ) - } -} - -/** - * 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)) { - return value - } - if (!isEncryptionAvailable()) { - console.warn( - "Cannot decrypt value: safeStorage encryption is not available", - ) - return value - } - try { - const base64Data = value.slice(ENCRYPTED_PREFIX.length) - const buffer = Buffer.from(base64Data, "base64") - return safeStorage.decryptString(buffer) - } catch (error) { - console.error("Failed to decrypt value:", error) - return value - } -} - -/** - * Encrypt sensitive fields in a config object - */ -function encryptConfig( - config: Record, -): Record { - const encrypted = { ...config } - for (const field of SENSITIVE_FIELDS) { - if (encrypted[field]) { - encrypted[field] = encryptValue(encrypted[field] as string) - } - } - return encrypted -} - -/** - * Decrypt sensitive fields in a config object - */ -function decryptConfig( - config: Record, -): Record { - const decrypted = { ...config } - for (const field of SENSITIVE_FIELDS) { - if (decrypted[field]) { - decrypted[field] = decryptValue(decrypted[field] as string) - } - } - return decrypted -} - -/** - * Configuration preset interface - */ -export interface ConfigPreset { - id: string - name: string - createdAt: number - updatedAt: number - config: { - AI_PROVIDER?: string - AI_MODEL?: string - AI_API_KEY?: string - AI_BASE_URL?: string - TEMPERATURE?: string - [key: string]: string | undefined - } -} - -/** - * Configuration file structure - */ -interface ConfigPresetsFile { - version: 1 - currentPresetId: string | null - presets: ConfigPreset[] - userLocale?: "en" | "zh" | "ja" | "zh-Hant" -} - -const CONFIG_FILE_NAME = "config-presets.json" - -/** - * Get the path to the config file - */ -function getConfigFilePath(): string { - const userDataPath = app.getPath("userData") - return path.join(userDataPath, CONFIG_FILE_NAME) -} - -// The presets file exists but the last read failed: a save now would -// replace the user's presets with the empty list that read returned -let presetsUnreadable = false - -/** - * Load presets from the config file - * Decrypts sensitive fields automatically - */ -export function loadPresets(): ConfigPresetsFile { - const configPath = getConfigFilePath() - - if (!existsSync(configPath)) { - // Nothing left that a save could overwrite - presetsUnreadable = false - return { - version: 1, - currentPresetId: null, - presets: [], - userLocale: undefined, - } - } - - let content: string - try { - content = readFileSync(configPath, "utf-8") - presetsUnreadable = false - } catch (error) { - // Often only for now (on Windows an antivirus scanner can hold the - // file): keep the file, and refuse saves based on this empty list - console.error("Failed to read config presets:", error) - presetsUnreadable = true - return { - version: 1, - currentPresetId: null, - presets: [], - userLocale: undefined, - } - } - - try { - const data = JSON.parse(content) as ConfigPresetsFile - - // Decrypt sensitive fields in each preset - data.presets = data.presets.map((preset) => ({ - ...preset, - config: decryptConfig(preset.config) as ConfigPreset["config"], - })) - - 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) { - // Still there: refuse saves that would overwrite it - console.error("Failed to back up config presets:", renameError) - presetsUnreadable = true - } - return { - version: 1, - currentPresetId: null, - presets: [], - userLocale: undefined, - } - } -} - -/** - * Save presets to the config file - * Encrypts sensitive fields automatically - */ -export function savePresets(data: ConfigPresetsFile): void { - if (presetsUnreadable) { - throw new Error( - "The presets file could not be read, so it was not overwritten. Please try again.", - ) - } - const configPath = getConfigFilePath() - const userDataPath = app.getPath("userData") - - // Ensure the directory exists - if (!existsSync(userDataPath)) { - mkdirSync(userDataPath, { recursive: true }) - } - - // Encrypt sensitive fields before saving - const dataToSave: ConfigPresetsFile = { - ...data, - presets: data.presets.map((preset) => ({ - ...preset, - config: encryptConfig(preset.config) as ConfigPreset["config"], - })), - } - - try { - // 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 - } -} - -/** - * Get all presets - */ -export function getAllPresets(): ConfigPreset[] { - const data = loadPresets() - return data.presets -} - -/** - * Get current preset ID - */ -export function getCurrentPresetId(): string | null { - const data = loadPresets() - return data.currentPresetId -} - -/** - * Get current preset - */ -export function getCurrentPreset(): ConfigPreset | null { - const data = loadPresets() - if (!data.currentPresetId) { - return null - } - return data.presets.find((p) => p.id === data.currentPresetId) || null -} - -/** - * Create a new preset - */ -export function createPreset( - preset: Omit, -): ConfigPreset { - const data = loadPresets() - const now = Date.now() - - const newPreset: ConfigPreset = { - id: randomUUID(), - name: preset.name, - config: preset.config, - createdAt: now, - updatedAt: now, - } - - data.presets.push(newPreset) - savePresets(data) - - return newPreset -} - -/** - * Update an existing preset - */ -export function updatePreset( - id: string, - updates: Partial>, -): ConfigPreset | null { - const data = loadPresets() - const index = data.presets.findIndex((p) => p.id === id) - - if (index === -1) { - return null - } - - const updatedPreset: ConfigPreset = { - ...data.presets[index], - ...updates, - updatedAt: Date.now(), - } - - data.presets[index] = updatedPreset - savePresets(data) - - return updatedPreset -} - -/** - * Delete a preset - */ -export function deletePreset(id: string): boolean { - const data = loadPresets() - const index = data.presets.findIndex((p) => p.id === id) - - if (index === -1) { - return false - } - - data.presets.splice(index, 1) - - // Clear current preset (and its env vars) if it was deleted - if (data.currentPresetId === id) { - data.currentPresetId = null - setPresetEnv(null) - } - - savePresets(data) - return true -} - -/** - * Set the current preset - */ -export function setCurrentPreset(id: string | null): boolean { - const data = loadPresets() - - let preset: ConfigPreset | null = null - if (id !== null) { - preset = data.presets.find((p) => p.id === id) || null - if (!preset) { - return false - } - } - - setPresetEnv(preset) - data.currentPresetId = id - savePresets(data) - return true -} - -/** - * Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables - */ -const PROVIDER_ENV_MAP: Record = { - openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" }, - anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }, - google: { - apiKey: "GOOGLE_GENERATIVE_AI_API_KEY", - baseUrl: "GOOGLE_BASE_URL", - }, - azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" }, - openrouter: { - apiKey: "OPENROUTER_API_KEY", - baseUrl: "OPENROUTER_BASE_URL", - }, - deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" }, - siliconflow: { - apiKey: "SILICONFLOW_API_KEY", - baseUrl: "SILICONFLOW_BASE_URL", - }, - modelscope: { - apiKey: "MODELSCOPE_API_KEY", - baseUrl: "MODELSCOPE_BASE_URL", - }, - gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" }, - // bedrock doesn't use API keys in the same way - bedrock: { apiKey: "", baseUrl: "" }, - ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" }, -} - -/** - * Map a preset's config to environment variables - * Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys - */ -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 - else if ( - key === "AI_API_KEY" && - provider && - PROVIDER_ENV_MAP[provider] - ) { - const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey - if (providerApiKey) { - env[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) { - env[providerBaseUrl] = value - } - } - // Apply other env vars directly - else { - env[key] = value + const configPath = getConfigPath() + if (fs.existsSync(configPath)) { + const data = JSON.parse(fs.readFileSync(configPath, "utf-8")) + if (data && typeof data === "object" && !Array.isArray(data)) { + return data } + console.error("Ignoring invalid config file:", data) } - } - 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) + console.error("Failed to load config file:", error) } + return {} } /** - * Apply preset environment variables to the current process - * Returns the environment variables that were applied + * Get the menu locale the user chose, if any */ -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 +export function getUserLocale(): UserLocale | undefined { + const { userLocale } = loadConfig() + return (USER_LOCALES as readonly unknown[]).includes(userLocale) + ? (userLocale as UserLocale) + : undefined } /** - * Apply the saved current preset's environment variables (used at startup) + * Save the menu locale the user chose */ -export function applyCurrentPresetToEnv(): void { - setPresetEnv(getCurrentPreset()) -} - -/** - * Get user's preferred locale from config - * Returns undefined if not set - */ -export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined { - const data = loadPresets() - return data.userLocale -} - -/** - * Set user's preferred locale in config - */ -export function setUserLocale( - locale: "en" | "zh" | "ja" | "zh-Hant" | null, -): void { - const data = loadPresets() - data.userLocale = locale === null ? undefined : locale - savePresets(data) +export function setUserLocale(locale: UserLocale): void { + const config = { ...loadConfig(), userLocale: locale } + const configPath = getConfigPath() + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + // 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) } diff --git a/electron/main/env-loader.ts b/electron/main/env-loader.ts index 563b3729..d119c603 100644 --- a/electron/main/env-loader.ts +++ b/electron/main/env-loader.ts @@ -1,5 +1,6 @@ import fs from "node:fs" import path from "node:path" +import { parse } from "dotenv" import { app } from "electron" /** @@ -28,69 +29,13 @@ export function loadEnvFile(): void { console.log("No .env file found, using system environment variables") } -/** - * Index of the quote that closes a value starting with a quote, or -1. A - * backslash before the quote character escapes it, as in dotenv; the - * backslash stays in the value. As in dotenv, an escaped quote with only a - * comment or nothing after it still closes the value when no other quote - * does ("C:\dir\" keeps its trailing backslash). - */ -function findClosingQuote(value: string): number { - const quote = value[0] - let lastEscaped = -1 - for (let i = 1; i < value.length; i++) { - if (value[i] === "\\" && value[i + 1] === quote) { - i++ - if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i - } else if (value[i] === quote) return i - } - return lastEscaped -} - /** * Parse and load environment variables from a file */ function loadEnvFromFile(filePath: string): void { try { - const content = fs.readFileSync(filePath, "utf-8") - const lines = content.split("\n") - - for (const line of lines) { - const trimmed = line.trim() - - // Skip comments and empty lines - if (!trimmed || trimmed.startsWith("#")) continue - - const equalIndex = trimmed.indexOf("=") - if (equalIndex === -1) continue - - const key = trimmed.slice(0, equalIndex).trim() - let value = trimmed.slice(equalIndex + 1).trim() - - const quote = value[0] - const closingQuote = - quote === '"' || quote === "'" ? findClosingQuote(value) : -1 - if ( - closingQuote > 0 && - /^\s*(#.*)?$/.test(value.slice(closingQuote + 1)) - ) { - // Quoted value, then nothing or a comment: keep what is - // inside the quotes, as dotenv reads it - value = value.slice(1, closingQuote) - } else { - // Unquoted value: drop an inline comment ("value # comment"). - // A value quoted from start to end with quotes inside (JSON - // with an apostrophe) loses only the outer two, as in dotenv. - value = value.replace(/\s+#.*$/, "") - if ( - closingQuote > 0 && - value.length > 1 && - value.endsWith(quote) - ) { - value = value.slice(1, -1) - } - } - + const parsed = parse(fs.readFileSync(filePath, "utf-8")) + for (const [key, value] of Object.entries(parsed)) { // Don't override existing environment variables if (!(key in process.env)) { process.env[key] = value diff --git a/electron/main/index.ts b/electron/main/index.ts index 08313b1a..eb67ff4e 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,11 +1,9 @@ import { app, BrowserWindow, dialog, shell } from "electron" import { buildAppMenu } from "./app-menu" -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, getAppUrl, @@ -36,14 +34,8 @@ if (!gotTheLock) { 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() // Build application menu buildAppMenu() diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 8ccddd60..5e986059 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -1,17 +1,6 @@ -import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron" -import { rebuildAppMenu, switchPreset } from "./app-menu" -import { - type ConfigPreset, - createPreset, - deletePreset, - getAllPresets, - getCurrentPreset, - getCurrentPresetId, - getUserLocale, - setCurrentPreset, - setUserLocale, - updatePreset, -} from "./config-manager" +import { app, type IpcMainInvokeEvent, ipcMain } from "electron" +import { buildAppMenu } from "./app-menu" +import { setUserLocale, USER_LOCALES, type UserLocale } from "./config-manager" import { restartNextServer } from "./next-server" import { noteNoChats, rememberChatPort } from "./port-manager" import { @@ -23,37 +12,9 @@ import { import { isAppUrl } from "./window-manager" /** - * Allowed configuration keys for presets - * This whitelist prevents arbitrary environment variable injection - */ -const ALLOWED_CONFIG_KEYS = new Set([ - "AI_PROVIDER", - "AI_MODEL", - "AI_API_KEY", - "AI_BASE_URL", - "TEMPERATURE", -]) - -/** - * Sanitize preset config to only include allowed keys - */ -function sanitizePresetConfig( - config: Record, -): Record { - const sanitized: Record = {} - for (const key of ALLOWED_CONFIG_KEYS) { - if (key in config && typeof config[key] === "string") { - sanitized[key] = config[key] - } - } - 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. + * Register an IPC handler that only answers the app's own pages (on the app + * server). 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, @@ -61,7 +22,7 @@ function handle( ): void { ipcMain.handle(channel, (event, ...args) => { const url = event.senderFrame?.url - if (!isAppUrl(url) && !url?.startsWith("file://")) { + if (!isAppUrl(url)) { throw new Error(`Blocked "${channel}" from untrusted page: ${url}`) } return listener(event, ...(args as Args)) @@ -72,12 +33,6 @@ function handle( * Register all IPC handlers */ export function registerIpcHandlers(): void { - // ==================== App Info ==================== - - handle("get-version", () => { - return app.getVersion() - }) - // ==================== Where the chats are ==================== // The page saved a chat, or loaded without any: decides which port @@ -87,116 +42,6 @@ export function registerIpcHandlers(): void { if (count === 0) noteNoChats() }) - // ==================== Window Controls ==================== - - ipcMain.on("window-minimize", (event) => { - const win = BrowserWindow.fromWebContents(event.sender) - win?.minimize() - }) - - ipcMain.on("window-maximize", (event) => { - const win = BrowserWindow.fromWebContents(event.sender) - if (win?.isMaximized()) { - win.unmaximize() - } else { - win?.maximize() - } - }) - - ipcMain.on("window-close", (event) => { - const win = BrowserWindow.fromWebContents(event.sender) - win?.close() - }) - - // ==================== Config Presets ==================== - - handle("config-presets:get-all", () => { - return getAllPresets() - }) - - handle("config-presets:get-current", () => { - return getCurrentPreset() - }) - - handle("config-presets:get-current-id", () => { - return getCurrentPresetId() - }) - - handle( - "config-presets:save", - async ( - _event, - preset: Omit & { - id?: string - }, - ) => { - // Validate preset name - if (typeof preset?.name !== "string" || !preset.name.trim()) { - throw new Error("Invalid preset name") - } - - // Sanitize config to only allow whitelisted keys - const sanitizedConfig = sanitizePresetConfig(preset.config ?? {}) - - if (preset.id) { - // Update existing preset - 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 - const created = createPreset({ - name: preset.name.trim(), - config: sanitizedConfig, - }) - rebuildAppMenu() - return created - }, - ) - - 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 - }) - - handle("config-presets:apply", async (_event, id: string) => { - try { - 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, - error: - error instanceof Error - ? error.message - : "Failed to restart server", - } - } - }) - - handle("config-presets:set-current", (_event, id: string | null) => { - return setCurrentPreset(id) - }) - // ==================== Proxy Settings ==================== handle("get-proxy", () => { @@ -228,7 +73,7 @@ export function registerIpcHandlers(): void { if (!app.isPackaged) { // In development, env vars are already applied // Next.js dev server may need manual restart - return { success: true, devMode: true } + return { success: true } } // Production: restart Next.js server to pick up new env vars @@ -247,20 +92,15 @@ export function registerIpcHandlers(): void { // ==================== User Locale ==================== - handle("get-user-locale", () => { - return getUserLocale() - }) - handle("set-user-locale", (_event, locale: string) => { - // Validate locale is one of the supported values - if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) { + if (!(USER_LOCALES as readonly string[]).includes(locale)) { return { success: false, error: "Invalid locale" } } try { - setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant") + setUserLocale(locale as UserLocale) // Rebuild the menu to reflect the new locale - rebuildAppMenu() + buildAppMenu() return { success: true } } catch (error) { return { diff --git a/electron/main/menu-i18n.ts b/electron/main/menu-i18n.ts index e641f30d..d08cf8c6 100644 --- a/electron/main/menu-i18n.ts +++ b/electron/main/menu-i18n.ts @@ -3,71 +3,35 @@ * Translations for menu labels that don't use Electron's built-in roles */ -import { getUserLocale } from "./config-manager" +import { getUserLocale, type UserLocale } from "./config-manager" -export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant" - -export interface MenuTranslations { - // App menu (macOS only) - settings: string +export type MenuLocale = UserLocale +const en = { // File menu - file: string + file: "File", // Edit menu - edit: string + edit: "Edit", // View menu - view: string - - // Configuration menu - configuration: string - switchPreset: string - managePresets: string - addConfigurationPreset: string + view: "View", // Window menu - window: string + window: "Window", // Help menu - help: string - documentation: string - reportIssue: string + help: "Help", + documentation: "Documentation", + reportIssue: "Report Issue", } +type MenuTranslations = typeof en + const translations: Record = { - en: { - // App menu - settings: "Settings...", - - // File menu - file: "File", - - // Edit menu - edit: "Edit", - - // View menu - view: "View", - - // Configuration menu - configuration: "Configuration", - switchPreset: "Switch Preset", - managePresets: "Manage Presets...", - addConfigurationPreset: "Add Configuration Preset...", - - // Window menu - window: "Window", - - // Help menu - help: "Help", - documentation: "Documentation", - reportIssue: "Report Issue", - }, + en, zh: { - // App menu - settings: "设置...", - // File menu file: "文件", @@ -77,12 +41,6 @@ const translations: Record = { // View menu view: "查看", - // Configuration menu - configuration: "配置", - switchPreset: "切换预设", - managePresets: "管理预设...", - addConfigurationPreset: "添加配置预设...", - // Window menu window: "窗口", @@ -93,9 +51,6 @@ const translations: Record = { }, ja: { - // App menu - settings: "設定...", - // File menu file: "ファイル", @@ -105,12 +60,6 @@ const translations: Record = { // View menu view: "表示", - // Configuration menu - configuration: "設定", - switchPreset: "プリセット切り替え", - managePresets: "プリセット管理...", - addConfigurationPreset: "設定プリセットを追加...", - // Window menu window: "ウインドウ", @@ -121,9 +70,6 @@ const translations: Record = { }, "zh-Hant": { - // App menu - settings: "設定...", - // File menu file: "檔案", @@ -133,12 +79,6 @@ const translations: Record = { // View menu view: "檢視", - // Configuration menu - configuration: "配置", - switchPreset: "切換預設", - managePresets: "管理預設...", - addConfigurationPreset: "新增配置預設...", - // Window menu window: "視窗", @@ -150,33 +90,22 @@ const translations: Record = { } /** - * Get menu translations for a given locale - * Falls back to English if locale is not supported + * Get menu translations for a locale (English for an unknown one) */ -export function getMenuTranslations(locale: string): MenuTranslations { - // Check for zh-Hant before normalizing - if ( - locale === "zh-Hant" || - locale.toLowerCase().startsWith("zh-hant") || - locale.toLowerCase().startsWith("zh-tw") || - locale.toLowerCase().startsWith("zh-hk") - ) { - return translations["zh-Hant"] - } - - // Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja") - const normalized = locale.toLowerCase().split("-")[0] - - if (normalized === "zh") return translations.zh - if (normalized === "ja") return translations.ja - return translations.en +export function getMenuTranslations(locale: MenuLocale): MenuTranslations { + return translations[locale] ?? translations.en } /** - * Detect system locale from Electron app - * Returns one of: "en", "zh", "ja", "zh-Hant" + * Get locale from stored preference or system default + * Checks config file for user's language preference first */ -export function detectSystemLocale(appLocale: string): MenuLocale { +export function getPreferredLocale(appLocale: string): MenuLocale { + const savedLocale = getUserLocale() + if (savedLocale) { + return savedLocale + } + const lower = appLocale.toLowerCase() // Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified @@ -188,24 +117,9 @@ export function detectSystemLocale(appLocale: string): MenuLocale { return "zh-Hant" } - const normalized = lower.split("-")[0] + const language = lower.split("-")[0] - if (normalized === "zh") return "zh" - if (normalized === "ja") return "ja" + if (language === "zh") return "zh" + if (language === "ja") return "ja" return "en" } - -/** - * Get locale from stored preference or system default - * Checks config file for user's language preference first - */ -export function getPreferredLocale(appLocale: string): MenuLocale { - // Try to get from saved preference first - const savedLocale = getUserLocale() - if (savedLocale) { - return savedLocale - } - - // Fall back to system locale - return detectSystemLocale(appLocale) -} diff --git a/electron/main/next-server.ts b/electron/main/next-server.ts index 0a09ed9f..aa3987af 100644 --- a/electron/main/next-server.ts +++ b/electron/main/next-server.ts @@ -12,7 +12,7 @@ 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 +// proxy changes) can't leave two servers running let serverQueue: Promise = Promise.resolve() function runExclusive(task: () => Promise): Promise { @@ -21,18 +21,6 @@ function runExclusive(task: () => Promise): Promise { return result } -/** - * Get the path to the standalone server resources - * In packaged app: resources/standalone - * In development: .next/standalone - */ -function getResourcePath(): string { - if (app.isPackaged) { - return path.join(process.resourcesPath, "standalone") - } - return path.join(app.getAppPath(), ".next", "standalone") -} - /** * Wait for the server to be ready by polling the health endpoint */ @@ -61,7 +49,8 @@ export function startNextServer(): Promise { } async function startServer(): Promise { - const resourcePath = getResourcePath() + // The standalone server shipped in the packaged app's resources + const resourcePath = path.join(process.resourcesPath, "standalone") const serverPath = path.join(resourcePath, "server.js") console.log(`Starting Next.js server from: ${resourcePath}`) @@ -75,7 +64,7 @@ async function startServer(): Promise { ) } - // Find an available port (random in production, fixed in development) + // Find an available port (the fixed ports 61337 / 13370, then fallbacks) const port = await findAvailablePort() console.log(`Using port: ${port}`) @@ -86,7 +75,7 @@ async function startServer(): Promise { HOSTNAME: "127.0.0.1", // Enable Node.js built-in proxy support for fetch (Node.js 24+) NODE_USE_ENV_PROXY: "1", - // The preset keys are the user's own, not a server's + // The local server's env keys are the user's own, not a shared server's NEXT_AI_DRAWIO_DESKTOP: "1", } @@ -97,10 +86,7 @@ async function startServer(): Promise { // 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) { - const cacheDir = path.join(app.getPath("userData"), "cache") - env.NEXT_CACHE_DIR = cacheDir - } + env.NEXT_CACHE_DIR = path.join(app.getPath("userData"), "cache") // Copy existing environment variables for (const [key, value] of Object.entries(process.env)) { diff --git a/electron/main/port-manager.ts b/electron/main/port-manager.ts index 509bc9ab..f6c42b70 100644 --- a/electron/main/port-manager.ts +++ b/electron/main/port-manager.ts @@ -9,8 +9,6 @@ import { app } from "electron" * (localStorage is origin-specific, so changing ports loses all saved data) */ const PORT_CONFIG = { - // Development mode uses fixed port for hot reload compatibility - development: 6002, // Legacy production port — tried first to preserve localStorage for existing users legacyProduction: 61337, // New production port below the ephemeral range (49152-65535) @@ -108,9 +106,8 @@ export function isPortAvailable(port: number): Promise { /** * Find an available port - * - In development: uses fixed port (6002) - * - In production: uses the legacy port (61337), then 13370, to preserve - * localStorage; 13370 first when only it has saved chats + * - Uses the legacy port (61337), then 13370, to preserve localStorage; + * 13370 first when only it has saved chats * - Falls back to sequential ports if preferred port is unavailable * - Last resort: lets the OS assign a port (port 0) * @@ -118,10 +115,7 @@ export function isPortAvailable(port: number): Promise { * @returns Promise The available port */ export async function findAvailablePort(reuseExisting = true): Promise { - const isDev = !app.isPackaged - const preferredPort = isDev - ? PORT_CONFIG.development - : PORT_CONFIG.production + const preferredPort = PORT_CONFIG.production // Try to reuse cached port if requested and available if (reuseExisting && allocatedPort !== null) { @@ -135,19 +129,18 @@ export async function findAvailablePort(reuseExisting = true): Promise { allocatedPort = null } - // In production, first the port where a chat was last saved. Without - // one, the legacy port first to preserve existing users' data, unless - // only the new port has data: their app started on 13370 while Windows - // reserved 61337, and 61337 being free now would hide it - const chatPort = isDev ? null : readChatPort() - const candidates = isDev - ? [preferredPort] - : chatPort !== null - ? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)] - : hasStoredData(PORT_CONFIG.production) && - !hasStoredData(PORT_CONFIG.legacyProduction) - ? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction] - : [PORT_CONFIG.legacyProduction, PORT_CONFIG.production] + // First the port where a chat was last saved. Without one, the legacy + // port first to preserve existing users' data, unless only the new port + // has data: their app started on 13370 while Windows reserved 61337, + // and 61337 being free now would hide it + const chatPort = readChatPort() + const candidates = + chatPort !== null + ? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)] + : hasStoredData(PORT_CONFIG.production) && + !hasStoredData(PORT_CONFIG.legacyProduction) + ? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction] + : [PORT_CONFIG.legacyProduction, PORT_CONFIG.production] for (const port of candidates) { if (await isPortAvailable(port)) { allocatedPort = port diff --git a/electron/main/settings-window.ts b/electron/main/settings-window.ts deleted file mode 100644 index b5ee490b..00000000 --- a/electron/main/settings-window.ts +++ /dev/null @@ -1,78 +0,0 @@ -import path from "node:path" -import { app, BrowserWindow, ipcMain } from "electron" - -let settingsWindow: BrowserWindow | null = null - -/** - * Create and show the settings window - */ -export function showSettingsWindow(parentWindow?: BrowserWindow): void { - // If settings window already exists, focus it - if (settingsWindow && !settingsWindow.isDestroyed()) { - settingsWindow.focus() - return - } - - // Determine path to settings preload script - // In compiled output: dist-electron/preload/settings.js - const preloadPath = path.join(__dirname, "..", "preload", "settings.js") - - // Determine path to settings HTML - // In packaged app: app.asar/dist-electron/settings/index.html - // In development: electron/settings/index.html - const settingsHtmlPath = app.isPackaged - ? path.join(__dirname, "..", "settings", "index.html") - : path.join(__dirname, "..", "..", "electron", "settings", "index.html") - - settingsWindow = new BrowserWindow({ - width: 600, - height: 700, - minWidth: 500, - minHeight: 500, - parent: parentWindow, - modal: false, - show: false, - title: "Settings - Next AI Draw.io", - webPreferences: { - preload: preloadPath, - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }) - settingsWindow.loadFile(settingsHtmlPath) - - settingsWindow.once("ready-to-show", () => { - settingsWindow?.show() - }) - - settingsWindow.on("closed", () => { - settingsWindow = null - }) -} - -/** - * Close the settings window if it exists - */ -export function closeSettingsWindow(): void { - if (settingsWindow && !settingsWindow.isDestroyed()) { - settingsWindow.close() - settingsWindow = null - } -} - -/** - * Check if settings window is open - */ -export function isSettingsWindowOpen(): boolean { - return settingsWindow !== null && !settingsWindow.isDestroyed() -} - -/** - * Register settings window IPC handlers - */ -export function registerSettingsWindowHandlers(): void { - ipcMain.on("settings:close", () => { - closeSettingsWindow() - }) -} diff --git a/electron/main/window-manager.ts b/electron/main/window-manager.ts index e533f500..08289da1 100644 --- a/electron/main/window-manager.ts +++ b/electron/main/window-manager.ts @@ -107,9 +107,9 @@ export function getAppUrl(): string | null { /** * Point the main window at the restarted app server (it can come up on a - * different port). On the same port the page fetches the new preset's - * server models instead of sending the old preset's choice; it is not - * reloaded, which would drop unsent attachments. + * different port). On the same port the page is told so it can fetch the + * server's models again; it is not reloaded, which would drop unsent + * attachments. */ export function setAppUrl(url: string): void { if (url === appUrl) { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f24249f7..4dd89920 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -4,27 +4,15 @@ import { contextBridge, ipcRenderer } from "electron" * Expose safe APIs to the renderer process */ contextBridge.exposeInMainWorld("electronAPI", { - // Platform information - platform: process.platform, - // Check if running in Electron isElectron: true, - // Application version - getVersion: () => ipcRenderer.invoke("get-version"), - - // Window controls (optional, for custom title bar) - minimize: () => ipcRenderer.send("window-minimize"), - maximize: () => ipcRenderer.send("window-maximize"), - close: () => ipcRenderer.send("window-close"), - // Proxy settings getProxy: () => ipcRenderer.invoke("get-proxy"), setProxy: (config: { httpProxy?: string; httpsProxy?: string }) => ipcRenderer.invoke("set-proxy", config), // User locale settings - getUserLocale: () => ipcRenderer.invoke("get-user-locale"), setUserLocale: (locale: string) => ipcRenderer.invoke("set-user-locale", locale), @@ -33,7 +21,7 @@ contextBridge.exposeInMainWorld("electronAPI", { chatSaved: () => ipcRenderer.invoke("chat-saved"), chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count), - // The server restarted on the same port (another preset) + // The server restarted on the same port (proxy change) onServerRestarted: (callback: () => void) => { const listener = () => callback() ipcRenderer.on("server-restarted", listener) diff --git a/electron/preload/settings.ts b/electron/preload/settings.ts deleted file mode 100644 index d3fe0114..00000000 --- a/electron/preload/settings.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Preload script for settings window - * Exposes APIs for managing configuration presets - */ -import { contextBridge, ipcRenderer } from "electron" - -// Expose settings API to the renderer process -contextBridge.exposeInMainWorld("settingsAPI", { - // Get all presets - getPresets: () => ipcRenderer.invoke("config-presets:get-all"), - - // Get current preset ID - getCurrentPresetId: () => - ipcRenderer.invoke("config-presets:get-current-id"), - - // Get current preset - getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"), - - // Save (create or update) a preset - savePreset: (preset: { - id?: string - name: string - config: Record - }) => ipcRenderer.invoke("config-presets:save", preset), - - // Delete a preset - deletePreset: (id: string) => - ipcRenderer.invoke("config-presets:delete", id), - - // Apply a preset (sets environment variables and restarts server) - applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id), - - // Close settings window - close: () => ipcRenderer.send("settings:close"), -}) diff --git a/electron/settings/index.html b/electron/settings/index.html deleted file mode 100644 index 9f5fed1a..00000000 --- a/electron/settings/index.html +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - Settings - Next AI Draw.io - - - -
-
- ⚠️ Deprecation Notice -

This settings panel will be removed in a future update.

-

Please use the AI Model Configuration button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.

-
- -

Configuration Presets

- -
-

Presets

-
- -
- -
-
- - - - - - - - -
- - - - diff --git a/electron/settings/settings.css b/electron/settings/settings.css deleted file mode 100644 index 32fbd180..00000000 --- a/electron/settings/settings.css +++ /dev/null @@ -1,344 +0,0 @@ -:root { - --bg-primary: #ffffff; - --bg-secondary: #f5f5f5; - --bg-hover: #e8e8e8; - --text-primary: #1a1a1a; - --text-secondary: #666666; - --border-color: #e0e0e0; - --accent-color: #0066cc; - --accent-hover: #0052a3; - --danger-color: #dc3545; - --success-color: #28a745; -} - -@media (prefers-color-scheme: dark) { - :root { - --bg-primary: #1a1a1a; - --bg-secondary: #2d2d2d; - --bg-hover: #3d3d3d; - --text-primary: #ffffff; - --text-secondary: #a0a0a0; - --border-color: #404040; - --accent-color: #4da6ff; - --accent-hover: #66b3ff; - } -} - -.deprecation-notice { - background-color: #fff3cd; - border: 1px solid #ffc107; - border-radius: 8px; - padding: 16px; - margin-bottom: 20px; -} - -.deprecation-notice strong { - color: #856404; - display: block; - margin-bottom: 8px; - font-size: 14px; -} - -.deprecation-notice p { - color: #856404; - font-size: 13px; - margin: 4px 0; -} - -@media (prefers-color-scheme: dark) { - .deprecation-notice { - background-color: #332701; - border-color: #665200; - } - - .deprecation-notice strong, - .deprecation-notice p { - color: #ffc107; - } -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, - sans-serif; - background-color: var(--bg-primary); - color: var(--text-primary); - line-height: 1.5; -} - -.container { - max-width: 560px; - margin: 0 auto; - padding: 24px; -} - -h1 { - font-size: 24px; - font-weight: 600; - margin-bottom: 24px; - padding-bottom: 16px; - border-bottom: 1px solid var(--border-color); -} - -h2 { - font-size: 16px; - font-weight: 600; - margin-bottom: 16px; - color: var(--text-secondary); -} - -.section { - margin-bottom: 32px; -} - -.preset-list { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 16px; -} - -.preset-card { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 16px; - cursor: pointer; - transition: all 0.2s ease; -} - -.preset-card:hover { - background: var(--bg-hover); -} - -.preset-card.active { - border-color: var(--accent-color); - box-shadow: 0 0 0 1px var(--accent-color); -} - -.preset-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.preset-name { - font-weight: 600; - font-size: 15px; -} - -.preset-badge { - background: var(--accent-color); - color: white; - font-size: 11px; - padding: 2px 8px; - border-radius: 10px; -} - -.preset-info { - font-size: 13px; - color: var(--text-secondary); -} - -.preset-actions { - display: flex; - gap: 8px; - margin-top: 12px; -} - -.btn { - padding: 8px 16px; - border: none; - border-radius: 6px; - font-size: 14px; - cursor: pointer; - transition: all 0.2s ease; - font-weight: 500; -} - -.btn-primary { - background: var(--accent-color); - color: white; -} - -.btn-primary:hover { - background: var(--accent-hover); -} - -.btn-secondary { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); -} - -.btn-secondary:hover { - background: var(--bg-hover); -} - -.btn-danger { - background: var(--danger-color); - color: white; -} - -.btn-danger:hover { - opacity: 0.9; -} - -.btn-sm { - padding: 6px 12px; - font-size: 13px; -} - -.empty-state { - text-align: center; - padding: 40px 20px; - color: var(--text-secondary); -} - -.empty-state p { - margin-bottom: 16px; -} - -/* Modal */ -.modal-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 100; - align-items: center; - justify-content: center; -} - -.modal-overlay.show { - display: flex; -} - -.modal { - background: var(--bg-primary); - border-radius: 12px; - width: 90%; - max-width: 480px; - max-height: 90vh; - overflow-y: auto; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); -} - -.modal-header { - padding: 20px 24px; - border-bottom: 1px solid var(--border-color); -} - -.modal-header h3 { - font-size: 18px; - font-weight: 600; -} - -.modal-body { - padding: 24px; -} - -.modal-footer { - padding: 16px 24px; - border-top: 1px solid var(--border-color); - display: flex; - justify-content: flex-end; - gap: 12px; -} - -.form-group { - margin-bottom: 20px; -} - -.form-group label { - display: block; - font-size: 14px; - font-weight: 500; - margin-bottom: 6px; -} - -.form-group input, -.form-group select { - width: 100%; - padding: 10px 12px; - border: 1px solid var(--border-color); - border-radius: 6px; - font-size: 14px; - background: var(--bg-primary); - color: var(--text-primary); -} - -.form-group input:focus, -.form-group select:focus { - outline: none; - border-color: var(--accent-color); - box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2); -} - -.form-group .hint { - font-size: 12px; - color: var(--text-secondary); - margin-top: 4px; -} - -.loading { - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid var(--border-color); - border-top-color: var(--accent-color); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - background: var(--text-primary); - color: var(--bg-primary); - padding: 12px 24px; - border-radius: 8px; - font-size: 14px; - z-index: 200; - opacity: 0; - transition: opacity 0.3s ease; -} - -.toast.show { - opacity: 1; -} - -.toast.success { - background: var(--success-color); - color: white; -} - -.toast.error { - background: var(--danger-color); - color: white; -} - -/* Inline style replacements */ -.delete-warning { - color: var(--text-secondary); - margin-top: 8px; - font-size: 14px; -} diff --git a/electron/settings/settings.js b/electron/settings/settings.js deleted file mode 100644 index b71928de..00000000 --- a/electron/settings/settings.js +++ /dev/null @@ -1,313 +0,0 @@ -// Settings page JavaScript -// This file handles the UI interactions for the settings window - -let presets = [] -let currentPresetId = null -let editingPresetId = null -let deletingPresetId = null - -// DOM Elements -const presetList = document.getElementById("preset-list") -const addPresetBtn = document.getElementById("add-preset-btn") -const presetModal = document.getElementById("preset-modal") -const deleteModal = document.getElementById("delete-modal") -const presetForm = document.getElementById("preset-form") -const modalTitle = document.getElementById("modal-title") -const toast = document.getElementById("toast") - -// Form fields -const presetIdField = document.getElementById("preset-id") -const presetNameField = document.getElementById("preset-name") -const aiProviderField = document.getElementById("ai-provider") -const aiModelField = document.getElementById("ai-model") -const aiApiKeyField = document.getElementById("ai-api-key") -const aiBaseUrlField = document.getElementById("ai-base-url") -const temperatureField = document.getElementById("temperature") - -// Buttons -const cancelBtn = document.getElementById("cancel-btn") -const saveBtn = document.getElementById("save-btn") -const deleteCancelBtn = document.getElementById("delete-cancel-btn") -const deleteConfirmBtn = document.getElementById("delete-confirm-btn") - -// Initialize -document.addEventListener("DOMContentLoaded", async () => { - await loadPresets() - setupEventListeners() -}) - -// Load presets from main process -async function loadPresets() { - try { - presets = await window.settingsAPI.getPresets() - currentPresetId = await window.settingsAPI.getCurrentPresetId() - renderPresets() - } catch (error) { - console.error("Failed to load presets:", error) - showToast("Failed to load presets", "error") - } -} - -// Render presets list -function renderPresets() { - if (presets.length === 0) { - presetList.innerHTML = ` -
-

No presets configured yet.

-

Add a preset to quickly switch between different AI configurations.

-
- ` - return - } - - presetList.innerHTML = presets - .map((preset) => { - const isActive = preset.id === currentPresetId - const providerLabel = getProviderLabel(preset.config.AI_PROVIDER) - - return ` -
-
- ${escapeHtml(preset.name)} - ${isActive ? 'Active' : ""} -
-
- ${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"} - ${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""} -
-
- ${!isActive ? `` : ""} - - -
-
- ` - }) - .join("") - - // Add event listeners to buttons - presetList.querySelectorAll(".apply-btn").forEach((btn) => { - btn.addEventListener("click", (e) => { - e.stopPropagation() - applyPreset(btn.dataset.id) - }) - }) - - presetList.querySelectorAll(".edit-btn").forEach((btn) => { - btn.addEventListener("click", (e) => { - e.stopPropagation() - openEditModal(btn.dataset.id) - }) - }) - - presetList.querySelectorAll(".delete-btn").forEach((btn) => { - btn.addEventListener("click", (e) => { - e.stopPropagation() - openDeleteModal(btn.dataset.id) - }) - }) -} - -// Setup event listeners -function setupEventListeners() { - addPresetBtn.addEventListener("click", () => openAddModal()) - cancelBtn.addEventListener("click", () => closeModal()) - saveBtn.addEventListener("click", () => savePreset()) - deleteCancelBtn.addEventListener("click", () => closeDeleteModal()) - deleteConfirmBtn.addEventListener("click", () => confirmDelete()) - - // Close modal on overlay click - presetModal.addEventListener("click", (e) => { - if (e.target === presetModal) closeModal() - }) - deleteModal.addEventListener("click", (e) => { - if (e.target === deleteModal) closeDeleteModal() - }) - - // Handle Enter key in form - presetForm.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault() - savePreset() - } - }) -} - -// Open add modal -function openAddModal() { - editingPresetId = null - modalTitle.textContent = "Add Preset" - presetForm.reset() - presetIdField.value = "" - presetModal.classList.add("show") - presetNameField.focus() -} - -// Open edit modal -function openEditModal(id) { - const preset = presets.find((p) => p.id === id) - if (!preset) return - - editingPresetId = id - modalTitle.textContent = "Edit Preset" - - presetIdField.value = preset.id - presetNameField.value = preset.name - aiProviderField.value = preset.config.AI_PROVIDER || "" - aiModelField.value = preset.config.AI_MODEL || "" - aiApiKeyField.value = preset.config.AI_API_KEY || "" - aiBaseUrlField.value = preset.config.AI_BASE_URL || "" - temperatureField.value = preset.config.TEMPERATURE || "" - - presetModal.classList.add("show") - presetNameField.focus() -} - -// Close modal -function closeModal() { - presetModal.classList.remove("show") - editingPresetId = null -} - -// Open delete modal -function openDeleteModal(id) { - const preset = presets.find((p) => p.id === id) - if (!preset) return - - deletingPresetId = id - document.getElementById("delete-preset-name").textContent = preset.name - deleteModal.classList.add("show") -} - -// Close delete modal -function closeDeleteModal() { - deleteModal.classList.remove("show") - deletingPresetId = null -} - -// Save preset -async function savePreset() { - const name = presetNameField.value.trim() - if (!name) { - showToast("Please enter a preset name", "error") - presetNameField.focus() - return - } - - const preset = { - id: editingPresetId || undefined, - name: name, - config: { - AI_PROVIDER: aiProviderField.value || undefined, - AI_MODEL: aiModelField.value.trim() || undefined, - AI_API_KEY: aiApiKeyField.value.trim() || undefined, - AI_BASE_URL: aiBaseUrlField.value.trim() || undefined, - TEMPERATURE: temperatureField.value.trim() || undefined, - }, - } - - // Remove undefined values - Object.keys(preset.config).forEach((key) => { - if (preset.config[key] === undefined) { - delete preset.config[key] - } - }) - - // closeModal() clears editingPresetId, so remember it for the toast - const isEdit = Boolean(editingPresetId) - - try { - saveBtn.disabled = true - saveBtn.innerHTML = '' - - await window.settingsAPI.savePreset(preset) - await loadPresets() - closeModal() - showToast(isEdit ? "Preset updated" : "Preset created", "success") - } catch (error) { - console.error("Failed to save preset:", error) - showToast("Failed to save preset", "error") - } finally { - saveBtn.disabled = false - saveBtn.textContent = "Save" - } -} - -// Confirm delete -async function confirmDelete() { - if (!deletingPresetId) return - - try { - deleteConfirmBtn.disabled = true - deleteConfirmBtn.innerHTML = '' - - await window.settingsAPI.deletePreset(deletingPresetId) - await loadPresets() - closeDeleteModal() - showToast("Preset deleted", "success") - } catch (error) { - console.error("Failed to delete preset:", error) - showToast("Failed to delete preset", "error") - } finally { - deleteConfirmBtn.disabled = false - deleteConfirmBtn.textContent = "Delete" - } -} - -// Apply preset -async function applyPreset(id) { - try { - const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`) - if (btn) { - btn.disabled = true - btn.innerHTML = '' - } - - const result = await window.settingsAPI.applyPreset(id) - if (result.success) { - showToast("Preset applied, server restarting...", "success") - } else { - showToast(result.error || "Failed to apply preset", "error") - } - } 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() - } -} - -// Get provider display label -function getProviderLabel(provider) { - const labels = { - openai: "OpenAI", - anthropic: "Anthropic", - google: "Google AI", - azure: "Azure OpenAI", - bedrock: "AWS Bedrock", - openrouter: "OpenRouter", - deepseek: "DeepSeek", - siliconflow: "SiliconFlow", - modelscope: "ModelScope", - ollama: "Ollama", - } - return labels[provider] || provider -} - -// Show toast notification -function showToast(message, type = "") { - toast.textContent = message - toast.className = "toast show" + (type ? ` ${type}` : "") - - setTimeout(() => { - toast.classList.remove("show") - }, 3000) -} - -// Escape HTML to prevent XSS -function escapeHtml(text) { - const div = document.createElement("div") - div.textContent = text - return div.innerHTML -} diff --git a/package.json b/package.json index c987c36e..9a4b51c4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", "electron:dev": "node scripts/electron-dev.mjs", "electron:build": "npm run build && npm run electron:compile", - "electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/", + "electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap", "electron:start": "npx cross-env NODE_ENV=development npx electron .", "electron:prepare": "node scripts/prepare-electron-build.mjs", "dist": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml", @@ -67,7 +67,6 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-use-controllable-state": "^1.2.2", - "@xmldom/xmldom": "^0.9.8", "ai": "^6.0.300", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -86,7 +85,6 @@ "react-dom": "^19.1.2", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", "saxes": "^6.0.0", "server-only": "^0.0.1", "sonner": "^2.0.7", @@ -109,14 +107,12 @@ ] }, "devDependencies": { - "@anthropic-ai/tokenizer": "^0.0.4", "@biomejs/biome": "2.5.15", "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.1", - "@testing-library/user-event": "^14.6.1", "@types/negotiator": "^0.6.4", "@types/node": "^24.0.0", "@types/pako": "^2.0.3", @@ -124,23 +120,18 @@ "@types/react-dom": "^19", "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^5.1.2", - "@vitest/coverage-v8": "^4.0.16", - "concurrently": "^9.2.1", "cross-env": "^10.1.0", + "dotenv": "^16.6.1", "electron": "^41.10.6", "electron-builder": "^26.0.12", "esbuild": "^0.28.0", - "eslint": "9.39.5", - "eslint-config-next": "16.1.6", "husky": "^9.1.7", "jsdom": "^27.4.0", "lint-staged": "^16.2.7", - "shx": "^0.4.0", "tailwindcss": "^4", "typescript": "^5", "vite-tsconfig-paths": "^6.0.3", "vitest": "^4.0.16", - "wait-on": "^9.0.3", "wrangler": "^4.60.0" }, "overrides": { diff --git a/resources/entitlements.mac.plist b/resources/entitlements.mac.plist deleted file mode 100644 index 809024b2..00000000 --- a/resources/entitlements.mac.plist +++ /dev/null @@ -1,16 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.allow-dyld-environment-variables - - com.apple.security.network.client - - com.apple.security.network.server - - - diff --git a/scripts/afterPack.cjs b/scripts/afterPack.cjs index bad13003..060918e4 100644 --- a/scripts/afterPack.cjs +++ b/scripts/afterPack.cjs @@ -4,49 +4,10 @@ * and ad-hoc signs macOS apps for offline draw.io bundle compatibility */ -const { - copyFileSync, - existsSync, - lstatSync, - mkdirSync, - readdirSync, - statSync, -} = require("fs") +const { existsSync } = require("fs") const path = require("path") const { execSync } = require("child_process") - -/** - * Copy directory recursively, converting symlinks to regular files/directories. - * This is needed because cpSync with dereference:true does NOT convert symlinks. - * macOS codesign fails if bundle contains symlinks pointing outside the bundle. - */ -function copyDereferenced(src, dst) { - const lstat = lstatSync(src) - - if (lstat.isSymbolicLink()) { - // Follow symlink and check what it points to - const stat = statSync(src) - if (stat.isDirectory()) { - // Symlink to directory: recursively copy the directory contents - mkdirSync(dst, { recursive: true }) - for (const entry of readdirSync(src)) { - copyDereferenced(path.join(src, entry), path.join(dst, entry)) - } - } else { - // Symlink to file: copy the actual file content - mkdirSync(path.join(dst, ".."), { recursive: true }) - copyFileSync(src, dst) - } - } else if (lstat.isDirectory()) { - mkdirSync(dst, { recursive: true }) - for (const entry of readdirSync(src)) { - copyDereferenced(path.join(src, entry), path.join(dst, entry)) - } - } else { - mkdirSync(path.join(dst, ".."), { recursive: true }) - copyFileSync(src, dst) - } -} +const copyDereferenced = require("./copy-dereferenced.cjs") module.exports = async (context) => { const appOutDir = context.appOutDir diff --git a/scripts/copy-dereferenced.cjs b/scripts/copy-dereferenced.cjs new file mode 100644 index 00000000..18def8af --- /dev/null +++ b/scripts/copy-dereferenced.cjs @@ -0,0 +1,43 @@ +const { + copyFileSync, + lstatSync, + mkdirSync, + readdirSync, + statSync, +} = require("fs") +const path = require("path") + +/** + * Copy directory recursively, converting symlinks to regular files/directories. + * This is needed because cpSync with dereference:true does NOT convert symlinks. + * macOS codesign fails if bundle contains symlinks pointing outside the bundle. + */ +function copyDereferenced(src, dst) { + const lstat = lstatSync(src) + + if (lstat.isSymbolicLink()) { + // Follow symlink and check what it points to + const stat = statSync(src) + if (stat.isDirectory()) { + // Symlink to directory: recursively copy the directory contents + mkdirSync(dst, { recursive: true }) + for (const entry of readdirSync(src)) { + copyDereferenced(path.join(src, entry), path.join(dst, entry)) + } + } else { + // Symlink to file: copy the actual file content + mkdirSync(path.join(dst, ".."), { recursive: true }) + copyFileSync(src, dst) + } + } else if (lstat.isDirectory()) { + mkdirSync(dst, { recursive: true }) + for (const entry of readdirSync(src)) { + copyDereferenced(path.join(src, entry), path.join(dst, entry)) + } + } else { + mkdirSync(path.join(dst, ".."), { recursive: true }) + copyFileSync(src, dst) + } +} + +module.exports = copyDereferenced diff --git a/scripts/electron-dev.mjs b/scripts/electron-dev.mjs index 99ee6c84..c5e649fe 100644 --- a/scripts/electron-dev.mjs +++ b/scripts/electron-dev.mjs @@ -2,17 +2,13 @@ /** * Development script for running Electron with Next.js - * 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 - * 5. Launches Electron - * 6. Watches for preset changes and restarts Next.js + * 1. Starts Next.js dev server + * 2. Waits for it to be ready + * 3. Compiles Electron TypeScript + * 4. Launches Electron */ import { spawn } from "node:child_process" -import { existsSync, readFileSync, watch } from "node:fs" -import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" @@ -22,66 +18,6 @@ const rootDir = path.join(__dirname, "..") const NEXT_PORT = 6002 const NEXT_URL = `http://localhost:${NEXT_PORT}` -/** - * Get the user data path (same as Electron's app.getPath("userData")) - */ -function getUserDataPath() { - const appName = "next-ai-draw-io" - switch (process.platform) { - case "darwin": - return path.join( - os.homedir(), - "Library", - "Application Support", - appName, - ) - case "win32": - return path.join( - process.env.APPDATA || - path.join(os.homedir(), "AppData", "Roaming"), - appName, - ) - default: - return path.join(os.homedir(), ".config", appName) - } -} - -/** - * 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) - */ -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( - 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 */ @@ -143,21 +79,12 @@ function killProcess(proc) { } /** - * Start Next.js dev server with preset environment + * Start Next.js dev server */ -function startNextServer(presetEnv) { - // The preset keys are the user's own, not a server's +function startNextServer() { + // The local server's env keys are the user's own, not a shared server's const env = { ...process.env, NEXT_AI_DRAWIO_DESKTOP: "1" } - // Apply preset environment variables - if (presetEnv) { - for (const [key, value] of Object.entries(presetEnv)) { - if (value !== undefined && value !== "") { - env[key] = value - } - } - } - const nextProcess = spawn("npm", ["run", "dev"], { cwd: rootDir, stdio: "inherit", @@ -178,13 +105,9 @@ function startNextServer(presetEnv) { async function main() { console.log("🚀 Starting Electron development environment...\n") - // Load preset configuration - let presetEnvContent = readPresetEnvFile() - const presetEnv = loadPresetEnv(presetEnvContent) - - // Start Next.js dev server with preset env + // Start Next.js dev server console.log("1. Starting Next.js development server...") - let nextProcess = startNextServer(presetEnv) + const nextProcess = startNextServer() // Wait for Next.js to be ready try { @@ -219,82 +142,14 @@ async function main() { }, }) - // Watch for preset env changes - const userDataPath = getUserDataPath() - let configWatcher = null - let restartPending = false - - // Restart Next.js when the preset env vars really changed - async function applyPresetChange() { - if (restartPending) return - 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("✅ Next.js server restarted with new configuration\n") - } catch (err) { - console.error("❌ Failed to restart Next.js:", err.message) - } - - restartPending = false - // A change written during the restart was skipped above - applyPresetChange() - } - - function setupConfigWatcher() { - 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( - userDataPath, - { persistent: false }, - (_eventType, filename) => { - if (filename === PRESET_ENV_FILE) applyPresetChange() - }, - ) - console.log("👀 Watching for preset configuration changes...") - // Electron may have written its preset before the watch started - applyPresetChange() - } catch (_err) { - // Directory might not be ready yet, try again later - setTimeout(setupConfigWatcher, 5000) - } - } - - // 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() killProcess(nextProcess) process.exit(code || 0) }) electronProcess.on("error", (err) => { console.error("Electron error:", err) - if (configWatcher) configWatcher.close() killProcess(nextProcess) process.exit(1) }) @@ -302,7 +157,6 @@ async function main() { // Handle termination signals const cleanup = () => { console.log("\n🛑 Shutting down...") - if (configWatcher) configWatcher.close() killProcess(electronProcess) killProcess(nextProcess) process.exit(0) diff --git a/scripts/prepare-electron-build.mjs b/scripts/prepare-electron-build.mjs index 0d50084e..a93ad2b0 100644 --- a/scripts/prepare-electron-build.mjs +++ b/scripts/prepare-electron-build.mjs @@ -6,54 +6,14 @@ * that electron-builder can properly include */ -import { - copyFileSync, - existsSync, - lstatSync, - mkdirSync, - readdirSync, - rmSync, - statSync, -} from "node:fs" +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs" import { join } from "node:path" import { fileURLToPath } from "node:url" +import copyDereferenced from "./copy-dereferenced.cjs" const __dirname = fileURLToPath(new URL(".", import.meta.url)) const rootDir = join(__dirname, "..") -/** - * Copy directory recursively, converting symlinks to regular files/directories. - * This is needed because cpSync with dereference:true does NOT convert symlinks. - * macOS codesign fails if bundle contains symlinks pointing outside the bundle. - */ -function copyDereferenced(src, dst) { - const lstat = lstatSync(src) - - if (lstat.isSymbolicLink()) { - // Follow symlink and check what it points to - const stat = statSync(src) - if (stat.isDirectory()) { - // Symlink to directory: recursively copy the directory contents - mkdirSync(dst, { recursive: true }) - for (const entry of readdirSync(src)) { - copyDereferenced(join(src, entry), join(dst, entry)) - } - } else { - // Symlink to file: copy the actual file content - mkdirSync(join(dst, ".."), { recursive: true }) - copyFileSync(src, dst) - } - } else if (lstat.isDirectory()) { - mkdirSync(dst, { recursive: true }) - for (const entry of readdirSync(src)) { - copyDereferenced(join(src, entry), join(dst, entry)) - } - } else { - mkdirSync(join(dst, ".."), { recursive: true }) - copyFileSync(src, dst) - } -} - const standaloneDir = join(rootDir, ".next", "standalone") const staticDir = join(rootDir, ".next", "static") const targetDir = join(rootDir, "electron-standalone") diff --git a/tests/unit/app-menu.test.ts b/tests/unit/app-menu.test.ts deleted file mode 100644 index a3493318..00000000 --- a/tests/unit/app-menu.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// @vitest-environment node -import { beforeEach, describe, expect, it, vi } from "vitest" - -vi.mock("electron", () => ({ - app: { - isPackaged: true, - getName: () => "app", - getVersion: () => "1", - getLocale: () => "en", - }, - BrowserWindow: { getFocusedWindow: () => null }, - dialog: {}, - Menu: { buildFromTemplate: () => ({}), setApplicationMenu: () => {} }, - shell: {}, -})) - -// The saved current preset, and restarts that wait until the test ends them -const state = vi.hoisted(() => ({ - current: "A" as string | null, - restarts: [] as Array<{ resolve: () => void; reject: (e: Error) => void }>, -})) -vi.mock("@/electron/main/config-manager", () => ({ - applyPresetToEnv: (id: string) => { - if (id === "missing") return null - state.current = id - return { AI_PROVIDER: id } - }, - getAllPresets: () => [], - getCurrentPresetId: () => state.current, - setCurrentPreset: (id: string | null) => { - state.current = id - return true - }, -})) -vi.mock("@/electron/main/next-server", () => ({ - restartNextServer: () => - new Promise((resolve, reject) => - state.restarts.push({ resolve, reject }), - ), -})) -vi.mock("@/electron/main/menu-i18n", () => ({ - getMenuTranslations: () => new Proxy({}, { get: () => "x" }), - getPreferredLocale: () => "en", -})) -vi.mock("@/electron/main/settings-window", () => ({ - showSettingsWindow: () => {}, -})) - -import { switchPreset } from "@/electron/main/app-menu" - -beforeEach(() => { - state.current = "A" - state.restarts = [] -}) - -describe("switchPreset", () => { - it("keeps a preset chosen while a failed switch was restarting", async () => { - const toB = switchPreset("B").catch(() => {}) - const toC = switchPreset("C") - // B's restart fails after the user already picked C - state.restarts[0].reject(new Error("timed out")) - await new Promise((r) => setTimeout(r, 0)) - for (const r of state.restarts.slice(1)) r.resolve() - await toB - await toC - expect(state.current).toBe("C") - }) - - it("keeps a newer choice of the same preset", async () => { - // A, then B, C, and B again while the first restart is pending - const first = switchPreset("B").catch(() => {}) - const second = switchPreset("C").catch(() => {}) - const third = switchPreset("B") - // The first restart fails: the current preset is B again, but it is - // the third switch's, which must not be undone - state.restarts[0].reject(new Error("timed out")) - await new Promise((r) => setTimeout(r, 0)) - for (const r of state.restarts.slice(1)) r.resolve() - await first - await second - await third - expect(state.current).toBe("B") - }) - - it("does not bring back the old preset over a deletion", async () => { - const toB = switchPreset("B").catch(() => {}) - // B is deleted while its restart is pending - state.current = null - state.restarts[0].reject(new Error("timed out")) - await toB - expect(state.current).toBeNull() - expect(state.restarts).toHaveLength(1) - }) - - it("still rolls back when a later request named no preset", async () => { - const toB = switchPreset("B").catch(() => {}) - await expect(switchPreset("missing")).rejects.toThrow("not found") - state.restarts[0].reject(new Error("timed out")) - await new Promise((r) => setTimeout(r, 0)) - state.restarts[1]?.resolve() - await toB - expect(state.current).toBe("A") - }) -}) diff --git a/tests/unit/config-manager.test.ts b/tests/unit/config-manager.test.ts deleted file mode 100644 index eaff86bf..00000000 --- a/tests/unit/config-manager.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// @vitest-environment node -import { - existsSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { beforeEach, describe, expect, it, vi } from "vitest" - -const userData = vi.hoisted(() => ({ dir: "" })) -vi.mock("electron", () => ({ - app: { getPath: () => userData.dir }, - safeStorage: { isEncryptionAvailable: () => false }, -})) - -// Make the next read of the presets file fail, like a file an antivirus -// scanner holds on Windows -const readFails = vi.hoisted(() => ({ next: false })) -// Make renaming the presets file fail, as when a sync tool holds it -const renameFails = vi.hoisted(() => ({ on: false })) -vi.mock("node:fs", async (importOriginal) => { - const fs = await importOriginal() - return { - ...fs, - readFileSync: ((...args: Parameters) => { - if (readFails.next && String(args[0]).endsWith(".json")) { - readFails.next = false - throw Object.assign(new Error("EBUSY: resource busy"), { - code: "EBUSY", - }) - } - return fs.readFileSync(...args) - }) as typeof fs.readFileSync, - renameSync: ((...args: Parameters) => { - if (renameFails.on && String(args[0]).endsWith(".json")) { - throw Object.assign( - new Error("EPERM: operation not permitted"), - { - code: "EPERM", - }, - ) - } - return fs.renameSync(...args) - }) as typeof fs.renameSync, - } -}) - -import { createPreset, loadPresets } from "@/electron/main/config-manager" - -const presetsFile = () => join(userData.dir, "config-presets.json") - -beforeEach(() => { - userData.dir = mkdtempSync(join(tmpdir(), "config-manager-")) - readFails.next = false - renameFails.on = false -}) - -describe("config presets file", () => { - it("keeps a file it could not read for now", () => { - createPreset({ name: "Mine", config: { AI_PROVIDER: "openai" } }) - - readFails.next = true - expect(loadPresets().presets).toEqual([]) - expect(existsSync(presetsFile())).toBe(true) - - // A save based on that empty read must not replace the presets - readFails.next = true - expect(() => - createPreset({ name: "New", config: { AI_PROVIDER: "openai" } }), - ).toThrow() - expect(loadPresets().presets.map((p) => p.name)).toEqual(["Mine"]) - }) - - it("saves again once a file it could not read is gone", () => { - createPreset({ name: "Mine", config: { AI_PROVIDER: "openai" } }) - readFails.next = true - loadPresets() - // The user removes the file to start over - rmSync(presetsFile()) - createPreset({ name: "New", config: { AI_PROVIDER: "openai" } }) - expect(loadPresets().presets.map((p) => p.name)).toEqual(["New"]) - }) - - it("keeps a file that is not JSON when it cannot be moved aside", () => { - writeFileSync(presetsFile(), "{not json") - renameFails.on = true - expect(loadPresets().presets).toEqual([]) - // A save based on that empty read must not replace it - expect(() => - createPreset({ name: "New", config: { AI_PROVIDER: "openai" } }), - ).toThrow() - expect(readFileSync(presetsFile(), "utf-8")).toBe("{not json") - }) - - it("moves a file that is not JSON aside", () => { - writeFileSync(presetsFile(), "{not json") - expect(loadPresets().presets).toEqual([]) - expect(existsSync(presetsFile())).toBe(false) - expect( - readdirSync(userData.dir).some((f) => - f.startsWith("config-presets.json.corrupt-"), - ), - ).toBe(true) - }) -}) diff --git a/tests/unit/env-loader.test.ts b/tests/unit/env-loader.test.ts deleted file mode 100644 index 3bdf06e7..00000000 --- a/tests/unit/env-loader.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// @vitest-environment node -import { mkdtempSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { afterEach, describe, expect, it, vi } from "vitest" - -const dir = vi.hoisted(() => ({ path: "" })) -vi.mock("electron", () => ({ - app: { - getPath: (name: string) => - name === "exe" ? `${dir.path}/app/exe` : dir.path, - getAppPath: () => `${dir.path}/app`, - }, -})) - -import { loadEnvFile } from "@/electron/main/env-loader" - -const KEYS = [ - "T_JSON", - "T_COMMENT", - "T_PLAIN", - "T_DOUBLE", - "T_QUOTED_COMMENT", - "T_KEY_COMMENT", - "T_HASH", - "T_AFTER", - "T_JOINED", - "T_ESC_HASH", - "T_ESC_INNER", - "T_ESC_COMMENT", - "T_DIR", - "T_DIR_COMMENT", -] -afterEach(() => { - for (const k of KEYS) delete process.env[k] -}) - -describe("loadEnvFile", () => { - it("reads quoted values like dotenv", () => { - dir.path = mkdtempSync(join(tmpdir(), "env-loader-")) - writeFileSync( - join(dir.path, ".env"), - [ - // An apostrophe inside a single-quoted JSON value - `T_JSON='{"name":"Team's models"}'`, - `T_COMMENT="value" # a comment`, - "T_PLAIN=plain # a comment", - `T_DOUBLE="say "hi""`, - ].join("\n"), - ) - loadEnvFile() - expect(process.env.T_JSON).toBe(`{"name":"Team's models"}`) - expect(process.env.T_COMMENT).toBe("value") - expect(process.env.T_PLAIN).toBe("plain") - expect(process.env.T_DOUBLE).toBe(`say "hi"`) - }) - - it("drops a comment that ends with a quote, like dotenv", () => { - // Expected values checked against dotenv 16.6.1's parse - dir.path = mkdtempSync(join(tmpdir(), "env-loader-")) - writeFileSync( - join(dir.path, ".env"), - [ - `T_QUOTED_COMMENT="gpt-5" # pick "fast"`, - `T_KEY_COMMENT="sk-abc" # from "Team A"`, - `T_AFTER='a' b`, - `T_JOINED="a"b`, - // Unquoted: a # without a space before it stays in the value - // (dotenv would cut it; this loader never did) - "T_HASH=http://host/#/x", - ].join("\n"), - ) - loadEnvFile() - expect(process.env.T_QUOTED_COMMENT).toBe("gpt-5") - expect(process.env.T_KEY_COMMENT).toBe("sk-abc") - expect(process.env.T_AFTER).toBe(`'a' b`) - expect(process.env.T_JOINED).toBe(`"a"b`) - expect(process.env.T_HASH).toBe("http://host/#/x") - }) - - it("does not end a quoted value at an escaped quote, like dotenv", () => { - dir.path = mkdtempSync(join(tmpdir(), "env-loader-")) - writeFileSync( - join(dir.path, ".env"), - [ - 'T_ESC_HASH="abc\\" #def"', - 'T_ESC_INNER="a # \\"b\\""', - 'T_ESC_COMMENT="x\\"y" # c', - ].join("\n"), - ) - loadEnvFile() - // Expected values from dotenv 16.6.1, which keeps the backslashes - expect(process.env.T_ESC_HASH).toBe('abc\\" #def') - expect(process.env.T_ESC_INNER).toBe('a # \\"b\\"') - expect(process.env.T_ESC_COMMENT).toBe('x\\"y') - }) - - it("keeps a backslash before the closing quote, like dotenv", () => { - dir.path = mkdtempSync(join(tmpdir(), "env-loader-")) - writeFileSync( - join(dir.path, ".env"), - ['T_DIR="C:\\dir\\"', 'T_DIR_COMMENT="C:\\data\\" # dir'].join( - "\n", - ), - ) - loadEnvFile() - // Windows folders; dotenv 16.6.1 reads them the same - expect(process.env.T_DIR).toBe("C:\\dir\\") - expect(process.env.T_DIR_COMMENT).toBe("C:\\data\\") - }) -}) diff --git a/vitest.config.mts b/vitest.config.mts index 790e1e80..f454801c 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,11 +7,5 @@ export default defineConfig({ test: { environment: "jsdom", include: ["tests/**/*.test.{ts,tsx}"], - coverage: { - provider: "v8", - reporter: ["text", "json", "html"], - include: ["lib/**/*.ts", "app/**/*.ts", "app/**/*.tsx"], - exclude: ["**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"], - }, }, })