import { app, BrowserWindow, shell, ipcMain, dialog, Menu, nativeImage } from 'electron' import { join, resolve } from 'path' import { ChildProcess, spawn } from 'child_process' import { existsSync, mkdirSync } from 'fs' import http from 'http' import https from 'https' import net from 'net' import { autoUpdater } from 'electron-updater' import type { UpdateInfo, ProgressInfo } from 'electron-updater' import { loadConfig, saveConfig, normalizeServerUrl, recordServer, type ConnectionMode, } from './config' import { loadLocalToolsConfig, saveLocalToolsConfig, expandPath, type LocalToolsConfig, } from './localToolsConfig' import { LocalBridge } from './localBridge' // ─── Constants ─────────────────────────────────────────────────────────────── let BACKEND_PORT = 0 let BACKEND_URL = '' const HEALTH_CHECK_INTERVAL = 1000 // ms const HEALTH_CHECK_TIMEOUT = 120_000 // 2 minutes max wait const WINDOW_WIDTH = 1280 const WINDOW_HEIGHT = 860 // ─── State ─────────────────────────────────────────────────────────────────── let mainWindow: BrowserWindow | null = null let javaProcess: ChildProcess | null = null let isQuitting = false let isUpdating = false let backendReady = false // Local-tool tunnel: lets a remote agent operate this machine's files/shell // through an authenticated WebSocket back to the backend. The JWT is read from // the renderer's localStorage (where the admin SPA stores it after login). async function readRendererToken(): Promise { if (!mainWindow || mainWindow.isDestroyed()) return null try { const token = await mainWindow.webContents.executeJavaScript( 'window.localStorage && window.localStorage.getItem("token")' ) return typeof token === 'string' && token.length > 0 ? token : null } catch { return null } } const localBridge = new LocalBridge(() => BACKEND_URL, readRendererToken) // Connection state: which backend the shell is talking to. let connectionMode: ConnectionMode | null = null // When true, the splash shows the connection chooser even if a mode was saved // (used by the "Switch Server" menu action). let forceChooser = false // Hosts whose TLS certificate the user explicitly trusted this session (covers // enterprise self-signed certificates on remote servers). const trustedCertHosts = new Set() // Reachability probes (health poll / test button) must not hard-fail on an // untrusted certificate — that only signals reachability. The real trust // decision still happens at BrowserWindow navigation via the certificate-error // handler, which prompts the user before loading the page. const insecureAgent = new https.Agent({ rejectUnauthorized: false }) // ─── Build Mode Detection ──────────────────────────────────────────────────── /** * The desktop app ships in two variants: * * "local" — bundles the JRE and Spring Boot JAR in extraResources so the * app can run an embedded backend. This is the traditional full * build. * * "remote" — omits the JRE/JAR (~530 MB lighter). The app can only connect * to a remote server. The "local" option is hidden in the splash * connection chooser. * * Detection is done at runtime by checking whether the JAR exists in the * resources directory. This avoids any build-time code injection — the same * main-process code runs in both builds; the only difference is whether the * JAR/JRE files are present. */ function detectBuildMode(): 'local' | 'remote' { const jarPath = getJarPath() return existsSync(jarPath) ? 'local' : 'remote' } const BUILD_MODE = detectBuildMode() interface UpdaterState { status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' version?: string releaseNotes?: string progress?: { percent: number; bytesPerSecond: number; transferred: number; total: number } error?: string } let updaterState: UpdaterState = { status: 'idle' } // ─── Platform Detection & Resource Paths ───────────────────────────────────── function getResourcesPath(): string { // In production: process.resourcesPath points to /Contents/Resources (macOS) or /resources (Windows) // In dev: use the local resources/ directory if (app.isPackaged) { return process.resourcesPath } return resolve(__dirname, '../../resources') } function getJavaExecutable(): string { const resourcesPath = getResourcesPath() const platform = process.platform // In production: extraResources copies jre//* → Resources/jre/ // In dev: jre is at resources/jre// const jrePath = join(resourcesPath, 'jre') // Candidate paths for java binary (try all known layouts) const candidates: string[] = [] if (platform === 'darwin') { // Packaged: jre/Contents/Home/bin/java candidates.push(join(jrePath, 'Contents', 'Home', 'bin', 'java')) // Dev: jre/mac-arm64/Contents/Home/bin/java candidates.push(join(jrePath, 'mac-arm64', 'Contents', 'Home', 'bin', 'java')) candidates.push(join(jrePath, 'mac-x64', 'Contents', 'Home', 'bin', 'java')) // Fallback: flat layout candidates.push(join(jrePath, 'bin', 'java')) } else if (platform === 'win32') { candidates.push(join(jrePath, 'bin', 'java.exe')) candidates.push(join(jrePath, 'win32-x64', 'bin', 'java.exe')) } else { candidates.push(join(jrePath, 'bin', 'java')) candidates.push(join(jrePath, 'linux-x64', 'bin', 'java')) candidates.push(join(jrePath, 'linux-arm64', 'bin', 'java')) } for (const candidate of candidates) { if (existsSync(candidate)) return candidate } // Return first candidate for error reporting return candidates[0] } function getJarPath(): string { const resourcesPath = getResourcesPath() return join(resourcesPath, 'app.jar') } function getUserDataPath(): string { const dataPath = join(app.getPath('userData'), 'data') if (!existsSync(dataPath)) { mkdirSync(dataPath, { recursive: true }) } return app.getPath('userData') } // ─── Java Backend Lifecycle ────────────────────────────────────────────────── function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer() server.listen(0, '127.0.0.1', () => { const { port } = server.address() as net.AddressInfo server.close(() => resolve(port)) }) server.on('error', reject) }) } async function startJavaBackend(): Promise { // A tunnel pinned to a prior backend URL must be dropped before the embedded // server comes up on a fresh port; it reconnects once the backend is ready. localBridge.stop() // Remote builds have no bundled JRE/JAR — refuse to start the local backend // and guide the user toward the connection chooser instead of showing a // generic "file not found" error. if (BUILD_MODE === 'remote') { console.log('[MateClaw] Remote build — local backend unavailable, showing connection chooser') sendToWindow('backend:status', 'choose') return } BACKEND_PORT = await getAvailablePort() BACKEND_URL = `http://localhost:${BACKEND_PORT}` console.log(`[MateClaw] Using dynamic port: ${BACKEND_PORT}`) const javaExec = getJavaExecutable() const jarPath = getJarPath() const workingDir = getUserDataPath() console.log(`[MateClaw] Java executable: ${javaExec}`) console.log(`[MateClaw] JAR path: ${jarPath}`) console.log(`[MateClaw] Working directory: ${workingDir}`) if (!existsSync(javaExec)) { console.error(`[MateClaw] Java executable not found: ${javaExec}`) dialog.showErrorBox( 'MateClaw 启动失败', `找不到 Java 运行时环境。\n路径: ${javaExec}\n\n请重新安装 MateClaw。` ) app.quit() return } if (!existsSync(jarPath)) { console.error(`[MateClaw] JAR not found: ${jarPath}`) dialog.showErrorBox( 'MateClaw 启动失败', `找不到应用程序包。\n路径: ${jarPath}\n\n请重新安装 MateClaw。` ) app.quit() return } // Prepare environment variables — inherit current env + override const env = { ...process.env, // Ensure H2 database is stored in userData SPRING_DATASOURCE_URL: `jdbc:h2:file:${join(workingDir, 'data', 'mateclaw')};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE`, } // Spawn Java process javaProcess = spawn(javaExec, [ '-jar', jarPath, `--server.port=${BACKEND_PORT}`, '--mateclaw.setup.await-language-selection=true', ], { cwd: workingDir, env, stdio: ['ignore', 'pipe', 'pipe'], }) javaProcess.stdout?.on('data', (data: Buffer) => { const line = data.toString().trim() if (line) console.log(`[Java] ${line}`) }) javaProcess.stderr?.on('data', (data: Buffer) => { const line = data.toString().trim() if (line) console.error(`[Java:ERR] ${line}`) }) javaProcess.on('error', (err: Error) => { console.error('[MateClaw] Failed to start Java process:', err) sendToWindow('backend:crashed', `Java 进程启动失败: ${err.message}`) }) javaProcess.on('exit', (code: number | null, signal: string | null) => { console.log(`[MateClaw] Java process exited: code=${code}, signal=${signal}`) javaProcess = null if (!isQuitting) { sendToWindow('backend:crashed', `Java 进程意外退出 (code: ${code})`) } }) // Start health check polling pollBackendReady() } function pollBackendReady(): void { const startTime = Date.now() sendToWindow('backend:status', 'starting') let resolved = false const check = () => { if (isQuitting || resolved) return const elapsed = Date.now() - startTime // Remote connections fail fast (server should already be up); the embedded // JVM gets the full window to boot. const timeout = connectionMode === 'remote' ? 15_000 : HEALTH_CHECK_TIMEOUT if (elapsed > timeout) { console.error('[MateClaw] Backend health check timed out') sendToWindow('backend:status', 'timeout') if (connectionMode !== 'remote') { dialog.showErrorBox( 'MateClaw 启动超时', '后端服务启动超时,请检查日志或重启应用。' ) } return } const isHttps = BACKEND_URL.startsWith('https:') const client = isHttps ? https : http const reqOpts = isHttps ? { agent: insecureAgent } : {} const req = client.get(`${BACKEND_URL}/`, reqOpts, (res) => { if (resolved) return resolved = true // Consume response data to free up the socket res.resume() backendReady = true console.log(`[MateClaw] Backend ready (${elapsed}ms, status: ${res.statusCode})`) sendToWindow('backend:status', 'ready') // Bring up the local-tool tunnel. It waits for a renderer JWT (post-login) // and reconnects on its own, so starting it here is safe even pre-login. if (loadLocalToolsConfig().enabled) { localBridge.start() } // Do NOT auto-navigate — let the splash screen handle it // after language selection / setup check completes. }) req.on('error', () => { if (resolved) return // Server not ready yet, retry setTimeout(check, HEALTH_CHECK_INTERVAL) }) req.setTimeout(3000, () => { req.destroy() if (resolved) return setTimeout(check, HEALTH_CHECK_INTERVAL) }) } check() } async function stopJavaBackend(): Promise { if (!javaProcess) return console.log('[MateClaw] Stopping Java backend...') return new Promise((resolve) => { const timeout = setTimeout(() => { console.log('[MateClaw] Force killing Java process') javaProcess?.kill('SIGKILL') resolve() }, 10_000) // 10s grace period javaProcess!.on('exit', () => { clearTimeout(timeout) console.log('[MateClaw] Java process stopped') resolve() }) // Try graceful shutdown first if (process.platform === 'win32') { // On Windows, spawn taskkill for graceful stop spawn('taskkill', ['/pid', String(javaProcess!.pid), '/t']) } else { javaProcess!.kill('SIGTERM') } }) } // ─── Connection Orchestration ──────────────────────────────────────────────── // Decide how to reach the backend on launch based on saved configuration. async function bootConnection(): Promise { if (forceChooser) { sendToWindow('backend:status', 'choose') return } const cfg = loadConfig() // Remote builds cannot start a local backend. If the user previously // saved 'local' mode (e.g. they upgraded from a full build), ignore the // stale preference and fall through to the connection chooser. if (BUILD_MODE === 'remote' && cfg.mode === 'local') { connectionMode = null sendToWindow('backend:status', 'choose') return } if (cfg.mode === 'local') { connectionMode = 'local' await startJavaBackend() } else if (cfg.mode === 'remote' && cfg.remoteUrl) { startRemoteConnection(cfg.remoteUrl) } else { // First run: the renderer queries getConnectionConfig() and shows the chooser. connectionMode = null sendToWindow('backend:status', 'choose') } } // Point the shell at a remote server and start health polling against it. function startRemoteConnection(url: string): void { const normalized = normalizeServerUrl(url) if (!normalized) { sendToWindow('backend:crashed', `无效的服务器地址: ${url}`) return } connectionMode = 'remote' backendReady = false // Drop any tunnel pinned to the previous backend; it is re-established against // the new URL once the backend reports ready. localBridge.stop() BACKEND_URL = normalized console.log(`[MateClaw] Remote mode → ${BACKEND_URL}`) pollBackendReady() } // Probe an arbitrary server root with a short timeout. Used by the connection // chooser's "Test" button before the user commits to a server. function probeServer( url: string, timeoutMs = 6000 ): Promise<{ ok: boolean; status?: number; error?: string }> { return new Promise((resolve) => { const normalized = normalizeServerUrl(url) if (!normalized) { resolve({ ok: false, error: 'invalid-url' }) return } const isHttps = normalized.startsWith('https:') const client = isHttps ? https : http const reqOpts = isHttps ? { agent: insecureAgent } : {} const req = client.get(`${normalized}/`, reqOpts, (res) => { res.resume() const status = res.statusCode ?? 0 // Any non-5xx response means the server is reachable and serving. resolve({ ok: status > 0 && status < 500, status }) }) req.on('error', (err) => resolve({ ok: false, error: err.message })) req.setTimeout(timeoutMs, () => { req.destroy() resolve({ ok: false, error: 'timeout' }) }) }) } // Reload the splash and force the connection chooser (menu "Switch Server"). function goToConnectionChooser(): void { forceChooser = true backendReady = false localBridge.stop() loadSplash() } function loadSplash(): void { if (!mainWindow || mainWindow.isDestroyed()) return if (process.env.VITE_DEV_SERVER_URL) { mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL) } else { mainWindow.loadFile(join(__dirname, '../../dist/index.html')) } } // ─── Window Management ─────────────────────────────────────────────────────── function createWindow(): void { const preloadPath = join(__dirname, '../preload/index.js') mainWindow = new BrowserWindow({ width: WINDOW_WIDTH, height: WINDOW_HEIGHT, minWidth: 900, minHeight: 600, title: 'MateClaw', icon: join(__dirname, '../../build/icon.png'), webPreferences: { preload: preloadPath, nodeIntegration: false, contextIsolation: true, webSecurity: true, }, show: false, backgroundColor: '#f5f5f5', }) // Show when ready to prevent visual flash mainWindow.once('ready-to-show', () => { mainWindow?.show() }) // Open DevTools in dev mode for debugging if (!app.isPackaged) { mainWindow.webContents.openDevTools({ mode: 'detach' }) } // Log renderer console messages to main process mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => { const levelStr = ['DEBUG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG' console.log(`[Renderer:${levelStr}] ${message} (${sourceId}:${line})`) }) // Load splash screen first loadSplash() // Open external links in system browser, but allow WeCom auth popup in-app mainWindow.webContents.setWindowOpenHandler(({ url }) => { // WeCom SDK needs in-app popup for postMessage auth callback if (url.includes('work.weixin.qq.com')) { return { action: 'allow', overrideBrowserWindowOptions: { width: 500, height: 620, title: '企业微信授权', webPreferences: { nodeIntegration: false, contextIsolation: true, }, }, } } if (url.startsWith('http')) { shell.openExternal(url) } return { action: 'deny' } }) mainWindow.on('closed', () => { mainWindow = null }) } function sendToWindow(channel: string, data: unknown): void { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, data) } } // ─── Auto Updater ─────────────────────────────────────────────────────────── function setupAutoUpdater(): void { if (!app.isPackaged) { // In dev mode, electron-updater can still work if dev-app-update.yml exists // at the project root. It overrides the publish config from electron-builder.json. const devUpdateConfig = resolve(__dirname, '../../dev-app-update.yml') if (!existsSync(devUpdateConfig)) { console.log('[MateClaw] Skipping auto-updater in dev mode (no dev-app-update.yml)') return } console.log('[MateClaw] Dev mode: using dev-app-update.yml for updater') autoUpdater.forceDevUpdateConfig = true } autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = false autoUpdater.on('checking-for-update', () => { updaterState = { status: 'checking' } sendToWindow('updater:state', updaterState) console.log('[MateClaw] Checking for update...') }) autoUpdater.on('update-available', (info: UpdateInfo) => { updaterState = { status: 'available', version: info.version, releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, } sendToWindow('updater:state', updaterState) console.log(`[MateClaw] Update available: ${info.version}`) }) autoUpdater.on('update-not-available', (info: UpdateInfo) => { updaterState = { status: 'not-available', version: info.version } sendToWindow('updater:state', updaterState) console.log('[MateClaw] No update available') }) autoUpdater.on('download-progress', (progress: ProgressInfo) => { updaterState = { ...updaterState, status: 'downloading', progress: { percent: progress.percent, bytesPerSecond: progress.bytesPerSecond, transferred: progress.transferred, total: progress.total, }, } sendToWindow('updater:state', updaterState) }) autoUpdater.on('update-downloaded', (info: UpdateInfo) => { updaterState = { status: 'downloaded', version: info.version } sendToWindow('updater:state', updaterState) console.log(`[MateClaw] Update downloaded: ${info.version}`) }) autoUpdater.on('error', (err: Error) => { updaterState = { status: 'error', error: err.message } sendToWindow('updater:state', updaterState) console.error('[MateClaw] Auto-updater error:', err.message) setTimeout(() => { if (updaterState.status === 'error') { updaterState = { status: 'idle' } sendToWindow('updater:state', updaterState) } }, 10_000) }) // Check for updates after a short delay to avoid blocking startup setTimeout(() => { autoUpdater.checkForUpdates().catch((err) => { console.error('[MateClaw] Update check failed:', err.message) }) }, 3000) } // ─── IPC Handlers ──────────────────────────────────────────────────────────── function registerIpcHandlers(): void { ipcMain.handle('app:get-platform', () => process.platform) ipcMain.handle('app:get-version', () => app.getVersion()) // Let the renderer know whether this is a local (full) or remote (lite) build // so the splash can hide the "local" connection option in remote builds. ipcMain.handle('app:get-build-mode', () => BUILD_MODE) ipcMain.handle('app:get-backend-url', () => BACKEND_URL) ipcMain.handle('app:is-backend-ready', () => backendReady) ipcMain.handle('app:open-external', (_event, url: string) => { shell.openExternal(url) }) ipcMain.handle('app:get-user-data-path', () => app.getPath('userData')) ipcMain.handle('app:restart-backend', async () => { backendReady = false sendToWindow('backend:status', 'restarting') if (connectionMode === 'remote') { // Nothing to restart locally — just re-probe the remote server. startRemoteConnection(BACKEND_URL) return } await stopJavaBackend() await startJavaBackend() }) // ── Connection management IPC ── ipcMain.handle('connection:get-config', () => { const cfg = loadConfig() return { mode: cfg.mode, remoteUrl: cfg.remoteUrl, servers: cfg.servers, // The renderer shows the chooser on first run or when "Switch Server" forced it. forceChoose: forceChooser, // Tell the renderer which build variant is running so it can hide the // "local" option in remote (lite) builds. buildMode: BUILD_MODE, } }) ipcMain.handle('connection:test', async (_event, url: string) => { return probeServer(url) }) ipcMain.handle('connection:use-local', async () => { // Remote builds have no bundled JRE/JAR — reject the local mode request. if (BUILD_MODE === 'remote') { sendToWindow('backend:crashed', '此版本为轻量版(Remote),不支持本地内嵌后端。请选择连接远程服务器。') return } forceChooser = false saveConfig({ mode: 'local' }) connectionMode = 'local' backendReady = false if (!javaProcess) { await startJavaBackend() } else { // Already running (e.g. switched away and back) — just re-check health. pollBackendReady() } }) ipcMain.handle('connection:use-remote', async (_event, url: string) => { const normalized = normalizeServerUrl(url) if (!normalized) return { ok: false, error: 'invalid-url' } forceChooser = false // A local JVM is pointless in remote mode — free its resources. if (javaProcess) { await stopJavaBackend() } saveConfig({ mode: 'remote', remoteUrl: normalized }) recordServer(normalized) startRemoteConnection(normalized) return { ok: true } }) ipcMain.handle('connection:switch-server', () => { goToConnectionChooser() }) ipcMain.handle('app:navigate-to-app', () => { if (mainWindow && !mainWindow.isDestroyed()) { console.log('[MateClaw] Navigating to main application') mainWindow.loadURL(BACKEND_URL) } }) // ── Local tools IPC ── ipcMain.handle('localtools:get-config', () => ({ ...loadLocalToolsConfig(), connected: localBridge.isConnected(), })) ipcMain.handle('localtools:set-config', (_event, patch: Partial) => { const saved = saveLocalToolsConfig(patch) // Honor an enable/disable toggle immediately. if (saved.enabled && backendReady) { localBridge.start() } else if (!saved.enabled) { localBridge.stop() } return saved }) ipcMain.handle('localtools:add-dir', async () => { const dir = await pickAllowedDirectory() return { ...loadLocalToolsConfig(), added: dir } }) ipcMain.handle('localtools:remove-dir', (_event, dir: string) => { const cfg = loadLocalToolsConfig() return saveLocalToolsConfig({ allowedDirs: cfg.allowedDirs.filter((d) => d !== dir), }) }) // ── Auto Updater IPC ── ipcMain.handle('updater:get-state', () => updaterState) ipcMain.handle('updater:check', async () => { if (!app.isPackaged) return { status: 'not-available' } as UpdaterState try { await autoUpdater.checkForUpdates() } catch (err: any) { console.error('[MateClaw] Manual update check failed:', err.message) } return updaterState }) ipcMain.handle('updater:download', async () => { if (updaterState.status !== 'available') return try { await autoUpdater.downloadUpdate() } catch (err: any) { console.error('[MateClaw] Download failed:', err.message) } }) ipcMain.handle('updater:install', async () => { if (updaterState.status !== 'downloaded') return console.log('[MateClaw] Installing update, stopping backend first...') isUpdating = true try { await stopJavaBackend() } catch (err) { console.error('[MateClaw] Error stopping backend before update:', err) } autoUpdater.quitAndInstall(false, true) }) } // ─── App Lifecycle ─────────────────────────────────────────────────────────── // Prevent multiple instances const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { app.quit() } else { app.on('second-instance', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.focus() } }) } // ─── Application Menu ──────────────────────────────────────────────────────── function showAboutDialog(): void { const iconPath = join(__dirname, '../../build/icon.png') const icon = existsSync(iconPath) ? nativeImage.createFromPath(iconPath) : undefined dialog.showMessageBox({ type: 'info', title: 'About MateClaw', message: 'MateClaw', detail: [ `Version: ${app.getVersion()}`, '', 'Your intelligent AI assistant powered by Spring AI Alibaba.', '', `Copyright © 2026 MateClaw Team`, ].join('\n'), buttons: ['OK'], icon, }) } // Add a directory to the local-tools whitelist via a native folder picker. // Returns the added path, or null if the user cancelled. async function pickAllowedDirectory(): Promise { const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined const result = parent ? await dialog.showOpenDialog(parent, { properties: ['openDirectory', 'createDirectory'] }) : await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] }) if (result.canceled || result.filePaths.length === 0) return null const dir = result.filePaths[0] const cfg = loadLocalToolsConfig() if (!cfg.allowedDirs.includes(dir)) { saveLocalToolsConfig({ allowedDirs: [...cfg.allowedDirs, dir] }) } return dir } // Native overview of the local-tools settings, with quick actions to add a // directory or toggle the feature. Keeps management self-contained in the // desktop shell without requiring a renderer settings page. async function showLocalToolsSettings(): Promise { const cfg = loadLocalToolsConfig() const dirs = cfg.allowedDirs.length > 0 ? cfg.allowedDirs.map((d) => ` • ${d}`).join('\n') : ` (未配置 — ${cfg.failClosed ? '默认拒绝所有本地访问' : '默认允许全部本地访问'})` const detail = [ `状态: ${cfg.enabled ? '已启用' : '已停用'}`, `隧道: ${localBridge.isConnected() ? '已连接' : '未连接'}`, '', '允许访问的目录:', dirs, ].join('\n') const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined const hasDirs = cfg.allowedDirs.length > 0 const buttons = hasDirs ? ['关闭', '添加目录…', '移除目录…', cfg.enabled ? '停用' : '启用'] : ['关闭', '添加目录…', cfg.enabled ? '停用' : '启用'] const toggleId = buttons.length - 1 const opts = { type: 'info' as const, title: '本地工具设置', message: '本地文件/命令工具', detail, buttons, defaultId: 0, cancelId: 0, noLink: true, } const res = parent ? await dialog.showMessageBox(parent, opts) : await dialog.showMessageBox(opts) if (res.response === 1) { await pickAllowedDirectory() } else if (hasDirs && res.response === 2) { await pickDirectoryToRemove(cfg.allowedDirs) } else if (res.response === toggleId) { const saved = saveLocalToolsConfig({ enabled: !cfg.enabled }) if (saved.enabled && backendReady) localBridge.start() else if (!saved.enabled) localBridge.stop() } } // Second-level picker for removing a whitelisted directory: native dialogs // cannot render per-item delete controls, so each directory becomes a button. async function pickDirectoryToRemove(dirs: string[]): Promise { const parent = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined const opts = { type: 'question' as const, title: '移除目录', message: '选择要从白名单移除的目录', detail: '移除后,本地文件/命令工具将无法再访问该目录。', buttons: ['取消', ...dirs], defaultId: 0, cancelId: 0, noLink: true, } const res = parent ? await dialog.showMessageBox(parent, opts) : await dialog.showMessageBox(opts) if (res.response === 0) return const dir = dirs[res.response - 1] const cfg = loadLocalToolsConfig() saveLocalToolsConfig({ allowedDirs: cfg.allowedDirs.filter((d) => d !== dir) }) } async function menuCheckForUpdates(): Promise { if (!app.isPackaged) { dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' }) return } try { const result = await autoUpdater.checkForUpdates() if (!result || !result.updateInfo || result.updateInfo.version === app.getVersion()) { dialog.showMessageBox({ type: 'info', title: 'Check for Updates', message: 'You are up to date!', detail: `MateClaw ${app.getVersion()} is the latest version.`, }) } // If update is available, the existing updater:state IPC events will notify the renderer } catch (err: any) { dialog.showMessageBox({ type: 'error', title: 'Update Error', message: 'Failed to check for updates', detail: err.message || 'Please check your network connection and try again.', }) } } function setupApplicationMenu(): void { const isMac = process.platform === 'darwin' const template: Electron.MenuItemConstructorOptions[] = [] // ── macOS App Menu ── if (isMac) { template.push({ label: app.name, submenu: [ { label: `About ${app.name}`, click: showAboutDialog }, { label: 'Check for Updates...', click: menuCheckForUpdates }, { type: 'separator' }, { label: 'Switch Server…', click: goToConnectionChooser }, { label: '本地工具设置…', click: () => { void showLocalToolsSettings() } }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }) } // ── File Menu (Windows/Linux only) ── if (!isMac) { template.push({ label: 'File', submenu: [ { label: 'Switch Server…', click: goToConnectionChooser }, { label: '本地工具设置…', click: () => { void showLocalToolsSettings() } }, { type: 'separator' }, { role: 'quit', label: 'Exit' }, ], }) } // ── Edit Menu ── template.push({ label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }, ], }) // ── View Menu ── template.push({ label: 'View', submenu: [ { role: 'reload' }, { role: 'forceReload' }, { role: 'toggleDevTools' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, ], }) // ── Window Menu (macOS) ── if (isMac) { template.push({ label: 'Window', submenu: [ { role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { role: 'front' }, ], }) } // ── Help Menu ── template.push({ label: 'Help', submenu: [ ...(!isMac ? [ { label: 'Check for Updates...', click: menuCheckForUpdates }, { type: 'separator' as const }, ] : []), { label: 'GitHub Repository', click: () => shell.openExternal('https://github.com/matevip/mateclaw'), }, { label: 'Report Issue', click: () => shell.openExternal('https://github.com/matevip/mateclaw/issues'), }, ...(!isMac ? [ { type: 'separator' as const }, { label: `About ${app.name}`, click: showAboutDialog }, ] : []), ], }) const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) } // Allow the user to accept a self-signed / untrusted certificate for the remote // server they explicitly chose to connect to (common on enterprise intranets). app.on('certificate-error', (event, _webContents, url, _error, _certificate, callback) => { let host = '' try { host = new URL(url).host } catch { callback(false) return } if (trustedCertHosts.has(host)) { event.preventDefault() callback(true) return } // Only prompt for the server the user is actively connecting to. if (connectionMode !== 'remote' || !BACKEND_URL.includes(host)) { callback(false) return } const choice = dialog.showMessageBoxSync({ type: 'warning', title: '证书不受信任', message: `服务器 ${host} 使用了不受信任的证书`, detail: '该服务器的 TLS 证书无法验证(可能是自签名证书)。仅在你信任此服务器时继续。', buttons: ['取消', '信任并继续'], defaultId: 0, cancelId: 0, }) if (choice === 1) { trustedCertHosts.add(host) event.preventDefault() callback(true) } else { callback(false) } }) app.whenReady().then(() => { setupApplicationMenu() registerIpcHandlers() createWindow() bootConnection() setupAutoUpdater() }) app.on('window-all-closed', () => { // On macOS, apps typically stay active until Cmd+Q if (process.platform !== 'darwin') { app.quit() } }) app.on('activate', () => { // On macOS, re-create window when dock icon is clicked if (BrowserWindow.getAllWindows().length === 0) { createWindow() if (!backendReady) { bootConnection() } } }) app.on('before-quit', async (event) => { if (isQuitting) return // During update install, backend is already stopped by updater:install handler if (isUpdating) { isQuitting = true return } isQuitting = true event.preventDefault() localBridge.stop() try { await stopJavaBackend() } catch (err) { console.error('[MateClaw] Error stopping backend:', err) } finally { app.exit(0) } })