mateclaw/mateclaw-desktop/electron/main/index.ts
2026-07-31 03:54:21 -04:00

1135 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string | null> {
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<string>()
// 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 <app>/Contents/Resources (macOS) or <app>/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/<platform>/* → Resources/jre/
// In dev: jre is at resources/jre/<platform-arch>/
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<number> {
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<void> {
// 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<void> {
if (!javaProcess) return
console.log('[MateClaw] Stopping Java backend...')
return new Promise<void>((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<void> {
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<LocalToolsConfig>) => {
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<string | null> {
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<void> {
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<void> {
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<void> {
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)
}
})