mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-04 00:37:48 +08:00
- Apply preset env after app ready, so Windows/Linux get decrypted keys - Never re-encrypt ciphertext; restore env when switching or removing presets - Block navigation away from the app, open external links in the browser, check IPC senders - Keep inherited proxy settings, default NO_PROXY for localhost - Serialize server start/restart, kill stuck processes, follow port changes - Atomic config writes, keep corrupt files as backups, remember the server port - Menu and settings window stay in sync; dev script gets the decrypted preset env - Use app.isPackaged, parse inline .env comments, drop .env files from the bundle
326 lines
9.5 KiB
TypeScript
326 lines
9.5 KiB
TypeScript
import {
|
|
app,
|
|
BrowserWindow,
|
|
dialog,
|
|
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 { restartNextServer } from "./next-server"
|
|
import {
|
|
applyProxyToEnv,
|
|
getProxyConfig,
|
|
type ProxyConfig,
|
|
saveProxyConfig,
|
|
} from "./proxy-manager"
|
|
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<string, string | undefined>,
|
|
): Record<string, string | undefined> {
|
|
const sanitized: Record<string, string | undefined> = {}
|
|
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.
|
|
*/
|
|
function handle<Args extends unknown[]>(
|
|
channel: string,
|
|
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
|
|
): void {
|
|
ipcMain.handle(channel, (event, ...args) => {
|
|
const url = event.senderFrame?.url
|
|
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
|
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
|
}
|
|
return listener(event, ...(args as Args))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Register all IPC handlers
|
|
*/
|
|
export function registerIpcHandlers(): void {
|
|
// ==================== App Info ====================
|
|
|
|
handle("get-version", () => {
|
|
return app.getVersion()
|
|
})
|
|
|
|
// ==================== 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()
|
|
})
|
|
|
|
// ==================== File Dialogs ====================
|
|
|
|
handle("dialog-open-file", async (event) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender)
|
|
if (!win) return null
|
|
|
|
const result = await dialog.showOpenDialog(win, {
|
|
properties: ["openFile"],
|
|
filters: [
|
|
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
|
|
{ name: "All Files", extensions: ["*"] },
|
|
],
|
|
})
|
|
|
|
if (result.canceled || result.filePaths.length === 0) {
|
|
return null
|
|
}
|
|
|
|
// Read the file content
|
|
const fs = await import("node:fs/promises")
|
|
try {
|
|
const content = await fs.readFile(result.filePaths[0], "utf-8")
|
|
return content
|
|
} catch (error) {
|
|
console.error("Failed to read file:", error)
|
|
return null
|
|
}
|
|
})
|
|
|
|
handle("dialog-save-file", async (event, data: string) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender)
|
|
if (!win || typeof data !== "string") return false
|
|
|
|
const result = await dialog.showSaveDialog(win, {
|
|
filters: [
|
|
{ name: "Draw.io Files", extensions: ["drawio"] },
|
|
{ name: "XML Files", extensions: ["xml"] },
|
|
],
|
|
})
|
|
|
|
if (result.canceled || !result.filePath) {
|
|
return false
|
|
}
|
|
|
|
const fs = await import("node:fs/promises")
|
|
try {
|
|
await fs.writeFile(result.filePath, data, "utf-8")
|
|
return true
|
|
} catch (error) {
|
|
console.error("Failed to save file:", error)
|
|
return false
|
|
}
|
|
})
|
|
|
|
// ==================== 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<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
|
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", () => {
|
|
return getProxyConfig()
|
|
})
|
|
|
|
handle("set-proxy", async (_event, config: ProxyConfig) => {
|
|
const isOptionalString = (value: unknown) =>
|
|
value === undefined || typeof value === "string"
|
|
if (
|
|
typeof config !== "object" ||
|
|
config === null ||
|
|
!isOptionalString(config.httpProxy) ||
|
|
!isOptionalString(config.httpsProxy)
|
|
) {
|
|
return { success: false, error: "Invalid proxy settings" }
|
|
}
|
|
|
|
try {
|
|
// Save config to file
|
|
saveProxyConfig({
|
|
httpProxy: config.httpProxy,
|
|
httpsProxy: config.httpsProxy,
|
|
})
|
|
|
|
// Apply to current process environment
|
|
applyProxyToEnv()
|
|
|
|
if (!app.isPackaged) {
|
|
// In development, env vars are already applied
|
|
// Next.js dev server may need manual restart
|
|
return { success: true, devMode: true }
|
|
}
|
|
|
|
// Production: restart Next.js server to pick up new env vars
|
|
await restartNextServer()
|
|
return { success: true }
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to apply proxy settings",
|
|
}
|
|
}
|
|
})
|
|
|
|
// ==================== 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)) {
|
|
return { success: false, error: "Invalid locale" }
|
|
}
|
|
|
|
try {
|
|
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
|
// Rebuild the menu to reflect the new locale
|
|
rebuildAppMenu()
|
|
return { success: true }
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to set locale",
|
|
}
|
|
}
|
|
})
|
|
}
|