const { app, BrowserWindow, ipcMain, shell, dialog, Menu, nativeImage, Tray, globalShortcut, Notification } = require('electron') const { join } = require('path') const { exec, spawn } = require('child_process') const fs = require('fs') const path = require('path') const os = require('os') const { autoUpdater } = require('electron-updater') const { TerminalManager } = require('./terminalManager.cjs') const { createLocalShellRuntime, resolveShellProtocol } = require('./localShell.cjs') const promptImageResizer = require('./promptImageResizer.cjs') const serverConfigLib = require('./serverConfig.cjs') const { resolveShellMaoBaseUrl } = require('./shellEnv.cjs') const chatAttachment = require('./chatAttachment.cjs') const desktopPrefsLib = require('./desktopPrefs.cjs') const { createTray } = require('./tray.cjs') const { readFileBytes } = require('./localFileBytes.cjs') app.setName('Mao') // Windows 系统通知的前提:appUserModelId 必须与 electron-builder 的 appId 一致(package.json)。 // 收件箱的 Electron 系统通知(P2)依赖它才能正常弹出并被任务栏正确归组。 app.setAppUserModelId('cn.etarch.mao.desktop') // 单实例锁(P2 前置):托盘常驻 / 开机自启 / dock 复活都要求同一时刻只有一个进程, // 否则多实例会争用 auth.json 等 userData 资源。第二个实例启动后唤起主窗再退出。 if (!app.requestSingleInstanceLock()) { app.quit() } else { app.on('second-instance', () => { // app ready 前禁止创建 BrowserWindow(真实 Electron 会抛未捕获异常):未就绪时直接返回, // whenReady 链会把主窗建出来,不会漏掉「唤起」语义 if (!app.isReady()) return const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow() if (!win || win.isDestroyed()) return if (win.isMinimized()) win.restore() win.show() win.focus() }) } let mainWindow = null /** 系统托盘实例(P2;app ready 后创建,退出时随进程销毁) */ let appTray = null /** before-quit 已触发:close 事件不再拦截为隐藏,保证退出路径全量清理 */ let quitting = false /** 当前已注册的全局快捷键(换键时精确注销旧键;app 退出时随进程销毁) */ let currentShortcutAccel = null let currentWorkspace = '' /** @type {Map void>} */ const pendingApprovals = new Map() /** * 多端审批(技术方案 2026-11-06):决策可能早于本地弹卡到达(手机先点 / 本机还没走到 * requestToolApproval)。命中即直接放行、不再弹一张只能撤掉的卡。 * TTL / 上限只为兜住无人认领的迟到决策。 */ const EARLY_APPROVAL_TTL_MS = 10 * 60 * 1000 const EARLY_APPROVAL_LIMIT = 100 /** @type {Map} */ const earlyApprovalDecisions = new Map() /** * 待派发的通知点击深链:点击时刻渲染层监听可能尚未注册(应用刚启动、 * 页面加载中、窗口重建),立即 send 会丢。这里总是留一份,由渲染层桥接 * 就绪后主动拉取(consume),收到后回执(ack)清除,防止页面重载后 * 拿陈旧点击重复跳转。did-finish-load 补发仅在「尚未被拉取」时执行。 */ let pendingNotificationClick = null const terminalManager = new TerminalManager() terminalManager.setEnvProvider(buildShellEnv) const PRIVATE_DIFF_FIELD = 'file_change_diff' const SNAPSHOT_LIMIT_BYTES = 512 * 1024 const PATCH_LIMIT_CHARS = 256 * 1024 const PATCH_CONTEXT_LINES = 3 const LCS_CELL_LIMIT = 2_000_000 let updaterInitialized = false let updateDownloaded = false let updateCheckPromise = null let updaterStatus = 'idle' let updaterProgressPercent = null function utf8Bytes(text) { return Buffer.byteLength(text || '', 'utf-8') } function isBinaryContent(text) { const value = text || '' const checkLen = Math.min(value.length, 8192) for (let i = 0; i < checkLen; i++) { if (value.charCodeAt(i) === 0) return true } return false } function splitLines(text) { if (!text) return [] return text.split(/\r\n|\r|\n/) } function lcsLength(oldLines, oldStart, oldEnd, newLines, newStart, newEnd) { const newLen = newEnd - newStart + 1 let previous = new Array(newLen + 1).fill(0) let current = new Array(newLen + 1).fill(0) for (let i = oldStart; i <= oldEnd; i++) { for (let j = 1; j <= newLen; j++) { if (oldLines[i] === newLines[newStart + j - 1]) { current[j] = previous[j - 1] + 1 } else { current[j] = Math.max(previous[j], current[j - 1]) } } const temp = previous previous = current current = temp current.fill(0) } return previous[newLen] } function computeLineDelta(beforeContent, afterContent) { const oldLines = splitLines(beforeContent) const newLines = splitLines(afterContent) if (oldLines.length === 0) return { linesAdded: newLines.length, linesDeleted: 0 } if (newLines.length === 0) return { linesAdded: 0, linesDeleted: oldLines.length } let prefix = 0 while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) { prefix++ } let oldSuffix = oldLines.length - 1 let newSuffix = newLines.length - 1 while (oldSuffix >= prefix && newSuffix >= prefix && oldLines[oldSuffix] === newLines[newSuffix]) { oldSuffix-- newSuffix-- } const oldChanged = oldSuffix - prefix + 1 const newChanged = newSuffix - prefix + 1 if (oldChanged <= 0) return { linesAdded: Math.max(0, newChanged), linesDeleted: 0 } if (newChanged <= 0) return { linesAdded: 0, linesDeleted: Math.max(0, oldChanged) } if (oldChanged * newChanged > LCS_CELL_LIMIT) { return { linesAdded: newChanged, linesDeleted: oldChanged } } const lcs = lcsLength(oldLines, prefix, oldSuffix, newLines, prefix, newSuffix) return { linesAdded: newChanged - lcs, linesDeleted: oldChanged - lcs } } function appendBounded(parts, state, text) { if (state.length >= PATCH_LIMIT_CHARS) { state.truncated = true return } const remaining = PATCH_LIMIT_CHARS - state.length if (text.length <= remaining) { parts.push(text) state.length += text.length } else { parts.push(text.slice(0, remaining)) state.length += remaining state.truncated = true } } function buildUnifiedPatch(filePath, before, after) { const oldLines = splitLines(before) const newLines = splitLines(after) let prefix = 0 while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) { prefix++ } let oldSuffix = oldLines.length - 1 let newSuffix = newLines.length - 1 while (oldSuffix >= prefix && newSuffix >= prefix && oldLines[oldSuffix] === newLines[newSuffix]) { oldSuffix-- newSuffix-- } const contextStart = Math.max(0, prefix - PATCH_CONTEXT_LINES) const oldContextEnd = Math.min(oldLines.length - 1, oldSuffix + PATCH_CONTEXT_LINES) const newContextEnd = Math.min(newLines.length - 1, newSuffix + PATCH_CONTEXT_LINES) const oldStartLine = contextStart + 1 const newStartLine = contextStart + 1 const oldCount = Math.max(0, oldContextEnd - contextStart + 1) const newCount = Math.max(0, newContextEnd - contextStart + 1) const parts = [] const state = { length: 0, truncated: false } appendBounded(parts, state, `--- a/${filePath}\n`) appendBounded(parts, state, `+++ b/${filePath}\n`) appendBounded(parts, state, `@@ -${oldStartLine},${oldCount} +${newStartLine},${newCount} @@\n`) for (let i = contextStart; i < prefix && i < oldLines.length; i++) { appendBounded(parts, state, ` ${oldLines[i]}\n`) } for (let i = prefix; i <= oldSuffix && i < oldLines.length; i++) { appendBounded(parts, state, `-${oldLines[i]}\n`) } for (let i = prefix; i <= newSuffix && i < newLines.length; i++) { appendBounded(parts, state, `+${newLines[i]}\n`) } const sharedTailStart = Math.max(prefix, Math.max(oldSuffix + 1, newSuffix + 1)) const sharedTailEnd = Math.min(oldContextEnd, oldLines.length - 1) for (let i = sharedTailStart; i <= sharedTailEnd; i++) { appendBounded(parts, state, ` ${oldLines[i]}\n`) } let content = parts.join('') if (state.truncated) { const suffix = '\n...[diff truncated]\n' content = content.slice(0, Math.max(0, PATCH_LIMIT_CHARS - suffix.length)) + suffix } return { content, truncated: state.truncated } } function buildFileChangeDiff(filePath, beforeContent, afterContent) { const before = beforeContent || '' const after = afterContent || '' if (isBinaryContent(before) || isBinaryContent(after)) { return { diff_mode: 'UNSUPPORTED', diff_unavailable_reason: '二进制文件无法生成文本 diff', patch_truncated: false } } if (utf8Bytes(before) <= SNAPSHOT_LIMIT_BYTES && utf8Bytes(after) <= SNAPSHOT_LIMIT_BYTES) { return { diff_mode: 'SNAPSHOT', before_content: before, after_content: after, patch_truncated: false } } const patch = buildUnifiedPatch(filePath, before, after) return { diff_mode: 'PATCH', patch_content: patch.content, patch_truncated: patch.truncated } } function asBoolArg(value, fallback = false) { if (typeof value === 'boolean') return value if (typeof value === 'number' && Number.isFinite(value)) return value !== 0 if (typeof value === 'string') { const s = value.trim().toLowerCase() if (s === 'true' || s === '1' || s === 'yes') return true if (s === 'false' || s === '0' || s === 'no' || s === '') return false } return fallback } function lineAtIndex(content, index) { let lineNumber = 1 let lineStart = 0 for (let i = 0; i < index; i++) { if (content[i] === '\n') { lineNumber++ lineStart = i + 1 } } let lineEnd = content.indexOf('\n', index) if (lineEnd === -1) lineEnd = content.length let lineText = content.slice(lineStart, lineEnd) if (lineText.endsWith('\r')) lineText = lineText.slice(0, -1) return { lineNumber, lineText } } function applyEditMatch(content, oldString, newString, replaceAll) { if (oldString === '') { return { ok: false, replacements: 0, error: 'old_string 不能为空' } } const starts = [] let idx = 0 while (idx < content.length) { const found = content.indexOf(oldString, idx) if (found === -1) break starts.push(found) idx = found + oldString.length } const count = starts.length if (count === 0) { return { ok: false, replacements: 0, error: '文件中未找到 old_string' } } if (count > 1 && !replaceAll) { const maxLines = 20 const maxPreviews = 8 const occurrenceLines = starts.slice(0, maxLines).map((start) => lineAtIndex(content, start).lineNumber) const previewCount = Math.min(maxPreviews, starts.length) const previews = [] for (let i = 0; i < previewCount; i++) { const { lineNumber, lineText } = lineAtIndex(content, starts[i]) const preview = lineText.length > 120 ? lineText.slice(0, 120) + '…' : lineText previews.push(` 第 ${lineNumber} 行: ${preview}`) } const listed = count > maxLines ? `(仅列出前 ${maxLines} 处)` : '' return { ok: false, replacements: 0, occurrences: count, occurrence_lines: occurrenceLines, error: `old_string 在文件中出现 ${count} 次,默认只替换唯一匹配,未执行编辑。` + '请在 old_string 中补充更多上下文使其只出现一次,或传入 replace_all=true 以替换全部出现。' + `出现位置(行号从 1 起)${listed}:\n` + previews.join('\n') } } return { ok: true, updated: content.split(oldString).join(newString), replacements: count } } function writeEditedFile(resolvedPath, filePathArg, oldString, newString, replaceAll) { const content = fs.readFileSync(resolvedPath, 'utf-8') const match = applyEditMatch(content, oldString ?? '', newString ?? '', replaceAll) if (!match.ok) { const out = { success: false, replacements: 0, error: match.error } if (match.occurrences != null) { out.occurrences = match.occurrences out.occurrence_lines = match.occurrence_lines } return out } fs.writeFileSync(resolvedPath, match.updated, 'utf-8') const lineDelta = computeLineDelta(content, match.updated) return { success: true, replacements: match.replacements, file_change: { path: filePathArg, type: 'MODIFIED', lines_added: lineDelta.linesAdded, lines_deleted: lineDelta.linesDeleted }, [PRIVATE_DIFF_FIELD]: buildFileChangeDiff(filePathArg, content, match.updated) } } /** * Get the full user PATH by executing a login shell. * macOS GUI apps don't load shell configs, so we need to manually resolve the PATH * that includes all user-configured tools (nvm, brew, cargo, etc.) */ let cachedUserPath = null async function resolveUserPath() { if (cachedUserPath) return cachedUserPath const shell = process.env.SHELL || '/bin/zsh' console.log('[env] Resolving user PATH, SHELL:', process.env.SHELL, 'Using shell:', shell) console.log('[env] Current PATH:', process.env.PATH) return new Promise((resolve) => { // Use login shell to get full user environment const cmd = `${shell} -l -c 'echo $PATH'` console.log('[env] Executing:', cmd) exec(cmd, { timeout: 10000, env: { ...process.env, TERM: 'dumb' } // Ensure basic env }, (error, stdout, stderr) => { if (error) { console.error('[env] Failed to resolve user PATH via login shell:', error.message) if (stderr) console.error('[env] stderr:', stderr) resolve(process.env.PATH) } else { const userPath = stdout.trim() if (userPath && userPath !== process.env.PATH) { cachedUserPath = userPath console.log('[env] Resolved user PATH via login shell:', userPath.substring(0, 100) + '...') resolve(userPath) } else { console.log('[env] Login shell PATH same as current, using current PATH') resolve(process.env.PATH) } } }) }) } /** * Build environment with user's full PATH and current MAO_TOKEN from local auth store. * 同时注入 MAO_BASE_URL(当前连接的服务器),shell 里的 mao / mao-agent 不再依赖内置默认地址。 */ async function buildShellEnv() { const userPath = await resolveUserPath() const { token } = readAuthStore() const env = { ...process.env, PATH: userPath, TERM: 'dumb', PS1: '' } if (token) { env.MAO_TOKEN = token } else { delete env.MAO_TOKEN } const maoBaseUrl = resolveShellMaoBaseUrl({ configured: isServerConfigured(), serverBaseUrl: getEffectiveServerBaseUrl() }) if (maoBaseUrl) { env.MAO_BASE_URL = maoBaseUrl } else { delete env.MAO_BASE_URL } return env } /** * 持久 shell 会话的 env 在 spawn 后不会自动更新;每次执行命令前重新写入。 * bash 用 export,PowerShell 用 $env:。 */ function refreshMaoTokenInShellSession(session) { if (!session?.writeStdin) return const protocol = resolveShellProtocol() const { token } = readAuthStore() if (token) { session.writeStdin(protocol.envSet('MAO_TOKEN', token) + '\n') } else { session.writeStdin(protocol.envUnset('MAO_TOKEN') + '\n') } const maoBaseUrl = resolveShellMaoBaseUrl({ configured: isServerConfigured(), serverBaseUrl: getEffectiveServerBaseUrl() }) if (maoBaseUrl) { session.writeStdin(protocol.envSet('MAO_BASE_URL', maoBaseUrl) + '\n') } else { session.writeStdin(protocol.envUnset('MAO_BASE_URL') + '\n') } } function sendToRenderer(channel, data) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, data) } } function setUpdaterStatus(status, progressPercent = updaterProgressPercent) { updaterStatus = status updaterProgressPercent = progressPercent buildApplicationMenu() } function installDownloadedUpdate() { if (!updateDownloaded) { return { success: false, error: 'Update has not been downloaded yet.' } } // macOS 的 quitAndInstall 先关窗、后发 before-quit。默认「关窗收托盘」会在 // quitting 仍为 false 时把 close 变成 hide,退出被取消,安装不会开始。 quitting = true autoUpdater.quitAndInstall(false, true) return { success: true } } function showUpdateMessage(type, title, message, buttons = ['确定']) { const options = { type, title, message, buttons, defaultId: 0, cancelId: buttons.length - 1 } if (mainWindow && !mainWindow.isDestroyed()) { return dialog.showMessageBox(mainWindow, options) } return dialog.showMessageBox(options) } async function showMenuUpdateCheckResult() { if (updateDownloaded) { const { response } = await showUpdateMessage( 'info', '更新已就绪', '客户端新版本已下载完成,可以重启 Mao 完成安装。', ['重启完成更新', '稍后'] ) if (response === 0) installDownloadedUpdate() return } if (updaterStatus === 'available' || updaterStatus === 'downloading') { const progressLabel = updaterProgressPercent == null ? '' : `(${Math.round(updaterProgressPercent)}%)` await showUpdateMessage('info', '正在下载新版本', `已发现客户端新版本,正在后台下载${progressLabel}。`) return } if (updaterStatus === 'not-available') { await showUpdateMessage('info', '已是最新版本', `当前 Mao ${app.getVersion()} 已是最新版本。`) return } if (updaterStatus === 'unsupported') { await showUpdateMessage('warning', '无法检查更新', '自动更新仅在打包后的客户端中可用。') } } async function checkForAppUpdateFromMenu() { try { await checkForAppUpdate() await showMenuUpdateCheckResult() } catch (error) { const message = error?.message || '检查更新失败' console.error('[updater] menu check failed:', error) await showUpdateMessage('error', '检查更新失败', message) } } function buildUpdateMenuItem() { const isDownloading = updaterStatus === 'available' || updaterStatus === 'downloading' const progressLabel = updaterProgressPercent == null ? '' : ` ${Math.round(updaterProgressPercent)}%` if (updateDownloaded) { return { label: '重启完成更新', enabled: true, click: () => { installDownloadedUpdate() } } } else if (isDownloading) { return { label: `正在下载新版本${progressLabel}`, enabled: false } } return { label: updaterStatus === 'checking' ? '正在检查更新' : '检查更新', enabled: app.isPackaged && !updateCheckPromise, click: () => { checkForAppUpdateFromMenu() } } } function buildServerMenuItem() { return { label: '服务器设置…', click: () => openServerPickerWindow(), } } function buildApplicationMenu() { if (!app.isReady()) return const template = [] if (process.platform === 'darwin') { template.push({ label: 'Mao', submenu: [ { role: 'about', label: '关于 Mao' }, { type: 'separator' }, buildServerMenuItem(), { type: 'separator' }, buildUpdateMenuItem(), { type: 'separator' }, { role: 'services', label: '服务' }, { type: 'separator' }, { role: 'hide', label: '隐藏 Mao' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '全部显示' }, { type: 'separator' }, { role: 'quit', label: '退出 Mao' } ] }) } else { template.push({ label: 'Mao', submenu: [ buildServerMenuItem(), { type: 'separator' }, buildUpdateMenuItem(), { type: 'separator' }, { role: 'quit', label: '退出 Mao' } ] }) } template.push( { label: '编辑', submenu: [ { role: 'undo', label: '撤销' }, { role: 'redo', label: '重做' }, { type: 'separator' }, { role: 'cut', label: '剪切' }, { role: 'copy', label: '复制' }, { role: 'paste', label: '粘贴' }, { role: 'selectAll', label: '全选' } ] }, { label: '显示', submenu: [ { role: 'reload', label: '重新加载' }, { role: 'forceReload', label: '强制重新加载' }, { role: 'toggleDevTools', label: '开发者工具' }, { type: 'separator' }, { role: 'resetZoom', label: '实际大小' }, { role: 'zoomIn', label: '放大' }, { role: 'zoomOut', label: '缩小' }, { type: 'separator' }, { role: 'togglefullscreen', label: '切换全屏' } ] }, { label: '窗口', submenu: [ { role: 'minimize', label: '最小化' }, { role: 'close', label: '关闭' } ] } ) Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } function serializeUpdaterError(error) { if (!error) return { message: 'Unknown updater error' } return { message: error.message || String(error), stack: error.stack } } function getUpdateFeedUrlOverride() { return process.env.MAO_DESKTOP_UPDATE_URL || '' } function applyUpdateFeedUrl() { const feed = serverConfigLib.resolveUpdateFeedUrl(getServerConfig(), getUpdateFeedUrlOverride()) if (!feed.url) return { mode: feed.mode, url: null } try { autoUpdater.setFeedURL({ provider: 'generic', url: feed.url, }) return feed } catch (e) { console.warn('[updater] setFeedURL failed:', e?.message) return { mode: feed.mode, url: feed.url, error: e?.message } } } function setupAutoUpdater() { if (updaterInitialized) return updaterInitialized = true autoUpdater.autoDownload = true autoUpdater.autoInstallOnAppQuit = true applyUpdateFeedUrl() autoUpdater.on('checking-for-update', () => { setUpdaterStatus('checking', null) const feed = serverConfigLib.resolveUpdateFeedUrl(getServerConfig(), getUpdateFeedUrlOverride()) sendToRenderer('update-checking', { feedUrl: feed.url || null }) }) autoUpdater.on('update-available', (info) => { updateDownloaded = false setUpdaterStatus('available', 0) sendToRenderer('update-available', info) }) autoUpdater.on('update-not-available', (info) => { setUpdaterStatus('not-available', null) sendToRenderer('update-not-available', info) }) autoUpdater.on('download-progress', (progress) => { setUpdaterStatus('downloading', Math.max(0, Math.min(100, progress?.percent || 0))) sendToRenderer('download-progress', progress) }) autoUpdater.on('update-downloaded', (info) => { updateDownloaded = true setUpdaterStatus('downloaded', 100) sendToRenderer('update-downloaded', info) }) autoUpdater.on('error', (error) => { console.error('[updater] error:', error) setUpdaterStatus('error', null) sendToRenderer('update-error', serializeUpdaterError(error)) }) } async function checkForAppUpdate() { if (!app.isPackaged) { setUpdaterStatus('unsupported', null) return { skipped: true, reason: 'Auto update is only available in packaged builds.' } } const feed = serverConfigLib.resolveUpdateFeedUrl(getServerConfig(), getUpdateFeedUrlOverride()) if (feed.mode === 'disabled') { setUpdaterStatus('idle', null) return { skipped: true, reason: 'Auto update is disabled by server config.' } } setupAutoUpdater() applyUpdateFeedUrl() if (!updateCheckPromise) { setUpdaterStatus('checking', null) updateCheckPromise = autoUpdater.checkForUpdates() .then((result) => ({ skipped: false, updateInfo: result?.updateInfo || null })) .catch((error) => { sendToRenderer('update-error', serializeUpdaterError(error)) throw error }) .finally(() => { updateCheckPromise = null buildApplicationMenu() }) } return updateCheckPromise } function requestToolApproval(toolName, description, sessionId, dangerReason, backendRequestId, approvalHint) { return new Promise((resolve) => { const requestId = backendRequestId || `approval_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` // 决策已先行到达(多端审批的早到窗口):直接按它放行,不弹本地卡 const early = earlyApprovalDecisions.get(requestId) if (early) { earlyApprovalDecisions.delete(requestId) resolve(!!early.approved) return } pendingApprovals.set(requestId, (approved) => { pendingApprovals.delete(requestId) resolve(approved) }) const payload = { requestId, toolName, description, sessionId } if (dangerReason) payload.dangerReason = dangerReason // 「总是允许」hint(V135):服务端随 tool_execute 下发的模式提示,透传给渲染层审批卡片 if (approvalHint) payload.approvalHint = approvalHint sendToRenderer('tool-approval-request', payload) }) } /** Persist shell reuse / write_stdin must re-check approval — first approve must not unlock later commands. */ async function ensureShellApproval(needApproval, description, sessionId, dangerReason, backendRequestId, approvalHint) { if (!needApproval) return true return requestToolApproval('shell', description, sessionId, dangerReason, backendRequestId, approvalHint) } function pruneEarlyApprovalDecisions(now = Date.now()) { for (const [requestId, entry] of earlyApprovalDecisions) { if (now - entry.at > EARLY_APPROVAL_TTL_MS) earlyApprovalDecisions.delete(requestId) } while (earlyApprovalDecisions.size > EARLY_APPROVAL_LIMIT) { const oldest = earlyApprovalDecisions.keys().next() if (oldest.done) break earlyApprovalDecisions.delete(oldest.value) } } /** * 多端审批的唯一解锁入口(技术方案 2026-11-06 §4.5-10): * 本地 IPC 卡点击、远端 resolved 回声、切会话清理都走这里。 * 有 pending 就直接 resolve;没有就写入早到缓冲(不读 locallyReleased,那个由渲染层决定)。 */ ipcMain.handle('tool-approval-remote-response', (event, payload = {}) => { const requestId = payload?.requestId if (!requestId) return false const resolve = pendingApprovals.get(requestId) if (resolve) { pendingApprovals.delete(requestId) resolve(!!payload.approved) return true } earlyApprovalDecisions.set(requestId, { approved: !!payload.approved, at: Date.now() }) pruneEarlyApprovalDecisions() return false }) // ========== Multi-server baseUrl config ========== /** @type {{ config: any, fromFile: boolean } | null} */ let serverConfigCache = null let serverPickerWindow = null function getServerConfigStorePath() { return serverConfigLib.getServerConfigPath(app.getPath('userData')) } function loadServerConfig(force = false) { if (serverConfigCache && !force) return serverConfigCache serverConfigCache = serverConfigLib.readServerConfig(app.getPath('userData')) return serverConfigCache } function getServerConfig() { return loadServerConfig().config } function getServerBaseUrlEnvOverride() { return (process.env.MAO_DESKTOP_SERVER_URL || '').trim() } function getEffectiveServerBaseUrl() { const envBase = getServerBaseUrlEnvOverride() if (envBase) { const normalized = serverConfigLib.normalizeServerBaseUrl(envBase, { allowHttp: true }) if (normalized.ok) return normalized.baseUrl } return getServerConfig().serverBaseUrl || serverConfigLib.DEFAULT_SERVER_BASE_URL } function isServerConfigured() { if (getServerBaseUrlEnvOverride()) return true return getServerConfig().configured === true } function getApiBaseUrl() { if (process.env.NODE_ENV === 'development' && !app.isPackaged) { return 'http://localhost:9080/api' } return serverConfigLib.deriveApiBase(getEffectiveServerBaseUrl()) } function getServerHostKey() { return serverConfigLib.serverHostKey(getEffectiveServerBaseUrl()) } function getServerPickerHtmlPath() { return path.join(__dirname, 'server-picker', 'index.html') } function loadServerPicker() { if (!mainWindow || mainWindow.isDestroyed()) return mainWindow.loadFile(getServerPickerHtmlPath()) } const LOCAL_RUNTIME_ROOT = path.join(os.homedir(), '.mao', 'runtime') function resolveLocalRuntimeDir(sessionId) { return path.join(LOCAL_RUNTIME_ROOT, getServerHostKey(), String(sessionId)) } function resolveLocalSkillsDir(sessionId) { return path.join(resolveLocalRuntimeDir(sessionId), 'skills') } function resolveLocalShellOutputDir(sessionId) { return path.join(resolveLocalRuntimeDir(sessionId), 'shellOutput') } function formatLocalRuntimePath(sessionId, ...segments) { return ['~/.mao/runtime', getServerHostKey(), String(sessionId), ...segments].join('/') } const localShell = createLocalShellRuntime({ buildEnv: buildShellEnv, refreshToken: refreshMaoTokenInShellSession, resolveOutput: (maoSessionId, shellId) => { const fileName = `${shellId}.out` return { absPath: path.join(resolveLocalShellOutputDir(maoSessionId || 0), fileName), displayPath: formatLocalRuntimePath(maoSessionId || 0, 'shellOutput', fileName), } }, }) function expandHomePath(filePath) { if (!filePath) return filePath if (filePath === '~') return os.homedir() if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2)) return filePath } function isUnderLocalRuntime(absolutePath, maoSessionId) { if (!maoSessionId) return false const runtimeDir = resolveLocalRuntimeDir(maoSessionId) const resolved = path.resolve(absolutePath) return resolved === runtimeDir || resolved.startsWith(runtimeDir + path.sep) } // ========== Skill sync IPC handler ========== function preferIpv4Loopback(url) { // Electron/undici 解析 localhost 会先走 ::1;TS 后端 listen 0.0.0.0 只收 IPv4。 return String(url || '').replace('://localhost', '://127.0.0.1') } function resolveSkillSyncBaseUrl(apiBase) { const raw = typeof apiBase === 'string' && apiBase.trim() !== '' ? apiBase.trim() : getApiBaseUrl() return preferIpv4Loopback(raw.replace(/\/v1\/?$/, '').replace(/\/$/, '')) } ipcMain.handle('skill-sync', async (event, { sessionId, syncUrl, token, apiBase }) => { if (!sessionId) { const err = 'No sessionId provided for skill sync.' sendToRenderer('skill-sync-complete', { sessionId, success: false, error: err }) return { success: false, error: err } } try { const AdmZip = require('adm-zip') const skillsDir = resolveLocalSkillsDir(sessionId) const origin = resolveSkillSyncBaseUrl(apiBase) const fullUrl = preferIpv4Loopback(/^https?:\/\//.test(syncUrl) ? syncUrl : `${origin}${syncUrl}`) const headers = token ? { 'Authorization': `Bearer ${token}` } : {} const response = await fetch(fullUrl, { method: 'POST', headers }) if (!response.ok) { const body = await response.text().catch(() => '') throw new Error(`Skill sync download failed: ${response.status} ${response.statusText} - ${body}`) } const zipBuffer = Buffer.from(await response.arrayBuffer()) fs.mkdirSync(skillsDir, { recursive: true }) const zip = new AdmZip(zipBuffer) zip.extractAllTo(skillsDir, true) sendToRenderer('skill-sync-complete', { sessionId, success: true }) return { success: true } } catch (e) { console.error('Skill sync failed:', e) sendToRenderer('skill-sync-complete', { sessionId, success: false, error: e.message }) return { success: false, error: e.message } } }) // ========== Local skills (~/.agents/skills) ========== const LOCAL_SKILLS_DIR = path.join(os.homedir(), '.agents', 'skills') function parseSkillMd(content) { if (!content || !content.startsWith('---')) return null const secondDelimiter = content.indexOf('---', 3) if (secondDelimiter === -1) return null const frontmatter = content.substring(3, secondDelimiter).trim() const body = content.substring(secondDelimiter + 3).trim() let name = null let description = null for (const line of frontmatter.split('\n')) { const nameMatch = line.match(/^name:\s*(.+)$/) if (nameMatch) { name = nameMatch[1].trim().replace(/^["']|["']$/g, '') } const descMatch = line.match(/^description:\s*(.+)$/) if (descMatch) { description = descMatch[1].trim().replace(/^["']|["']$/g, '') } } if (!name) return null return { name, description: description || '', body } } function resolveLocalSkillFolder(folderName) { if (!folderName || typeof folderName !== 'string') return null if (folderName.includes('/') || folderName.includes('\\') || folderName.startsWith('.')) { return null } const skillFolder = path.resolve(LOCAL_SKILLS_DIR, folderName) const normalizedRoot = path.resolve(LOCAL_SKILLS_DIR) if (skillFolder !== normalizedRoot && !skillFolder.startsWith(normalizedRoot + path.sep)) { return null } if (!fs.existsSync(skillFolder) || !fs.statSync(skillFolder).isDirectory()) { return null } return skillFolder } function collectSkillFiles(dir, baseDir, files = []) { const entries = fs.readdirSync(dir, { withFileTypes: true }) for (const entry of entries) { if (entry.name.startsWith('.')) continue const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { collectSkillFiles(fullPath, baseDir, files) } else if (entry.isFile()) { const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/') if (!relativePath || relativePath.includes('/.')) continue files.push({ relativePath, fullPath }) } } return files } ipcMain.handle('list-local-skills', async () => { try { if (!fs.existsSync(LOCAL_SKILLS_DIR)) { return { skills: [], skillsDir: LOCAL_SKILLS_DIR } } const skills = [] const entries = fs.readdirSync(LOCAL_SKILLS_DIR, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.')) continue const folderPath = path.join(LOCAL_SKILLS_DIR, entry.name) const skillMdPath = path.join(folderPath, 'SKILL.md') if (!fs.existsSync(skillMdPath)) continue try { const content = fs.readFileSync(skillMdPath, 'utf-8') const parsed = parseSkillMd(content) if (!parsed) continue skills.push({ folderName: entry.name, name: parsed.name, description: parsed.description, folderPath, }) } catch (e) { console.warn('[local-skills] Failed to parse:', entry.name, e.message) } } skills.sort((a, b) => a.name.localeCompare(b.name)) return { skills, skillsDir: LOCAL_SKILLS_DIR } } catch (e) { console.error('[local-skills] list failed:', e) return { error: e.message, skills: [], skillsDir: LOCAL_SKILLS_DIR } } }) ipcMain.handle('get-local-skill-detail', async (event, { folderName }) => { try { const skillFolder = resolveLocalSkillFolder(folderName) if (!skillFolder) { return { error: 'Skill not found: ' + folderName } } const skillMdPath = path.join(skillFolder, 'SKILL.md') const content = fs.readFileSync(skillMdPath, 'utf-8') const parsed = parseSkillMd(content) if (!parsed) { return { error: 'Invalid SKILL.md in skill: ' + folderName } } return { name: parsed.name, description: parsed.description, body: parsed.body, folderPath: skillFolder, filePath: skillMdPath, folderName, } } catch (e) { return { error: e.message } } }) ipcMain.handle('read-local-skill-files', async (event, { folderName }) => { try { const skillFolder = resolveLocalSkillFolder(folderName) if (!skillFolder) { return { error: 'Skill not found: ' + folderName } } const fileEntries = collectSkillFiles(skillFolder, skillFolder) const files = fileEntries.map(({ relativePath, fullPath }) => ({ relativePath, base64: fs.readFileSync(fullPath).toString('base64'), })) return { folderName, files } } catch (e) { return { error: e.message } } }) ipcMain.handle('read-agents-md', async (event, { workspace }) => { try { if (!workspace) { return { content: null } } const agentsMdPath = path.join(workspace, 'AGENTS.md') if (!fs.existsSync(agentsMdPath)) { return { content: null } } const stat = fs.statSync(agentsMdPath) if (!stat.isFile()) { return { content: null } } const content = fs.readFileSync(agentsMdPath, 'utf-8') return { content: content || null } } catch (e) { console.error('[read-agents-md] Failed:', e.message) return { error: e.message, content: null } } }) function resolveWorkspacePath(filePath, workspace, maoSessionId) { const effectiveWorkspace = workspace || currentWorkspace if (!filePath) return filePath const expanded = expandHomePath(filePath) if (path.isAbsolute(expanded)) { if (maoSessionId && isUnderLocalRuntime(expanded, maoSessionId)) { return expanded } return expanded } if (!effectiveWorkspace) return expanded return path.join(effectiveWorkspace, expanded) } function detectShell() { return process.env.SHELL || process.env.ComSpec || process.env.COMSPEC || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh') } function buildOsVersion() { if (process.platform === 'win32') { return `${os.version()} ${os.release()}` } return `${os.type()} ${os.release()}` } function isGitWorkspace(workspace) { if (!workspace) return false let current = path.resolve(workspace) while (current && current !== path.dirname(current)) { if (fs.existsSync(path.join(current, '.git'))) { return true } current = path.dirname(current) } return false } const { getGitStatus, refreshGitStatus, getGitFileDiff, listGitRepos, getGitLog, getGitCommitFiles, getGitCommitFileDiff, getGitBranchCompare, getGitBranchCompareFileDiff, getGitBranches, getGitStashes, getGitRemotes } = require('./gitStatus.cjs') const { buildCommitInput, commit: gitCommit, pull: gitPull, push: gitPush, fetch: gitFetch, switchBranch: gitSwitchBranch, stashOp: gitStashOp } = require('./gitOperations.cjs') function loadMainContent() { if (process.env.NODE_ENV === 'development' && !app.isPackaged) { mainWindow.loadURL('http://localhost:5201') mainWindow.webContents.openDevTools() return } // 生产:远程 SPA;未配置服务器时先展示本地配置页 if (!isServerConfigured()) { loadServerPicker() return } const target = getEffectiveServerBaseUrl() mainWindow.loadURL(target).catch((err) => { console.error('[server] loadURL failed:', target, err) loadServerPicker() }) } async function cleanupSiteScopedResources() { try { terminalManager.killAll() } catch (e) { console.warn('[server] terminal cleanup failed:', e?.message) } try { localShell.closeAll() } catch (e) { console.warn('[server] local shell cleanup failed:', e?.message) } const sessionIds = Array.from(mcpClients.keys()) await Promise.all( sessionIds.map((sid) => closeMcpSession(sid).catch((e) => console.warn('[server] mcp cleanup failed:', e?.message)) ) ) } /** * 切换服务器:校验 → 可选探测 → host 变化时清 auth/本地资源 → 写配置 → 加载目标站。 * @param {{ serverBaseUrl: string, updateFeedMode?: string, allowHttp?: boolean, skipProbe?: boolean }} payload */ async function applyServerConfig(payload = {}) { const allowHttp = payload.allowHttp === true const normalized = serverConfigLib.normalizeServerBaseUrl(payload.serverBaseUrl, { allowHttp }) if (!normalized.ok) { return { ok: false, error: normalized.error } } const targetBase = normalized.baseUrl const previousBase = getEffectiveServerBaseUrl() const previousHost = serverConfigLib.serverHostKey(previousBase) const nextHost = serverConfigLib.serverHostKey(targetBase) const hostChanged = previousHost !== nextHost if (payload.skipProbe !== true) { const probe = await serverConfigLib.probeServer(targetBase) if (!probe.ok) { return { ok: false, error: probe.error || '服务器检测失败', probe } } } if (hostChanged) { writeAuthStore({ token: null, refreshToken: null }) await cleanupSiteScopedResources() } const writeResult = serverConfigLib.writeServerConfig(app.getPath('userData'), { serverBaseUrl: targetBase, updateFeedMode: payload.updateFeedMode || getServerConfig().updateFeedMode || 'follow-site', allowHttp, configured: true, }) if (!writeResult.ok) { return { ok: false, error: writeResult.error } } serverConfigCache = null loadServerConfig(true) // 历史只记「连接成功」过的地址,置顶去重供快速切换 try { serverConfigLib.recordServerHistory(app.getPath('userData'), targetBase, allowHttp) } catch (e) { console.warn('[server] history record failed:', e?.message) } const feed = serverConfigLib.resolveUpdateFeedUrl(getServerConfig(), getUpdateFeedUrlOverride()) if (feed.url) { try { autoUpdater.setFeedURL({ provider: 'generic', url: feed.url }) } catch (e) { console.warn('[updater] setFeedURL failed:', e?.message) } } if (mainWindow && !mainWindow.isDestroyed()) { loadMainContent() } return { ok: true, serverBaseUrl: targetBase, hostChanged, updateFeedMode: writeResult.config.updateFeedMode, } } function openServerPickerWindow() { if (serverPickerWindow && !serverPickerWindow.isDestroyed()) { serverPickerWindow.focus() return } serverPickerWindow = new BrowserWindow({ width: 560, useContentSize: true, resizable: false, minimizable: false, maximizable: false, fullscreenable: false, title: 'Mao · 服务器设置', parent: mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined, modal: false, autoHideMenuBar: true, webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }) serverPickerWindow.loadFile(getServerPickerHtmlPath()) serverPickerWindow.on('closed', () => { serverPickerWindow = null }) } function createWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, show: false, title: 'Mao', titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 12 }, backgroundColor: '#f5f5f7', webPreferences: { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true } }) mainWindow.once('ready-to-show', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show() } }) loadMainContent() mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url) return { action: 'deny' } }) // 关窗拦截(P2,默认托盘化):quitting 旗标未置位且偏好为托盘时,红点/X/窗口菜单「关闭」 // 一律 hide 而非销毁——渲染层与 WS 存活,任务继续跑、通知照常可达。 // 退出路径(托盘菜单 / 应用菜单 role:quit / app.quit())都先走 before-quit 置 // quitting=true,本拦截随之失效,窗口正常关闭并全量清理。 mainWindow.on('close', (e) => { if (quitting) return const prefs = desktopPrefsLib.readDesktopPrefs(app.getPath('userData')) if (prefs.closeToTray !== 'tray') return e.preventDefault() mainWindow.hide() if (!prefs.trayHintShown) { sendToRenderer('tray-first-close-hint', {}) desktopPrefsLib.writeDesktopPrefs(app.getPath('userData'), { trayHintShown: true }) } }) mainWindow.on('closed', () => { mainWindow = null }) // 托盘菜单文案(显示/隐藏主窗)随窗口显示态刷新。挂在 createWindow 里而非 initTray: // 退出模式下关窗会销毁窗口,activate/second-instance 重建的新窗口同样需要这些监听, // 否则文案停留在旧状态,用户按文案点击会做出与意图相反的动作。 // closed 也必须监听:退出模式关窗走销毁,此时 isWindowVisible() 因 mainWindow 置 null // 返回 false,文案应翻为「显示主窗」(点击即重建),停在「隐藏主窗」会语义相反。 const refreshTray = () => appTray?.refresh() mainWindow.on('show', refreshTray) mainWindow.on('hide', refreshTray) mainWindow.on('minimize', refreshTray) mainWindow.on('restore', refreshTray) mainWindow.on('closed', refreshTray) // 返回实例:调用方(second-instance / 通知 click 重建路径)需要拿到它来 // restore/show/focus 或挂加载回调,不能只靠 ready-to-show 兜底 return mainWindow } // ========== P2/P3 桌面壳:托盘 / 通知 / 角标 / 偏好 / 快捷键 ========== function readDesktopPrefs() { return desktopPrefsLib.readDesktopPrefs(app.getPath('userData')) } function initTray() { if (appTray) return appTray = createTray({ isWindowVisible: () => !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(), onShowHide: () => { const win = mainWindow if (!win || win.isDestroyed()) { createWindow() return } if (win.isVisible()) { win.hide() } else { if (win.isMinimized()) win.restore() win.show() win.focus() } appTray?.refresh() }, onNewTask: () => { // 渲染层复用「新建任务」既有入口(Home + newTask=1);窗口不可见时先唤起 const win = mainWindow if (win && !win.isDestroyed()) { if (win.isMinimized()) win.restore() win.show() win.focus() } sendToRenderer('tray-new-task', {}) }, onQuit: () => { // before-quit 置 quitting → close 不再拦截 → 全量清理后退出 app.quit() }, }) } /** * 全局快捷键(P3,默认 Alt+Shift+M,可改可关):触发时窗口可见且聚焦则隐藏,否则唤起。 * 换键顺序必须是「先注册新键、成功后再摘旧键」:反过来会在新键注册失败时把旧键也弄丢 * (unregisterAll 先清空),而偏好文件与设置页仍显示旧键,快捷键静默失效到重启。 * @param {string | null} accel * @returns {{ ok: true } | { ok: false, error: string }} */ function applyGlobalShortcut(accel) { if (!accel) { // 关闭:只摘当前已注册的那个键,不影响(也不存在的)其他注册项 try { if (currentShortcutAccel) globalShortcut.unregister(currentShortcutAccel) } catch (e) { console.warn('[shortcut] unregister failed:', e?.message) } currentShortcutAccel = null return { ok: true } } // 与当前键相同:直接视为成功。Electron 对「重复注册同一 accelerator」的返回值有版本 // 差异,早退可消掉这层不确定,也避免误导性的「被其他程序占用」提示 if (currentShortcutAccel === accel) return { ok: true } try { const registered = globalShortcut.register(accel, () => { const win = mainWindow // 无窗口(退出模式关窗销毁后应用仍驻留)时重建:快捷键语义是「否则唤起」, // 直接 return 会让仍显示「已启用」的快捷键完全失效 if (!win || win.isDestroyed()) { createWindow() appTray?.refresh() return } if (win.isVisible() && !win.isMinimized() && win.isFocused()) { win.hide() } else { if (win.isMinimized()) win.restore() win.show() win.focus() } appTray?.refresh() }) if (!registered) { // 新键没注册上:旧键原样保持生效,偏好不写,UI 与实际状态一致 return { ok: false, error: '快捷键注册失败,可能已被其他程序占用' } } if (currentShortcutAccel && currentShortcutAccel !== accel) { try { globalShortcut.unregister(currentShortcutAccel) } catch (e) { console.warn('[shortcut] unregister old accel failed:', e?.message) } } currentShortcutAccel = accel return { ok: true } } catch (e) { return { ok: false, error: `快捷键格式无效:${e?.message || ''}` } } } /** * 开机自启(P3):仅打包后客户端可写登录项(防 dev 的 electron 二进制误入登录项,决策 8)。 * openAsHidden 配合托盘模式:自启后驻留托盘不弹窗。 */ function applyAutoStart(enabled) { if (!app.isPackaged) { return { ok: false, error: '开发模式下不支持开机自启,请使用打包后的客户端' } } try { app.setLoginItemSettings({ openAtLogin: enabled === true, openAsHidden: true }) return { ok: true } } catch (e) { return { ok: false, error: `设置开机自启失败:${e?.message || ''}` } } } ipcMain.handle('local-copy-attachment', async (event, payload) => { // 落点由主进程推导(home + server host + sessionId),渲染层无法指定。 // 异步拷贝:大文件的 copyFile 不阻塞主进程事件循环(曾用同步 copyFileSync, // 拖入接近上限的大附件会把整个应用卡死数十秒)。 return chatAttachment.copyLocalAttachmentAsync({ sourcePath: payload?.sourcePath, sessionId: payload?.sessionId, maxSizeMb: payload?.maxSizeMb, homeDir: os.homedir(), hostKey: getServerHostKey(), }) }) ipcMain.handle('local-write-text-file', async (event, payload) => { // 渲染层粘贴附件入口:同名避重,不覆盖已有附件。 // handleLocalWriteTextFile 回的是工具协议 { success, path, bytes_written } / { error }, // 渲染层与 local-copy-attachment 对齐,只认 { ok, absolutePath, error }。 // 直接把工具形态透传出去时,写盘成功也会被当成失败(error 为空 → 「写入失败」), // 粘贴超长文本发送会被整单取消。 const result = await handleLocalWriteTextFile(payload, payload?.sessionId, true) if (result?.success && result.path) { return { ok: true, absolutePath: result.path } } return { ok: false, error: result?.error || '写入失败' } }) ipcMain.handle('desktop-notify', (event, payload) => { const title = String(payload?.title ?? '') const body = String(payload?.body ?? '') const tag = payload?.tag != null ? String(payload.tag) : undefined const itemId = payload?.itemId ?? null const sessionId = payload?.sessionId ?? null if (!title) return { ok: false, error: '通知标题为空' } try { const notification = new Notification({ title, body, tag }) notification.on('click', () => { // 窗口隐藏到托盘时点击通知先唤起主窗,再把跳转事件交给渲染层。 // 窗口已销毁(退出模式 + macOS 关窗后点旧通知)时重建:否则 sendToRenderer // 因 mainWindow 为 null 静默丢弃,用户点了通知却什么都没有。 // 无论走哪条路径都先入待派发队列:渲染层监听可能尚未注册(应用刚启动、 // 页面加载中、重建窗口的渲染进程是新的),立即 send 会丢,由渲染层桥接 // 就绪后主动 consume 兜底;did-finish-load 补发仅在尚未被拉取时执行。 const clickPayload = { itemId, sessionId } pendingNotificationClick = clickPayload const win = mainWindow if (win && !win.isDestroyed()) { if (win.isMinimized()) win.restore() win.show() win.focus() sendToRenderer('desktop-notification-click', clickPayload) return } const created = createWindow() // 新窗口的渲染层(Vue 应用 + ipcRenderer.on)要等页面加载完才就绪:同步发 // 跳转事件会丢(窗口能出来但不跳会话),等 did-finish-load 再补发。 // 读队列与 send 同步完成,与 consume 不会交错:已被渲染层拉取时队列已空,跳过。 if (created && !created.isDestroyed() && created.webContents) { created.webContents.once('did-finish-load', () => { const pending = pendingNotificationClick if (!pending) return sendToRenderer('desktop-notification-click', pending) }) } }) notification.on('failed', () => { console.warn('[notify] display failed:', title) sendToRenderer('desktop-notify-result', { ok: false, error: '系统通知展示失败', tag }) }) notification.show() return { ok: true } } catch (e) { const error = e?.message || '通知发送失败' sendToRenderer('desktop-notify-result', { ok: false, error, tag }) return { ok: false, error } } }) ipcMain.handle('desktop-notification-click-consume', () => { // 渲染层桥接就绪后主动拉取:点击时监听尚未注册(应用刚启动/页面加载中/窗口 // 重建)导致的丢失由这里兜底。取出即清空——拉取过的点击不重复派发。 const payload = pendingNotificationClick pendingNotificationClick = null return payload }) ipcMain.handle('desktop-notification-click-ack', () => { // 渲染层已收到立即派发的点击深链:清除待派发队列,防止页面重载后 // 桥接重新就绪时拿陈旧点击重复跳转。 pendingNotificationClick = null return { ok: true } }) ipcMain.handle('desktop-badge', (event, payload) => { const count = Math.max(0, Math.floor(Number(payload?.count) || 0)) if (process.platform === 'darwin' && app.dock && typeof app.dock.setBadgeCount === 'function') { app.dock.setBadgeCount(count) } // win/linux no-op(overlay 角标列为后续可选) return { ok: true } }) ipcMain.handle('desktop-prefs-get', () => { return { ok: true, prefs: readDesktopPrefs(), packaged: app.isPackaged, platform: process.platform } }) // 本机设备身份(技术方案 2026-11-05 §4.2):deviceId 首次取用时生成并落盘(ensure), // 名字用主机名、平台用 process.platform——渲染层 WS auth 帧与建 LOCAL 会话时携带。 ipcMain.handle('device-info-get', () => { return { id: desktopPrefsLib.ensureDesktopDeviceId(app.getPath('userData')), name: os.hostname(), platform: process.platform, } }) ipcMain.handle('desktop-prefs-set', (event, patch) => { const next = patch && typeof patch === 'object' ? patch : {} // dev 模式禁止开启自启:直接拒绝,不落盘(防打包前的误开启在打包后生效) if (next.autoStart === true && !app.isPackaged) { return { ok: false, error: '开发模式下不支持开机自启,请使用打包后的客户端' } } const result = desktopPrefsLib.writeDesktopPrefs(app.getPath('userData'), next) let sideError = null if ('autoStart' in next) { const applied = applyAutoStart(result.prefs.autoStart) if (!applied.ok) sideError = applied.error } return { ok: true, prefs: result.prefs, error: sideError || undefined } }) ipcMain.handle('desktop-shortcut-set', (event, accel) => { const next = accel == null ? null : String(accel).trim() if (next === '') { return { ok: false, error: '快捷键不能为空(关闭请传 null)' } } const applied = applyGlobalShortcut(next) if (!applied.ok) { sendToRenderer('desktop-shortcut-failed', { accel: next, error: applied.error }) return { ok: false, error: applied.error } } const result = desktopPrefsLib.writeDesktopPrefs(app.getPath('userData'), { globalShortcut: next }) return { ok: true, prefs: result.prefs } }) app.whenReady().then(() => { buildApplicationMenu() createWindow() initTray() // 快捷键按偏好注册(默认 Alt+Shift+M);失败时回传提示 const shortcut = readDesktopPrefs().globalShortcut const registered = applyGlobalShortcut(shortcut) if (!registered.ok) { sendToRenderer('desktop-shortcut-failed', { accel: shortcut, error: registered.error }) } if (app.isPackaged) { setTimeout(() => { checkForAppUpdate().catch((error) => { console.error('[updater] initial check failed:', error) }) }, 3000) } }) app.on('window-all-closed', () => { // 托盘模式下窗口是 hide 而非 close,不会触发本事件;仅「退出模式」下走原逻辑 if (process.platform !== 'darwin') { app.quit() } }) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } else if (mainWindow && !mainWindow.isDestroyed()) { // 托盘化后红点关闭 = 隐藏:dock 点击应恢复而非新建窗口 if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.show() appTray?.refresh() } }) app.on('before-quit', () => { quitting = true // 快捷键全部注销后同步清掉跟踪值:否则退出若被中止,之后「重设同一个键」会命中 // 同键早退而跳过 register,键保持未注册但 UI/偏好显示已启用 currentShortcutAccel = null try { globalShortcut.unregisterAll() } catch (e) { console.warn('[shortcut] unregisterAll on quit failed:', e?.message) } terminalManager.killAll() localShell.closeAll() localShell.stopCleanup() // 关闭全部 MCP 连接(stdio 子进程 / HTTP 会话) const sessionIds = Array.from(mcpClients.keys()) sessionIds.forEach((sid) => { closeMcpSession(sid).catch((e) => console.warn('[mcp] quit cleanup failed:', e.message)) }) }) // ========== Auth token persistence (file:// localStorage is not reliable) ========== function getAuthStorePath() { return path.join(app.getPath('userData'), 'auth.json') } function readAuthStore() { try { const authPath = getAuthStorePath() if (fs.existsSync(authPath)) { const data = JSON.parse(fs.readFileSync(authPath, 'utf8')) // 服务端 host 变化后旧 token 不可用,避免串站 const currentHost = getServerHostKey() if (data.serverHost && data.serverHost !== currentHost) { return { token: null, refreshToken: null } } return { token: data.token || null, refreshToken: data.refreshToken || null } } } catch (e) { console.error('[auth] Failed to read auth store:', e.message) } return { token: null, refreshToken: null } } function writeAuthStore(data) { try { const authPath = getAuthStorePath() fs.mkdirSync(path.dirname(authPath), { recursive: true }) const payload = { token: data.token || null, refreshToken: data.refreshToken || null, serverHost: data.token || data.refreshToken ? getServerHostKey() : null, } fs.writeFileSync(authPath, JSON.stringify(payload), 'utf8') } catch (e) { console.error('[auth] Failed to write auth store:', e.message) } } ipcMain.handle('auth-get-tokens', () => readAuthStore()) ipcMain.handle('auth-set-tokens', (_event, { token, refreshToken }) => { writeAuthStore({ token: token || null, refreshToken: refreshToken || null }) }) ipcMain.handle('auth-clear-tokens', () => { writeAuthStore({ token: null, refreshToken: null }) }) // ========== Server baseUrl config IPC ========== ipcMain.handle('server-config-get', () => { const config = getServerConfig() const envOverride = getServerBaseUrlEnvOverride() const feed = serverConfigLib.resolveUpdateFeedUrl(config, getUpdateFeedUrlOverride()) return { serverBaseUrl: getEffectiveServerBaseUrl(), updateFeedMode: config.updateFeedMode || 'follow-site', allowHttp: config.allowHttp === true, configured: isServerConfigured(), envOverride: Boolean(envOverride), appVersion: app.getVersion(), hostKey: getServerHostKey(), apiBase: getApiBaseUrl(), updateFeed: feed, history: serverConfigLib.readServerHistory(app.getPath('userData')), } }) ipcMain.handle('server-config-probe', async (_event, payload = {}) => { const allowHttp = payload.allowHttp === true const normalized = serverConfigLib.normalizeServerBaseUrl(payload.serverBaseUrl, { allowHttp }) if (!normalized.ok) return { ok: false, error: normalized.error } return serverConfigLib.probeServer(normalized.baseUrl) }) ipcMain.handle('server-config-set', async (_event, payload = {}) => { return applyServerConfig(payload || {}) }) ipcMain.handle('server-config-open-picker', () => { openServerPickerWindow() return { ok: true } }) ipcMain.handle('server-picker-close', () => { if (serverPickerWindow && !serverPickerWindow.isDestroyed()) { serverPickerWindow.close() } return { ok: true } }) ipcMain.handle('server-config-remove-history', (_event, payload = {}) => { const target = String(payload?.serverBaseUrl || '').trim() if (!target) return { ok: false, error: '缺少地址', history: serverConfigLib.readServerHistory(app.getPath('userData')) } const history = serverConfigLib.removeServerHistory(app.getPath('userData'), target) return { ok: true, history } }) // ========== Window control IPC handlers ========== ipcMain.handle('window-minimize', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.minimize() } }) ipcMain.handle('window-maximize', () => { if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMaximized()) { mainWindow.unmaximize() } else { mainWindow.maximize() } } }) ipcMain.handle('window-close', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.close() } }) // ========== Original IPC handlers ========== ipcMain.handle('get-app-version', () => { return app.getVersion() }) ipcMain.handle('get-platform', () => { return process.platform }) ipcMain.handle('check-for-update', () => { return checkForAppUpdate() }) ipcMain.handle('install-update', () => { return installDownloadedUpdate() }) ipcMain.handle('get-environment-info', (event, { workspace } = {}) => { return { isGit: isGitWorkspace(workspace || currentWorkspace), platform: process.platform, shell: detectShell(), osVersion: buildOsVersion() } }) ipcMain.handle('git-repos', async (event, { workspace } = {}) => { try { return await listGitRepos(workspace || currentWorkspace) } catch (e) { return { isRootGit: false, repos: [], error: e.message || '扫描 Git 仓库失败' } } }) ipcMain.handle('git-status', async (event, { workspace, repoPath } = {}) => { try { return await getGitStatus(workspace || currentWorkspace, repoPath) } catch (e) { return { isGit: false, remoteStatusAvailable: false, error: e.message || '读取 Git 状态失败' } } }) ipcMain.handle('git-status-refresh', async (event, { workspace, repoPath } = {}) => { try { return await refreshGitStatus(workspace || currentWorkspace, repoPath) } catch (e) { return { isGit: false, remoteStatusAvailable: false, error: e.message || '刷新 Git 状态失败' } } }) ipcMain.handle('git-file-diff', async (event, { workspace, repoPath, path: filePath } = {}) => { try { return await getGitFileDiff(workspace || currentWorkspace, repoPath, filePath) } catch (e) { return { path: filePath || '', changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: e.message || '读取 Git diff 失败', } } }) ipcMain.handle('git-commit-input', async (_event, { workspace, repoPath } = {}) => { return buildCommitInput(workspace || currentWorkspace, repoPath) }) ipcMain.handle('git-commit', async (_event, { workspace, repoPath, message } = {}) => { return gitCommit(workspace || currentWorkspace, repoPath, message) }) ipcMain.handle('git-pull', async (_event, { workspace, repoPath } = {}) => { return gitPull(workspace || currentWorkspace, repoPath) }) ipcMain.handle('git-push', async (_event, { workspace, repoPath } = {}) => { return gitPush(workspace || currentWorkspace, repoPath) }) ipcMain.handle('git-log', async (_event, { workspace, repoPath, options } = {}) => { try { return await getGitLog(workspace || currentWorkspace, repoPath, options || {}) } catch (e) { return { error: e.message || '读取提交历史失败' } } }) ipcMain.handle('git-commit-files', async (_event, { workspace, repoPath, hash } = {}) => { try { return await getGitCommitFiles(workspace || currentWorkspace, repoPath, hash) } catch (e) { return { error: e.message || '读取提交文件清单失败' } } }) ipcMain.handle('git-commit-file-diff', async (_event, { workspace, repoPath, hash, path: filePath, oldPath } = {}) => { try { return await getGitCommitFileDiff(workspace || currentWorkspace, repoPath, hash, filePath, oldPath) } catch (e) { return { path: filePath || '', changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: e.message || '读取提交 diff 失败', } } }) // 分支对比:失败形状同 git-commit-files(对象形态的 { error },由 provider 侧解包后抛错), // 因为「ref 不存在 / 无共同祖先 / 对比失败」都必须与「两分支无差异(files: [])」可区分 ipcMain.handle('git-branch-compare', async (_event, { workspace, repoPath, base, target } = {}) => { try { return await getGitBranchCompare(workspace || currentWorkspace, repoPath, base, target) } catch (e) { return { error: e.message || '读取分支对比失败' } } }) // 分支对比单文件 diff:失败形状同 git-commit-file-diff(降级 diff 对象,不抛) ipcMain.handle('git-branch-compare-file-diff', async (_event, { workspace, repoPath, base, target, path: filePath, oldPath } = {}) => { try { return await getGitBranchCompareFileDiff(workspace || currentWorkspace, repoPath, base, target, filePath, oldPath) } catch (e) { return { path: filePath || '', changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: e.message || '读取分支 diff 失败', } } }) ipcMain.handle('git-branches', async (_event, { workspace, repoPath } = {}) => { try { return await getGitBranches(workspace || currentWorkspace, repoPath) } catch (e) { return { error: e.message || '读取分支列表失败' } } }) ipcMain.handle('git-stashes', async (_event, { workspace, repoPath } = {}) => { try { return await getGitStashes(workspace || currentWorkspace, repoPath) } catch (e) { return { error: e.message || '读取 stash 列表失败' } } }) ipcMain.handle('git-remotes', async (_event, { workspace, repoPath } = {}) => { try { return await getGitRemotes(workspace || currentWorkspace, repoPath) } catch (e) { return { error: e.message || '读取远端列表失败' } } }) ipcMain.handle('git-fetch', async (_event, { workspace, repoPath } = {}) => { return gitFetch(workspace || currentWorkspace, repoPath) }) ipcMain.handle('git-switch-branch', async (_event, { workspace, repoPath, branch, create } = {}) => { return gitSwitchBranch(workspace || currentWorkspace, repoPath, branch, create === true) }) ipcMain.handle('git-stash-op', async (_event, { workspace, repoPath, action, ref } = {}) => { return gitStashOp(workspace || currentWorkspace, repoPath, action, ref) }) ipcMain.handle('open-external', async (event, url) => { if (typeof url !== 'string' || !url.trim()) { throw new Error('URL is required') } const parsed = new URL(url) if (!['http:', 'https:'].includes(parsed.protocol)) { throw new Error('Unsupported external URL protocol') } await shell.openExternal(parsed.toString()) }) ipcMain.handle('open-feishu-auth-window', async (event, payload) => { const authUrl = typeof payload === 'string' ? payload : payload?.authUrl const callbackPath = typeof payload === 'object' && payload?.callbackPath ? payload.callbackPath : '/api/v1/auth/feishu/callback' return new Promise((resolve) => { const authWindow = new BrowserWindow({ width: 800, height: 700, title: '飞书登录', autoHideMenuBar: true, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, }, }) authWindow.loadURL(authUrl) let resolved = false function checkUrl(url) { if (resolved) return try { const parsed = new URL(url) if (parsed.pathname === callbackPath || parsed.pathname.endsWith(callbackPath)) { resolved = true const state = parsed.searchParams.get('state') || '' const code = parsed.searchParams.get('code') || '' if (!authWindow.isDestroyed()) { authWindow.close() } resolve({ state, code }) } } catch { // ignore invalid URLs } } authWindow.webContents.on('did-navigate', (_, url) => checkUrl(url)) authWindow.webContents.on('did-navigate-in-page', (_, url) => checkUrl(url)) authWindow.on('closed', () => { if (!resolved) { resolved = true resolve({ state: '' }) } }) }) }) ipcMain.handle('select-directory', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'], title: '选择工作目录' }) if (result.canceled || result.filePaths.length === 0) { return null } return result.filePaths[0] }) ipcMain.handle('open-folder', (event, folderPath) => { if (folderPath) shell.openPath(folderPath) }) ipcMain.handle('show-item-in-folder', (event, fullPath) => { if (fullPath) shell.showItemInFolder(fullPath) }) ipcMain.handle('open-terminal', (event, folderPath) => { if (!folderPath) return spawn('open', ['-a', 'Terminal', folderPath], { detached: true, stdio: 'ignore' }).unref() }) // ========== Terminal IPC handlers (node-pty) ========== ipcMain.handle('terminal:create', async (event, options) => { const result = await terminalManager.create(options) const session = terminalManager.sessions.get(result.id) session.pty.onData((data) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('terminal:data', { id: result.id, data }) } }) session.pty.onExit(({ exitCode }) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('terminal:exit', { id: result.id, exitCode }) } terminalManager.sessions.delete(result.id) }) return result }) ipcMain.on('terminal:data', (event, { id, data }) => { terminalManager.write(id, data) }) ipcMain.on('terminal:resize', (event, { id, cols, rows }) => { terminalManager.resize(id, cols, rows) }) ipcMain.handle('terminal:kill', (event, { id }) => { terminalManager.kill(id) }) ipcMain.handle('terminal:list', () => { return terminalManager.list() }) // ========== Local tool execution IPC handlers ========== ipcMain.handle('local-read-file', async (event, { path: filePath, offset, limit }) => { try { const resolvedPath = resolveWorkspacePath(filePath) if (mimeFromPath(filePath)) { return readLocalImage(resolvedPath, filePath) } if (filePath.toLowerCase().endsWith('.pdf')) { // PDF 预览:按字节读取并以 base64 data_uri 返回,由渲染进程转为 Blob 交给 pdf.js const buffer = fs.readFileSync(resolvedPath) return { content: `PDF 文件:${filePath}`, total_lines: 0, media_type: 'pdf', mime: 'application/pdf', data_uri: `data:application/pdf;base64,${buffer.toString('base64')}`, } } const content = fs.readFileSync(resolvedPath, 'utf-8') const lines = content.split('\n') const totalLines = lines.length const start = offset || 0 const end = limit ? Math.min(start + limit, totalLines) : totalLines const sliced = lines.slice(start, end).join('\n') return { content: sliced, total_lines: totalLines } } catch (e) { return { error: e.message } } }) // LOCAL 二进制字节读取(技术方案 §5.2):zip / xlsx / docx / 媒体预览的取数通道。 // 路径解析与 local-read-file 共用 resolveWorkspacePath;类型校验与大小上限在 // localFileBytes.cjs(可单测)。文件全程不出本机,无任何上传语义。 ipcMain.handle('local-read-bytes', async (event, { path: filePath }) => { try { const resolvedPath = resolveWorkspacePath(filePath) return readFileBytes(resolvedPath) } catch (e) { return { error: e.message } } }) ipcMain.handle('local-write-file', async (event, { path: filePath, content }) => { const approved = await requestToolApproval('write_file', filePath) if (!approved) { return { success: false, error: 'User denied file write.' } } try { const resolvedPath = resolveWorkspacePath(filePath) const fileExisted = fs.existsSync(resolvedPath) const beforeContent = fileExisted ? fs.readFileSync(resolvedPath, 'utf-8') : '' const dir = path.dirname(resolvedPath) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(resolvedPath, content, 'utf-8') const linesAdded = content.length === 0 ? 0 : content.split('\n').length const lineDelta = fileExisted ? computeLineDelta(beforeContent, content) : { linesAdded, linesDeleted: 0 } return { success: true, bytes_written: Buffer.byteLength(content, 'utf-8'), file_change: { path: filePath, type: fileExisted ? 'MODIFIED' : 'CREATED', lines_added: lineDelta.linesAdded, lines_deleted: lineDelta.linesDeleted }, [PRIVATE_DIFF_FIELD]: buildFileChangeDiff(filePath, beforeContent, content) } } catch (e) { return { error: e.message } } }) ipcMain.handle('local-edit-file', async (event, { path: filePath, old_string, new_string, replace_all }) => { const approved = await requestToolApproval('edit_file', filePath) if (!approved) { return { success: false, error: 'User denied file edit.' } } try { return writeEditedFile( resolveWorkspacePath(filePath), filePath, old_string, new_string, asBoolArg(replace_all), ) } catch (e) { return { error: e.message } } }) const IGNORED_DIRS = new Set(['node_modules', '__pycache__', '.git', 'target', 'dist', 'build', '.next', '.nuxt', '.venv', 'venv', '.idea', '.vscode']) ipcMain.handle('list-workspace-files', async (event, { workspace, filter, limit }) => { try { if (!workspace || !fs.existsSync(workspace)) return [] const maxResults = limit || 20 const results = [] function walkDir(dir, relativeBase) { if (results.length >= maxResults) return let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return } // Sort entries: files first, then dirs, alphabetically entries.sort((a, b) => { if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? 1 : -1 return a.name.localeCompare(b.name) }) for (const entry of entries) { if (results.length >= maxResults) return if (entry.name.startsWith('.')) continue if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue const fullPath = path.join(dir, entry.name) const relPath = relativeBase ? relativeBase + '/' + entry.name : entry.name if (entry.isDirectory()) { walkDir(fullPath, relPath) } else { try { const stat = fs.statSync(fullPath) if (!filter || entry.name.toLowerCase().includes(filter.toLowerCase()) || relPath.toLowerCase().includes(filter.toLowerCase())) { results.push({ path: relPath, name: entry.name, size: stat.size, mtime: stat.mtimeMs }) } } catch { // skip files we can't stat } } } } walkDir(workspace, '') // Sort by modification time descending results.sort((a, b) => b.mtime - a.mtime) // Remove mtime from response return results.map(({ mtime, ...rest }) => rest) } catch (e) { return [] } }) ipcMain.handle('list-directory', async (event, { dirPath, workspace }) => { try { if (!dirPath || !workspace) return { error: 'Missing dirPath or workspace' } const resolvedDir = path.resolve(dirPath) const resolvedWorkspace = path.resolve(workspace) if (!resolvedDir.startsWith(resolvedWorkspace + path.sep) && resolvedDir !== resolvedWorkspace) { return { error: 'Access denied: path outside workspace' } } if (!fs.existsSync(resolvedDir)) return { error: 'Directory does not exist' } const entries = fs.readdirSync(resolvedDir, { withFileTypes: true }) const results = [] const MAX_ENTRIES = 500 for (const entry of entries) { if (results.length >= MAX_ENTRIES) break const fullPath = path.join(resolvedDir, entry.name) const relPath = path.relative(resolvedWorkspace, fullPath) let isSymlink = false let size = 0 let mtime = 0 try { const lstat = fs.lstatSync(fullPath) isSymlink = lstat.isSymbolicLink() size = lstat.size mtime = lstat.mtimeMs } catch { // skip entries we can't stat continue } results.push({ name: entry.name, path: relPath, isDirectory: entry.isDirectory() && !isSymlink, size, mtime, isSymlink }) } // Sort: folders first alphabetically, then files alphabetically results.sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 return a.name.localeCompare(b.name) }) return { entries: results, truncated: entries.length > MAX_ENTRIES } } catch (e) { return { error: e.message } } }) // ========== MCP (Model Context Protocol) support ========== // LOCAL 模式下桌面端作为 MCP 客户端代理:服务端下发 mcp_sync_required, // 主进程用官方 Node SDK 建立连接并上报工具清单;工具执行走 tool-execute 委托。 const { Client: McpClient } = require('@modelcontextprotocol/sdk/client') const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js') const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js') const { getDefaultEnvironment } = require('@modelcontextprotocol/sdk/client/stdio.js') const MCP_TOOL_PREFIX = 'mcp__' const MCP_CALL_TIMEOUT_MS = 120000 /** * sessionId -> serverName -> { client, transport, tools } * @type {Map>} */ const mcpClients = new Map() function mcpServerKey(serverName) { return String(serverName || '') } /** 解析 mcp__{serverName}__{toolName},非法名返回 null */ function parseMcpToolName(toolName) { if (!toolName || !toolName.startsWith(MCP_TOOL_PREFIX)) return null const rest = toolName.slice(MCP_TOOL_PREFIX.length) const sep = rest.indexOf('__') if (sep <= 0) return null const serverName = rest.slice(0, sep) const innerName = rest.slice(sep + 2) if (!serverName || !innerName) return null return { serverName, toolName: innerName } } async function connectMcpServer(sessionId, server) { const { name, type, command, args, url, env } = server || {} if (!name) throw new Error('MCP server missing name') let transport if (type === 'STDIO') { if (!command) throw new Error(`MCP server "${name}": STDIO requires command`) // 合并默认安全环境(PATH/HOME 等),再叠加管理员配置的环境变量 const mergedEnv = { ...getDefaultEnvironment() } if (env && typeof env === 'object') { for (const [k, v] of Object.entries(env)) { if (v != null) mergedEnv[k] = String(v) } } transport = new StdioClientTransport({ command, args: Array.isArray(args) ? args.map(String) : [], env: mergedEnv, stderr: 'pipe' }) } else if (type === 'HTTP') { if (!url) throw new Error(`MCP server "${name}": HTTP requires url`) transport = new StreamableHTTPClientTransport(new URL(url)) } else { throw new Error(`MCP server "${name}": unsupported type "${type}"`) } const client = new McpClient({ name: 'mao-desktop', version: require('../package.json').version || '1.0.0' }) await client.connect(transport) const list = await client.listTools() const tools = (list.tools || []).map((t) => ({ name: t.name, description: t.description || '', schema: t.inputSchema || { type: 'object', properties: {} }, // 透传 MCP 标准注解:服务端工作集用它标注 [只读]、审批据此对明确只读的工具免审。 annotations: t.annotations || null })) return { client, transport, tools } } async function closeMcpSession(sessionId) { const connections = mcpClients.get(Number(sessionId)) if (!connections) return mcpClients.delete(Number(sessionId)) const names = Array.from(connections.keys()) await Promise.allSettled(names.map(async (serverName) => { try { const conn = connections.get(serverName) if (conn?.client) { await conn.client.close() } } catch (e) { console.warn(`[mcp] close failed for ${serverName}:`, e.message) } })) console.log(`[mcp] closed ${names.length} connection(s) for session ${sessionId}`) } async function syncMcpServers(sessionId, servers) { const sid = Number(sessionId) // 先清理旧连接,避免重复同步时泄漏 await closeMcpSession(sid) const connections = new Map() mcpClients.set(sid, connections) const reports = [] for (const server of servers || []) { const name = server?.name if (!name) continue try { const conn = await connectMcpServer(sid, server) connections.set(mcpServerKey(name), conn) reports.push({ name, connected: true, tools: conn.tools, error: null }) console.log(`[mcp] connected server "${name}" for session ${sid}, ${conn.tools.length} tool(s)`) } catch (e) { console.warn(`[mcp] connect failed for "${name}" (session ${sid}):`, e.message) reports.push({ name, connected: false, tools: [], error: e.message }) } } return reports } async function callMcpTool(sessionId, serverName, toolName, args) { const sid = Number(sessionId) const connections = mcpClients.get(sid) const conn = connections && connections.get(mcpServerKey(serverName)) if (!conn?.client) { throw new Error(`MCP server "${serverName}" is not connected (session ${sid}). Re-run the session to re-sync MCP servers.`) } const timer = setTimeout(() => { console.warn(`[mcp] call ${serverName}.${toolName} timed out after ${MCP_CALL_TIMEOUT_MS}ms`) }, MCP_CALL_TIMEOUT_MS) try { const result = await conn.client.callTool({ name: toolName, arguments: args }, undefined, { timeout: MCP_CALL_TIMEOUT_MS }) if (result?.isError) { throw new Error(formatCallToolResult(result) || `MCP tool ${serverName}.${toolName} returned an error`) } // 传入完整 result,保留顶层 structuredContent(部分服务器主要或仅返回该字段) return formatCallToolResult(result) } finally { clearTimeout(timer) } } /** * 将 MCP CallToolResult 转为文本(与后端 Java McpClientManager.formatResult 语义一致): * - 顶层 structuredContent 非空时优先序列化输出(结构化数据更利于模型理解); * - 否则拼接 content 中的文本/图片/资源内容。 */ function formatCallToolResult(result) { if (!result) return '' if (result.structuredContent != null) { try { return JSON.stringify(result.structuredContent, null, 2) } catch { /* fallthrough */ } } return formatMcpContent(result.content) } function formatMcpContent(content) { if (content == null) return '' if (typeof content === 'string') return content if (!Array.isArray(content)) { try { return JSON.stringify(content) } catch { return String(content) } } const parts = [] for (const item of content) { if (!item) continue if (item.type === 'text' && typeof item.text === 'string') { parts.push(item.text) } else if (item.type === 'image' && item.data) { parts.push(`[image data: ${item.mimeType || 'unknown'}, ${item.data.length} chars base64]`) } else if (item.type === 'resource' && item.resource) { parts.push(`[resource: ${item.resource.uri || ''}]\n${item.resource.text || ''}`) } else if (item.structuredContent != null) { try { parts.push(JSON.stringify(item.structuredContent, null, 2)) } catch { /* ignore */ } } else { try { parts.push(JSON.stringify(item)) } catch { /* ignore */ } } } return parts.filter(Boolean).join('\n') } ipcMain.handle('mcp-sync', async (event, { sessionId, servers }) => { if (!sessionId) return { success: false, error: 'No sessionId provided for mcp-sync.' } try { const reports = await syncMcpServers(sessionId, servers || []) return { success: true, reports } } catch (e) { console.error('[mcp] mcp-sync failed:', e) return { success: false, error: e.message, reports: [] } } }) ipcMain.handle('mcp-close', async (event, { sessionId }) => { try { await closeMcpSession(Number(sessionId)) return { success: true } } catch (e) { return { success: false, error: e.message } } }) // ========== Tool execution via Streaming WS ========== async function executeToolByName(toolName, parsedArgs, sessionId, workspace, needApproval, dangerReason, requestId, approvalHint) { switch (toolName) { case 'shell': return await handleShellFromWebSocket(parsedArgs, sessionId, workspace, needApproval, dangerReason, requestId, approvalHint) case 'read_file': return await handleLocalReadFile(parsedArgs, workspace, sessionId) case 'write_file': return await handleLocalWriteFile(parsedArgs, workspace, sessionId, needApproval, requestId) case 'edit_file': return await handleLocalEditFile(parsedArgs, workspace, sessionId, needApproval, requestId) case 'glob_search': return await handleLocalGlobSearch(parsedArgs, workspace, sessionId) case 'grep_search': return await handleLocalGrepSearch(parsedArgs, workspace, sessionId) case 'write_local_text_file': // dump_user_messages 委托:文件名由消息 id 决定,重复导出覆盖同一文件(幂等,dedupe=false)。 return await handleLocalWriteTextFile(parsedArgs, sessionId, false) default: { const mcp = parseMcpToolName(toolName) if (mcp) { return await handleMcpToolFromWebSocket(mcp, parsedArgs, sessionId, needApproval, dangerReason, requestId, approvalHint) } return { error: `Unknown tool: ${toolName}` } } } } async function handleMcpToolFromWebSocket(mcp, parsedArgs, sessionId, needApproval, dangerReason, requestId, approvalHint) { // 与 shell/write_file 等一致:needApproval 时先征求用户审批再执行 if (needApproval) { const description = `调用 MCP 工具 ${mcp.serverName}.${mcp.toolName}` const approved = await requestToolApproval(`mcp:${mcp.serverName}`, description, sessionId, dangerReason, requestId, approvalHint) if (!approved) { return { error: 'User denied MCP tool execution.' } } } try { const result = await callMcpTool(sessionId, mcp.serverName, mcp.toolName, parsedArgs) return { result } } catch (e) { console.error(`[mcp] tool ${mcp.serverName}.${mcp.toolName} execution failed:`, e) return { error: e.message || 'MCP tool execution failed' } } } ipcMain.handle('tool-execute', async (event, { toolName, args, requestId, workspace, sessionId, needApproval, dangerReason, approvalHint }) => { // Use the workspace from this specific tool call, falling back to currentWorkspace const effectiveWorkspace = workspace || currentWorkspace console.log('[tool-execute] received workspace:', workspace, ', effectiveWorkspace:', effectiveWorkspace, ', currentWorkspace before:', currentWorkspace, ', needApproval:', needApproval, ', dangerReason:', dangerReason) if (workspace) { console.log('[tool-execute] setting currentWorkspace:', workspace, '(was:', currentWorkspace, ')') currentWorkspace = workspace } let parsedArgs try { parsedArgs = typeof args === 'string' ? JSON.parse(args) : args } catch { parsedArgs = {} } try { const result = await executeToolByName(toolName, parsedArgs, sessionId, effectiveWorkspace, !!needApproval, dangerReason, requestId, approvalHint) return { requestId, result: JSON.stringify(result), error: null } } catch (e) { console.error(`Tool ${toolName} execution failed:`, e) return { requestId, result: null, error: e.message } } }) async function handleShellFromWebSocket(args, sessionId, workspace, needApproval, dangerReason, requestId, approvalHint) { return localShell.handle(args, { conversationId: sessionId, workspace, needApproval, approve: (description) => ensureShellApproval(needApproval, description, sessionId, dangerReason, requestId, approvalHint), }) } function extractFilePath(args) { return args.path || args.file || args.filePath || args.file_path || args.target_file } const IMAGE_EXTENSIONS = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' } const MAX_IMAGE_BYTES = 5 * 1024 * 1024 function mimeFromPath(filePath) { const lower = filePath.toLowerCase() for (const [ext, mime] of Object.entries(IMAGE_EXTENSIONS)) { if (lower.endsWith(ext)) return mime } return null } function detectMimeFromBytes(buffer) { if (!buffer || buffer.length < 12) return null if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png' if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg' if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return 'image/gif' if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) return 'image/webp' return null } function formatImageSize(bytes) { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } function readLocalImage(resolvedPath, filePath) { const sizeBytes = fs.statSync(resolvedPath).size if (sizeBytes > MAX_IMAGE_BYTES) { return { content: `错误:文件过大(${formatImageSize(sizeBytes)}),图片读取上限为 ${formatImageSize(MAX_IMAGE_BYTES)}:${filePath}`, total_lines: 0 } } const buffer = fs.readFileSync(resolvedPath) const mime = detectMimeFromBytes(buffer) if (!mime) { return { content: `错误:不支持的图片格式或文件内容无效:${filePath}`, total_lines: 0 } } let outBuffer = buffer let outMime = mime let width = null let height = null let dimNote = '' const resized = promptImageResizer.resizeForPrompt(buffer, mime, nativeImage) if (resized) { outBuffer = resized.buffer outMime = resized.mime width = resized.width height = resized.height if (resized.resized) { try { const orig = nativeImage.createFromBuffer(buffer) if (!orig.isEmpty()) { const { width: ow, height: oh } = orig.getSize() if (ow && oh && (ow !== width || oh !== height)) { dimNote = `, ${ow}×${oh}→${width}×${height}` } } } catch { /* ignore */ } if (!dimNote && width && height) { dimNote = `, ${width}×${height}` } } else if (width && height) { dimNote = `, ${width}×${height}` } } const outSize = outBuffer.length const dataUri = `data:${outMime};base64,${outBuffer.toString('base64')}` return { content: `图片读取成功:${filePath} (${outMime}, ${formatImageSize(outSize)}${dimNote})`, total_lines: 0, media_type: 'image', mime: outMime, path: filePath, size_bytes: outSize, ...(width != null ? { width } : {}), ...(height != null ? { height } : {}), data_uri: dataUri } } async function handleLocalReadFile(args, workspace, maoSessionId) { try { const filePath = extractFilePath(args) if (!filePath) { return { content: '错误:缺少必填参数 path', total_lines: 0 } } const resolvedPath = resolveWorkspacePath(filePath, workspace, maoSessionId) if (!fs.existsSync(resolvedPath)) { return { content: `错误:文件不存在:${filePath}`, total_lines: 0 } } if (!fs.statSync(resolvedPath).isFile()) { return { content: `错误:不是普通文件:${filePath}`, total_lines: 0 } } if (mimeFromPath(filePath)) { return readLocalImage(resolvedPath, filePath) } const content = fs.readFileSync(resolvedPath, 'utf-8') const lines = content.split('\n') const start = args.offset || 0 const end = args.limit ? Math.min(start + args.limit, lines.length) : lines.length return { content: lines.slice(start, end).join('\n'), total_lines: lines.length } } catch (e) { return { content: `错误:${e.message}`, total_lines: 0 } } } async function handleLocalWriteFile(args, workspace, sessionId, needApproval, requestId) { if (needApproval) { const approved = await requestToolApproval('write_file', args.path, sessionId, null, requestId) if (!approved) { return { success: false, error: 'User denied file write.' } } } try { const resolvedPath = resolveWorkspacePath(args.path, workspace, sessionId) // Snapshot before write for change tracking const fileExisted = fs.existsSync(resolvedPath) let beforeContent = '' if (fileExisted) { beforeContent = fs.readFileSync(resolvedPath, 'utf-8') } const dir = path.dirname(resolvedPath) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(resolvedPath, args.content, 'utf-8') // Compute file change stats const newLineCount = args.content.length === 0 ? 0 : args.content.split('\n').length const lineDelta = fileExisted ? computeLineDelta(beforeContent, args.content) : { linesAdded: newLineCount, linesDeleted: 0 } return { success: true, bytes_written: Buffer.byteLength(args.content, 'utf-8'), file_change: { path: args.path, type: fileExisted ? 'MODIFIED' : 'CREATED', lines_added: lineDelta.linesAdded, lines_deleted: lineDelta.linesDeleted }, [PRIVATE_DIFF_FIELD]: buildFileChangeDiff(args.path, beforeContent, args.content) } } catch (e) { return { error: e.message } } } async function handleLocalEditFile(args, workspace, sessionId, needApproval, requestId) { if (needApproval) { const approved = await requestToolApproval('edit_file', args.path, sessionId, null, requestId) if (!approved) { return { success: false, error: 'User denied file edit.' } } } try { const resolvedPath = resolveWorkspacePath(args.path, workspace, sessionId) return writeEditedFile( resolvedPath, args.path, args.old_string, args.new_string, asBoolArg(args.replace_all), ) } catch (e) { return { error: e.message } } } /** * LOCAL 文本落盘(dump_user_messages 的委托入口 与 渲染层粘贴附件两个来源): * 把文本写到 `~/.mao/attachments///`,返回本机绝对路径。 * 落点由主进程推导(home + server host + sessionId),渲染层/服务端无法指定; * 写入失败不抛异常,以 { error } 形态回传(与其它 LOCAL 工具一致)。 * * @param {object} args { fileName, content, maxSizeMb } * @param {number|string} sessionId 会话 ID * @param {boolean} dedupe 同名避重:true 追加 -1/-2 副本(粘贴附件),false 覆盖(dump 幂等) */ async function handleLocalWriteTextFile(args, sessionId, dedupe) { const result = await chatAttachment.writeLocalTextFileAsync({ fileName: args?.fileName, content: args?.content, maxSizeMb: args?.maxSizeMb, sessionId, dedupe, homeDir: os.homedir(), hostKey: getServerHostKey(), }) if (!result.ok) return { error: result.error } return { success: true, path: result.absolutePath, bytes_written: result.bytes } } // --- glob_search / grep_search --- let rgAvailable = null async function isRgAvailable() { if (rgAvailable !== null) return rgAvailable try { const { execFile } = require('child_process') await new Promise((resolve, reject) => { execFile('rg', ['--version'], (err) => err ? reject(err) : resolve()) }) rgAvailable = true } catch { rgAvailable = false } return rgAvailable } async function handleLocalGlobSearch(args, workspace, maoSessionId) { const pattern = args.pattern if (!pattern) return { files: [], error: 'pattern is required' } const headLimit = args.head_limit || 100 const resolvedPath = args.path ? resolveWorkspacePath(args.path, workspace, maoSessionId) : (workspace || currentWorkspace || '.') try { const scope = resolveSearchScope(resolvedPath) let files = [] if (await isRgAvailable()) { files = await globWithRg(pattern, scope, headLimit) } else { files = globWithNode(pattern, scope, headLimit) } const truncated = files.length >= headLimit return { files, search_root: scope.cwd, truncated, total_matched: files.length } } catch (e) { return { files: [], error: e.message } } } function resolveSearchScope(resolvedPath) { let stat try { stat = fs.statSync(resolvedPath) } catch { throw new Error(`Search path does not exist or is not accessible: ${resolvedPath}`) } if (stat.isFile()) { const cwd = path.dirname(resolvedPath) return { cwd, rgTarget: path.basename(resolvedPath), singleFile: resolvedPath } } return { cwd: resolvedPath, rgTarget: '.', singleFile: null } } function outputFilePath(scope, rgFilePath, workspaceRoot) { if (scope.singleFile) { const normalized = path.normalize(scope.singleFile) if (workspaceRoot && normalized.startsWith(path.normalize(workspaceRoot))) { return path.relative(workspaceRoot, normalized) } return path.basename(normalized) } return relativizeRgPath(rgFilePath, scope.cwd) } function relativizeRgPath(filePath, searchRoot) { const trimmed = filePath.startsWith('./') ? filePath.slice(2) : filePath if (path.isAbsolute(trimmed)) { const normalized = path.normalize(trimmed) return normalized.startsWith(searchRoot) ? path.relative(searchRoot, normalized) : normalized } return path.normalize(trimmed) } function globWithRg(pattern, scope, headLimit) { return new Promise((resolve, reject) => { const { execFile } = require('child_process') execFile('rg', ['--files', '--glob', pattern, scope.rgTarget], { cwd: scope.cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { if (err && !stdout) return reject(err) const lines = (stdout || '').split('\n').filter(Boolean).slice(0, headLimit) resolve(lines.map(l => relativizeRgPath(l, scope.cwd))) }) }) } function globWithNode(pattern, scope, headLimit) { const { minimatch } = require('minimatch') const files = [] const searchRoot = scope.cwd function walk(dir) { if (files.length >= headLimit) return let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { if (files.length >= headLimit) return const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { if (entry.name === 'node_modules' || entry.name === '.git') continue walk(fullPath) } else if (entry.isFile()) { const relative = path.relative(searchRoot, fullPath) if (minimatch(relative, pattern) || minimatch(entry.name, pattern)) { files.push(relative) } } } } walk(searchRoot) return files } async function handleLocalGrepSearch(args, workspace, maoSessionId) { const pattern = args.pattern if (!pattern) return { matches: [], error: 'pattern is required' } const glob = args.glob || null const ignoreCase = args.ignore_case || false const contextLines = args.context_lines || 0 const maxOutputChars = args.max_output_chars || 10000 const resolvedPath = args.path ? resolveWorkspacePath(args.path, workspace, maoSessionId) : (workspace || currentWorkspace || '.') const workspaceRoot = workspace || currentWorkspace || '.' try { const scope = resolveSearchScope(resolvedPath) if (await isRgAvailable()) { return await grepWithRg(pattern, scope, workspaceRoot, glob, ignoreCase, contextLines, maxOutputChars) } else { return grepWithNode(pattern, scope, workspaceRoot, glob, ignoreCase, contextLines, maxOutputChars) } } catch (e) { return { matches: [], error: e.message } } } function grepWithRg(pattern, scope, workspaceRoot, glob, ignoreCase, contextLines, maxOutputChars) { return new Promise((resolve, reject) => { const { execFile } = require('child_process') const cmd = ['--line-number', '--no-heading'] if (ignoreCase) cmd.push('--ignore-case') if (contextLines > 0) { cmd.push('--context'); cmd.push(String(contextLines)) } if (glob) { cmd.push('--glob'); cmd.push(glob) } cmd.push(pattern) cmd.push(scope.rgTarget) execFile('rg', cmd, { cwd: scope.cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { if (err && !stdout) return resolve({ matches: [], truncated: false, total_matches: 0 }) const lines = (stdout || '').split('\n').filter(Boolean) const matches = [] let totalMatches = 0 let charsUsed = 0 let truncated = false for (const line of lines) { if (charsUsed + line.length + 1 > maxOutputChars) { truncated = true break } const parsed = parseRgLine(line, scope, workspaceRoot) if (parsed) { matches.push(parsed) totalMatches++ charsUsed += line.length + 1 } } resolve({ matches, truncated, total_matches: totalMatches }) }) }) } function parseRgLine(line, scope, workspaceRoot) { if (scope.singleFile) { return parseRgSingleFileLine(line, scope, workspaceRoot) } const firstColon = line.indexOf(':') if (firstColon < 0) return null const secondColon = line.indexOf(':', firstColon + 1) if (secondColon < 0) return null const filePath = line.substring(0, firstColon) const lineNumStr = line.substring(firstColon + 1, secondColon) const content = line.substring(secondColon + 1) const lineNum = parseInt(lineNumStr, 10) if (isNaN(lineNum)) { const firstDash = line.indexOf('-') if (firstDash < 0) return null const secondDash = line.indexOf('-', firstDash + 1) if (secondDash < 0) return null const dashLineNum = parseInt(line.substring(firstDash + 1, secondDash), 10) if (isNaN(dashLineNum)) return null return { file: outputFilePath(scope, line.substring(0, firstDash), workspaceRoot), line: dashLineNum, content: line.substring(secondDash + 1) } } return { file: outputFilePath(scope, filePath, workspaceRoot), line: lineNum, content } } function parseRgSingleFileLine(line, scope, workspaceRoot) { const colon = line.indexOf(':') const dash = line.indexOf('-') let sepIdx if (colon > 0 && (dash < 0 || colon < dash)) { sepIdx = colon } else if (dash > 0) { sepIdx = dash } else { return null } const lineNum = parseInt(line.substring(0, sepIdx), 10) if (isNaN(lineNum)) return null return { file: outputFilePath(scope, '', workspaceRoot), line: lineNum, content: line.substring(sepIdx + 1) } } function grepWithNode(pattern, scope, workspaceRoot, glob, ignoreCase, contextLines, maxOutputChars) { const { minimatch } = require('minimatch') const flags = ignoreCase ? 'i' : '' let regex try { regex = new RegExp(pattern, flags) } catch { return { matches: [], error: 'Invalid regex pattern: ' + pattern } } const matches = [] let totalMatches = 0 let charsUsed = 0 let truncated = false function scanFile(fullPath) { if (truncated) return if (glob && !minimatch(path.basename(fullPath), glob)) return let lines try { lines = fs.readFileSync(fullPath, 'utf-8').split('\n') } catch { return } const relative = outputFilePath(scope, fullPath, workspaceRoot) for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { const match = { file: relative, line: i + 1, content: lines[i] } if (contextLines > 0) { const before = [] for (let c = Math.max(0, i - contextLines); c < i; c++) before.push(lines[c]) const after = [] for (let c = i + 1; c <= Math.min(lines.length - 1, i + contextLines); c++) after.push(lines[c]) if (before.length > 0) match.context_before = before if (after.length > 0) match.context_after = after } const entrySize = JSON.stringify(match).length if (charsUsed + entrySize > maxOutputChars) { truncated = true break } matches.push(match) totalMatches++ charsUsed += entrySize } } } function walk(dir) { if (truncated) return let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { if (truncated) return const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { if (entry.name === 'node_modules' || entry.name === '.git') continue walk(fullPath) } else if (entry.isFile()) { scanFile(fullPath) } } } if (scope.singleFile) { scanFile(scope.singleFile) } else { walk(scope.cwd) } return { matches, truncated, total_matches: totalMatches } }