mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
* feat(electron): add desktop application support with electron - implement complete Electron main process architecture with window management, app menu, IPC handlers, and settings window - integrate Next.js server for production builds with embedded standalone server - add configuration management with persistent storage and env file support - create preload scripts with secure context bridge for renderer communication - set up electron-builder configuration for multi-platform packaging (macOS, Windows, Linux) - add GitHub Actions workflow for automated release builds - include development scripts for hot-reload during Electron development * feat(electron): enhance security and stability - encrypt API keys using Electron safeStorage API before persisting to disk - add error handling and rollback for preset switching failures - extract inline styles to external CSS file and remove unsafe-inline from CSP - implement dynamic port allocation with automatic fallback for production builds * fix(electron): add maintainer field for Linux .deb package - add maintainer email to linux configuration in electron-builder.yml - required for building .deb packages * fix(electron): use shx for cross-platform file copying - replace Unix-only cp -r with npx shx cp -r - add shx as devDependency for Windows compatibility * fix(electron): fix runtime icon path for all platforms - use icon.png directly instead of platform-specific formats - electron-builder handles icon conversion during packaging - macOS uses embedded icon from app bundle, no explicit path needed - add icon.png to extraResources for Windows/Linux runtime access * fix(electron): add security warning for plaintext API key storage - warn user when safeStorage is unavailable (Linux without keyring) - fail secure: throw error if encryption fails instead of storing plaintext - prevent duplicate warnings with hasWarnedAboutPlaintext flag * fix(electron): add remaining review fixes - Add Windows ARM64 architecture support - Add IPC input validation with config key whitelist - Add server.js existence check before starting Next.js server - Make afterPack throw error on missing directories - Add workflow permissions for release job --------- Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
461 lines
12 KiB
TypeScript
461 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto"
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
import path from "node:path"
|
|
import { app, safeStorage } from "electron"
|
|
|
|
/**
|
|
* Fields that contain sensitive data and should be encrypted
|
|
*/
|
|
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
|
|
|
|
/**
|
|
* Prefix to identify encrypted values
|
|
*/
|
|
const ENCRYPTED_PREFIX = "encrypted:"
|
|
|
|
/**
|
|
* Check if safeStorage encryption is available
|
|
*/
|
|
function isEncryptionAvailable(): boolean {
|
|
return safeStorage.isEncryptionAvailable()
|
|
}
|
|
|
|
/**
|
|
* Track if we've already warned about plaintext storage
|
|
*/
|
|
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 {
|
|
if (!value) {
|
|
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
|
|
}
|
|
|
|
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
|
|
*/
|
|
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<string, string | undefined>,
|
|
): Record<string, string | undefined> {
|
|
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<string, string | undefined>,
|
|
): Record<string, string | undefined> {
|
|
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[]
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Load presets from the config file
|
|
* Decrypts sensitive fields automatically
|
|
*/
|
|
export function loadPresets(): ConfigPresetsFile {
|
|
const configPath = getConfigFilePath()
|
|
|
|
if (!existsSync(configPath)) {
|
|
return {
|
|
version: 1,
|
|
currentPresetId: null,
|
|
presets: [],
|
|
}
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(configPath, "utf-8")
|
|
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)
|
|
return {
|
|
version: 1,
|
|
currentPresetId: null,
|
|
presets: [],
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save presets to the config file
|
|
* Encrypts sensitive fields automatically
|
|
*/
|
|
export function savePresets(data: ConfigPresetsFile): void {
|
|
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 {
|
|
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
|
} 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, "id" | "createdAt" | "updatedAt">,
|
|
): 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<Omit<ConfigPreset, "id" | "createdAt">>,
|
|
): 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 if it was deleted
|
|
if (data.currentPresetId === id) {
|
|
data.currentPresetId = null
|
|
}
|
|
|
|
savePresets(data)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Set the current preset
|
|
*/
|
|
export function setCurrentPreset(id: string | null): boolean {
|
|
const data = loadPresets()
|
|
|
|
if (id !== null) {
|
|
const preset = data.presets.find((p) => p.id === id)
|
|
if (!preset) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
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<string, { apiKey: string; baseUrl: string }> = {
|
|
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",
|
|
},
|
|
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
|
// bedrock and ollama don't use API keys in the same way
|
|
bedrock: { apiKey: "", baseUrl: "" },
|
|
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
|
}
|
|
|
|
/**
|
|
* Apply preset environment variables to the current process
|
|
* Returns the environment variables that were applied
|
|
*/
|
|
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
|
const data = loadPresets()
|
|
const preset = data.presets.find((p) => p.id === id)
|
|
|
|
if (!preset) {
|
|
return null
|
|
}
|
|
|
|
const appliedEnv: Record<string, string> = {}
|
|
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
|
|
|
for (const [key, value] of Object.entries(preset.config)) {
|
|
if (value !== undefined && value !== "") {
|
|
// Map generic AI_API_KEY to provider-specific key
|
|
if (
|
|
key === "AI_API_KEY" &&
|
|
provider &&
|
|
PROVIDER_ENV_MAP[provider]
|
|
) {
|
|
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
|
if (providerApiKey) {
|
|
process.env[providerApiKey] = value
|
|
appliedEnv[providerApiKey] = value
|
|
}
|
|
}
|
|
// Map generic AI_BASE_URL to provider-specific key
|
|
else if (
|
|
key === "AI_BASE_URL" &&
|
|
provider &&
|
|
PROVIDER_ENV_MAP[provider]
|
|
) {
|
|
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
|
if (providerBaseUrl) {
|
|
process.env[providerBaseUrl] = value
|
|
appliedEnv[providerBaseUrl] = value
|
|
}
|
|
}
|
|
// Apply other env vars directly
|
|
else {
|
|
process.env[key] = value
|
|
appliedEnv[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set as current preset
|
|
data.currentPresetId = id
|
|
savePresets(data)
|
|
|
|
return appliedEnv
|
|
}
|
|
|
|
/**
|
|
* Get environment variables from current preset
|
|
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
|
*/
|
|
export function getCurrentPresetEnv(): Record<string, string> {
|
|
const preset = getCurrentPreset()
|
|
if (!preset) {
|
|
return {}
|
|
}
|
|
|
|
const env: Record<string, string> = {}
|
|
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
|
|
|
for (const [key, value] of Object.entries(preset.config)) {
|
|
if (value !== undefined && value !== "") {
|
|
// Map generic AI_API_KEY to provider-specific key
|
|
if (
|
|
key === "AI_API_KEY" &&
|
|
provider &&
|
|
PROVIDER_ENV_MAP[provider]
|
|
) {
|
|
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
|
if (providerApiKey) {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
return env
|
|
}
|