// main.js —— 主进程:窗口、IPC、GitHub(isomorphic-git)、市场 API。 // 市场地址/密钥【只在主进程】,绝不下发前端。契约见 CONTRACT.md。 const { app, BrowserWindow, ipcMain, dialog, shell, Tray, Menu, nativeImage } = require("electron"); const { autoUpdater } = require("electron-updater"); const path = require("path"); const os = require("os"); const fs = require("fs"); const git = require("isomorphic-git"); const gitHttp = require("isomorphic-git/http/node"); const codex = require("./lib/codex"); const purge = require("./lib/purge"); const secrets = require("./lib/secrets"); const sessions = require("./lib/sessions"); const providerSync = require("./lib/provider_sync"); const router = require("./lib/router"); const canvas = require("./lib/canvas"); const voice = require("./lib/voice_cdp"); const proxy = require("./lib/proxy"); const memory = require("./lib/memory"); const { extractZip } = require("./lib/zip"); const hotstate = require("./lib/hotstate"); const hotupdate = require("./lib/hotupdate"); // —— 应用根目录(热更新)—— // APP_ROOT:有效代码/界面所在根(可能是包内,也可能是 userData/hot/),由 bootstrap 挂到 global。 // BUNDLE_ROOT:恒指包内(node_modules / build 图标等原生/打包资源在此)。 // 直接运行 main.js(无 bootstrap)时回退到相对 __dirname 的默认值。 const BUNDLE_ROOT = global.__BUNDLE_ROOT__ || path.join(__dirname, ".."); const APP_ROOT = global.__APP_ROOT__ || path.join(__dirname, ".."); // —— 常量(契约冻结,前端不可见)—— const MARKET_URL = "https://sc.tokendun.cc"; // 市场写入密钥不再写死在客户端;改由管理员在「设置」里填「管理员密钥」(= 服务器 CODEX_REGISTRY_API_KEY)。 const STAGING = path.join(os.tmpdir(), "codex-market-staging"); const CLONE_ROOT = path.join(os.tmpdir(), "codex-market-clone"); const TOOLBOX_DATA_ROOT = (() => { try { const userData = app.getPath("userData"); if (userData) { const name = path.basename(userData); if (name.includes("codex-market")) { return path.join(path.dirname(userData), name.replace("codex-market", "tokendun-codex-client")); } } const appData = app.getPath("appData"); if (appData) return path.join(appData, "tokendun-codex-client"); } catch {} return path.join(os.homedir(), "Library/Application Support/tokendun-codex-client"); })(); const SKILL_STORE = path.join(TOOLBOX_DATA_ROOT, "skills"); const PLUGIN_STORE = path.join(TOOLBOX_DATA_ROOT, "plugins"); function pathContains(parent, child) { const rel = path.relative(parent, child); return rel && !rel.startsWith("..") && !path.isAbsolute(rel); } function ensureSymlink(target, link) { if (!fs.existsSync(target)) throw new Error(`软链目标不存在:${target}`); fs.mkdirSync(path.dirname(link), { recursive: true }); try { fs.rmSync(link, { recursive: true, force: true }); } catch {} fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir"); } function moveDirSync(src, dest) { fs.mkdirSync(path.dirname(dest), { recursive: true }); try { fs.renameSync(src, dest); } catch (e) { if (e && e.code === "EXDEV") { fs.cpSync(src, dest, { recursive: true }); fs.rmSync(src, { recursive: true, force: true }); return; } throw e; } } let mainWindow = null; let tray = null; let isQuitting = false; let trayHinted = false; // Windows:首次最小化到托盘时弹一次气泡提示 let lastPull = null; // { base, items } —— gh-restore 用上次 pull 的结果 // —— 日志:主进程 → 前端 api.onLog —— function log(msg) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("log", String(msg)); } } // --------------------------------------------------------------------------- // 窗口 // --------------------------------------------------------------------------- function createWindow() { mainWindow = new BrowserWindow({ width: 1180, height: 820, minWidth: 900, minHeight: 640, backgroundColor: "#0f1117", title: "TD工具箱", webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, }, }); mainWindow.removeMenu(); mainWindow.loadFile(path.join(APP_ROOT, "renderer", "index.html")); // 热更新健康确认:渲染层成功加载 → 把当前热更版本标记为「已确认健康」(防砖回退点) mainWindow.webContents.on("did-finish-load", () => { if (APP_ROOT !== BUNDLE_ROOT) { try { hotstate.confirmHealthy(global.__HOT_BASE__, path.basename(APP_ROOT)); } catch {} } }); // 关闭窗口 = 最小化到托盘(代理继续在后台跑),而非退出 mainWindow.on("close", (e) => { if (!isQuitting) { e.preventDefault(); mainWindow.hide(); if (process.platform === "darwin" && app.dock) app.dock.hide(); // Windows:首次最小化提示用户「仍在后台运行」,避免误以为已关闭而担心断开 if (process.platform === "win32" && tray && !trayHinted) { trayHinted = true; try { tray.displayBalloon({ title: "TD工具箱仍在后台运行", content: "已最小化到系统托盘(会话记忆 / 路由 / 代理继续工作)。需要彻底退出请右键托盘图标选「退出」。", }); } catch {} } } }); } function showWindow() { if (!mainWindow || mainWindow.isDestroyed()) createWindow(); else mainWindow.show(); if (process.platform === "darwin" && app.dock) app.dock.show(); } function rebuildTrayMenu() { if (!tray) return; const st = router.status(); const groupItems = st.groups.map((g) => ({ label: (g.id === st.activeId ? "● " : "○ ") + g.name, click: () => { router.setActive(g.id); rebuildTrayMenu(); if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("router-changed", router.status()); }, })); const px = proxy.status(); const pxLabel = px.mode === "global" ? "全局代理:运行中(整机 TUN)" : px.mode === "system" ? "系统代理:运行中" : "代理:未开启"; const menu = Menu.buildFromTemplate([ { label: `本地路由:${st.running ? "运行中 " + st.address : "未启动"}`, enabled: false }, { type: "separator" }, { label: "切换分组", enabled: false }, ...(groupItems.length ? groupItems : [{ label: "(无分组)", enabled: false }]), { type: "separator" }, { label: pxLabel, enabled: false }, ...(px.running ? [{ label: "关闭代理", click: () => proxy.stop().catch(() => {}) }] : []), { type: "separator" }, { label: "打开主界面", click: showWindow }, { label: "退出", click: () => quitApp() }, ]); tray.setContextMenu(menu); tray.setToolTip("TD工具箱 · 本地路由"); } function setupTray() { if (tray) return; // Windows 托盘用 .ico(清晰、缩放正确),其它平台用 .png const iconName = process.platform === "win32" ? "icon.ico" : "icon.png"; let img = nativeImage.createFromPath(path.join(BUNDLE_ROOT, "build", iconName)); if (img.isEmpty()) img = nativeImage.createFromPath(path.join(BUNDLE_ROOT, "build", "icon.png")); // png 需缩放到托盘尺寸;ico 交给系统按需取合适尺寸,不缩放 if (!img.isEmpty() && process.platform !== "win32") img = img.resize({ width: 18, height: 18 }); tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img); tray.on("click", showWindow); tray.on("double-click", showWindow); rebuildTrayMenu(); } // 单实例锁:避免两个实例互相覆盖 AGENTS.md / 会话记忆库(曾出现"旧进程覆盖新内容") if (!app.requestSingleInstanceLock()) { app.quit(); } else { app.on("second-instance", () => showWindow()); } app.whenReady().then(async () => { createWindow(); setupTray(); app.on("activate", () => showWindow()); setupAutoUpdate(); // 全局代理:状态变化推送到前端 + 刷新托盘;启动时清理上次崩溃残留的系统代理 proxy.setOnChange((st) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("proxy-changed", st); rebuildTrayMenu(); }); proxy.cleanupStale().catch(() => {}); // 会话记忆:后台监视 ~/.codex/sessions,会话静默后自动摘要入库 + 写 HANDOFF.md memory.start({ log, notify: () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("memory-changed"); }, }); // 启动时不再自动接管/启动本地路由:用户可能只想用其它功能。 // 保存分组已直接写 Codex 直连配置;只有用户显式「启用本地路由」才 start() 路由进程。 // 仅刷新一次托盘菜单以反映当前分组/状态(不 start)。 try { rebuildTrayMenu(); } catch (e) { log(`刷新托盘失败:${e.message || e}`); } try { const r = migrateLegacySkills(); if (r.migrated.length) log(`启动迁移 skill: ${r.migrated.length} 个转为软链`); if (r.errors.length) log(`启动迁移 skill 错误: ${JSON.stringify(r.errors)}`); } catch (e) { log(`启动迁移 skill 异常: ${e.message || e}`); } try { const r = migrateLegacyPlugins(); if (r.migrated.length) log(`启动迁移 plugin: ${r.migrated.length} 个转为软链`); if (r.errors.length) log(`启动迁移 plugin 错误: ${JSON.stringify(r.errors)}`); } catch (e) { log(`启动迁移 plugin 异常: ${e.message || e}`); } }); // 统一退出:清理代理(杀 sing-box + 还原网络) → 停本地路由 → 销毁托盘 → 退出。 // 带强制兜底,保证托盘「退出」后不残留任何进程。 let quitting = false; async function quitApp() { if (quitting) return; quitting = true; isQuitting = true; try { memory.stop(); // 停监视 + 记忆库落盘 } catch {} try { // 最多等 5 秒完成网络还原 / 拆 TUN / 杀引擎,超时也继续退出 await Promise.race([proxy.shutdown(), new Promise((r) => setTimeout(r, 5000))]); } catch {} try { router.stop(); } catch {} if (tray) { try { tray.destroy(); } catch {} tray = null; } app.quit(); // 兜底:若 1.2 秒后仍未退干净(有句柄/子进程挂着),强制结束所有进程 setTimeout(() => app.exit(0), 1200); } // 拦截所有退出入口(托盘退出 / Cmd+Q / 自动更新),统一走 quitApp 做彻底清理 app.on("before-quit", (e) => { if (quitting) return; // 已在退出流程中 → 放行,让其正常结束 e.preventDefault(); quitApp(); }); // --------------------------------------------------------------------------- // 在线更新(electron-updater + Gitea Releases) // --------------------------------------------------------------------------- function sendUpdate(channel, data) { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, data); } const UPDATE_OWNER = "mhgmh"; const UPDATE_REPO = "codex-market-app"; // 自建 gitea 发布源(国内可达、匿名可读):完整包更新和热更都走这里。 // up 域名给 API/直连,update 域名给用户浏览下载页(管理登录会话在此)。 const GITEA_RELEASES_API = `https://update.tokendun.cc/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`; const GITEA_RELEASES_PAGE = `https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases`; // 比较版本号:a>b 返回 1,a parseInt(n, 10) || 0); const pb = String(b).replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { if ((pa[i] || 0) > (pb[i] || 0)) return 1; if ((pa[i] || 0) < (pb[i] || 0)) return -1; } return 0; } // 带超时的 releases API 拉取;失败抛错由上层归一处理 async function fetchReleaseJson(url, ms = 8000) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), ms); try { const res = await fetch(url, { headers: { "User-Agent": "codex-market" }, signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } finally { clearTimeout(timer); } } // Mac:未签名无法用 Squirrel 自动安装,改为「检测 + 提示下载」半自动更新。 // 更新源走自建 gitea(国内可达、匿名可读)。 async function checkUpdateManual() { // 当前版本用「有效内容版本」(已确认热更版本优先):热更到 1.5.5 后不再因打包版固定 1.5.0 而误报有新版 const current = effectiveAppVersion(); let data = null; const page = GITEA_RELEASES_PAGE; try { data = await fetchReleaseJson(GITEA_RELEASES_API); } catch { const netErr = new Error("NETWORK"); netErr.network = true; throw netErr; } const latest = (data.tag_name || "").replace(/^v/, ""); const hasNew = latest && cmpVersion(latest, current) > 0; // 更新说明 + 强制标记:Release 说明里含 [强制更新] / [force] / [mandatory] 即为强制更新 const rawBody = String(data.body || ""); const mandatory = /\[(强制更新|force|mandatory)\]/i.test(rawBody); const notes = rawBody.replace(/\[(强制更新|force|mandatory)\]/gi, "").trim(); if (hasNew) sendUpdate("update-status", { state: "available-manual", version: latest, url: page, notes, mandatory }); else sendUpdate("update-status", { state: "none" }); return { hasNew, latest, current, url: page, notes, mandatory }; } // 免安装(zip/便携)版没有 app-update.yml,electron-updater 会抛 ENOENT。此时直接跳过自动更新。 function updaterAvailable() { try { return app.isPackaged && fs.existsSync(path.join(process.resourcesPath, "app-update.yml")); } catch { return false; } } function setupAutoUpdate() { // 免安装版:无更新配置 → 不启用自动更新,避免刷 ENOENT 报错 if (process.platform === "win32" && !updaterAvailable()) return; // —— Windows:electron-updater 全自动 —— if (process.platform === "win32") { autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on("update-available", (info) => { log(`发现新版本 ${info.version},后台下载中…`); sendUpdate("update-status", { state: "available", version: info.version }); }); autoUpdater.on("update-not-available", () => sendUpdate("update-status", { state: "none" })); autoUpdater.on("download-progress", (p) => sendUpdate("update-status", { state: "downloading", percent: Math.round(p.percent) }) ); autoUpdater.on("update-downloaded", async (info) => { sendUpdate("update-status", { state: "downloaded", version: info.version }); const r = await dialog.showMessageBox(mainWindow, { type: "info", buttons: ["立即重启更新", "稍后"], defaultId: 0, cancelId: 1, title: "有新版本", message: `新版本 ${info.version} 已下载完成`, detail: "点击「立即重启更新」完成安装,或稍后退出软件时自动更新。", }); if (r.response === 0) autoUpdater.quitAndInstall(); }); autoUpdater.on("error", (err) => { log(`更新检查失败:${err == null ? "未知错误" : err.message || err}`); sendUpdate("update-status", { state: "error", message: String(err && err.message ? err.message : err) }); }); // 不再自动触发 electron-updater 的 GitHub 检查:改用下方统一的 gitea 检测 + 前端弹窗 // (国内可达;electron-updater 相关处理器保留,供将来需要时手动触发) } // —— 所有平台:启动后做双通道更新检测(先热更,需原生变更才走完整包),由前端弹窗提示 —— if (app.isPackaged) { setTimeout(() => startupUpdateCheck().catch((e) => log(`检查更新失败:${e.message || e}`)), 4000); } } // 双通道:优先 gitea 热更清单;有新内容且当前原生版本够 → 热更弹窗; // 需完整包(requiresFull / minAppVersion 不满足)→ 走完整安装包检测弹窗。 async function startupUpdateCheck() { try { const m = await hotupdate.fetchManifest(); _lastManifest = m; if (m) { const r = hotupdate.evaluate(m, app.getVersion(), bundledHotVersion(), global.__HOT_BASE__); if (r.canHot) { sendUpdate("update-status", { state: "hot-available", version: r.contentVersion, notes: r.notes, mandatory: r.mandatory, }); return; } if (r.needFull) { await checkUpdateManual(); return; } // 有新内容但需完整包 } } catch { /* 热更检测失败 → 退回完整包检测 */ } await checkUpdateManual(); } app.on("window-all-closed", () => { // 托盘常驻:窗口关了也不退出(本地路由需在后台继续转发); // 真正退出走托盘「退出」或 before-quit(isQuitting=true)。 if (isQuitting) app.quit(); }); // --------------------------------------------------------------------------- // GitHub 辅助 // --------------------------------------------------------------------------- // 解析 owner/repo,支持 owner/repo、https://github.com/owner/repo(.git) function normalizeRepo(input) { let s = String(input || "").trim(); let owner, name; const m = s.match(/github\.com[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i); if (m) { owner = m[1]; name = m[2]; } else if (s.includes("/")) { [owner, name] = s.split("/", 2); } else { throw new Error("仓库格式应为 user/repo 或完整 GitHub 链接"); } name = name.replace(/\.git$/i, ""); if (!owner || !name) throw new Error("仓库格式应为 user/repo 或完整 GitHub 链接"); return { owner, name }; } // 上传只需【仓库名】(owner 由 token 决定)。纯名字 / owner/repo / 完整 URL / 带斜杠都能吃。 function repoNameFrom(input) { let s = String(input || "").trim(); const m = s.match(/github\.com[:/]+[^/]+\/([^/]+?)(?:\.git)?\/?$/i); if (m) s = m[1]; s = s.replace(/^\/+|\/+$/g, ""); // 去掉首尾斜杠 if (s.includes("/")) s = s.split("/").pop(); // owner/repo → 取 repo 段 s = s.replace(/\.git$/i, "").trim(); if (!s) throw new Error("请填写 GitHub 仓库名,如 codex-backup"); if (!/^[A-Za-z0-9._-]+$/.test(s)) { throw new Error(`仓库名「${s}」不合法:只能含字母、数字、. _ -(如 codex-backup)`); } return s; } async function ghGetUser(token) { const res = await fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "User-Agent": "codex-market", }, }); const scopes = res.headers.get("x-oauth-scopes") || ""; if (!res.ok) { throw new Error(`Token 验证失败(${res.status}):请检查 GitHub token 是否正确。`); } const me = await res.json(); return { login: me.login, scopes }; } async function ghCreateRepo(token, name, isPrivate) { const res = await fetch("https://api.github.com/user/repos", { method: "POST", headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", "User-Agent": "codex-market", }, body: JSON.stringify({ name, private: !!isPrivate, auto_init: false }), }); if (res.ok) { log(" 仓库已创建 ✅"); return; } if (res.status === 422) { log(" 仓库已存在,直接使用"); } else if (res.status === 403 || res.status === 404) { log(` ⚠️ 无法自动建仓(${res.status}),尝试直接推送——若仓库不存在会失败`); } else { const txt = await res.text().catch(() => ""); throw new Error(`建仓失败(${res.status}):${txt}`); } } // 递归把 staging 里的文件 add 进 index(跳过 .git) async function addAll(dir) { const walk = (rel) => { const abs = path.join(dir, rel); for (const ent of fs.readdirSync(abs, { withFileTypes: true })) { if (ent.name === ".git") continue; const childRel = rel ? path.posix.join(rel, ent.name) : ent.name; if (ent.isDirectory()) { walk(childRel); } else { files.push(childRel); } } }; const files = []; walk(""); for (const f of files) { await git.add({ fs, dir, filepath: f }); } } async function uploadToGithub(staging, token, repoName, isPrivate) { log("验证 Token…"); const { login, scopes } = await ghGetUser(token); log(` 登录为: ${login}`); const scopeList = scopes.split(",").map((s) => s.trim()).filter(Boolean); log(` token 权限: ${scopes || "(空 —— 多半是 fine-grained token)"}`); if (!scopeList.length) { throw new Error( "这个 token 没有任何 classic 权限范围,几乎肯定是 fine-grained token。\n" + "解决:生成 classic token 并勾选最上面的 repo 复选框:https://github.com/settings/tokens" ); } if (!scopeList.includes("repo") && !scopeList.includes("public_repo")) { throw new Error(`token 权限不足,缺少 repo 权限。当前只有:${scopes}\n解决:重新生成 classic token 并勾选 repo。`); } if (isPrivate && !scopeList.includes("repo")) { throw new Error( `传【私有】仓库需要完整 repo 权限,但当前只有:${scopes}\n办法一:勾完整 repo 权限重生成 token;办法二:改传公开仓库。` ); } log(`创建仓库 ${login}/${repoName} …`); await ghCreateRepo(token, repoName, isPrivate); const url = `https://github.com/${login}/${repoName}.git`; const onAuth = () => ({ username: token }); await git.init({ fs, dir: staging, defaultBranch: "main" }); await addAll(staging); await git.commit({ fs, dir: staging, message: "backup codex plugins & skills", author: { name: login, email: `${login}@users.noreply.github.com`, }, }); log("推送到 GitHub(可能要几秒)…"); try { await git.push({ fs, http: gitHttp, dir: staging, url, ref: "main", remoteRef: "main", force: true, onAuth, }); } catch (e) { throw new Error( "推送失败。常见原因:\n" + " • 用的是 fine-grained token → 换成 classic token(勾 repo 权限)\n" + " • 仓库不存在且 token 无建仓权限 → 先在网页手动建空仓库\n" + `原始错误:${e.message || e}` ); } log(`\n🎉 上传成功!仓库地址:https://github.com/${login}/${repoName}`); return login; // owner } async function cloneRepo(repoInput, token, dest) { const { owner, name } = normalizeRepo(repoInput); if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true }); fs.mkdirSync(path.dirname(dest), { recursive: true }); log(`拉取 ${owner}/${name} …`); const opts = { fs, http: gitHttp, dir: dest, url: `https://github.com/${owner}/${name}.git`, singleBranch: true, depth: 1, }; if (token) opts.onAuth = () => ({ username: token }); await git.clone(opts); log("拉取完成 ✅"); return { owner, name, dest }; } // --------------------------------------------------------------------------- // 市场 API // --------------------------------------------------------------------------- async function marketList() { const res = await fetch(`${MARKET_URL}/api/plugins`, { headers: { "User-Agent": "codex-market" }, }); if (!res.ok) throw new Error(`拉取市场列表失败(${res.status})`); const data = await res.json().catch(() => ({})); return data.plugins || []; } async function marketPublish(entries) { if (!entries.length) return 0; // 上架市场需要「管理员密钥」(= 服务器 CODEX_REGISTRY_API_KEY),只有管理员在设置里填了才行 const adminKey = secrets.getSecret("adminKey"); if (!adminKey) { const e = new Error("NO_ADMIN_KEY"); e.noAdminKey = true; throw e; } const res = await fetch(`${MARKET_URL}/api/plugins`, { method: "POST", headers: { "X-API-Key": adminKey, "Content-Type": "application/json", "User-Agent": "codex-market", }, body: JSON.stringify({ plugins: entries }), }); if (res.status === 401) throw new Error("管理员密钥无效,无权上架市场"); if (!res.ok) { const txt = await res.text().catch(() => ""); throw new Error(`发布到市场失败(${res.status}):${txt}`); } const data = await res.json().catch(() => ({})); return data.upserted || 0; } // 读取插件自带的描述(<插件目录>/<版本>/.codex-plugin/plugin.json 的 description), // 清洗成一句适合卡片显示的简介;读不到就返回空串。 function readPluginDescription(pluginPath) { try { if (!pluginPath || !fs.existsSync(pluginPath)) return ""; // 版本目录:插件目录下第一个包含 .codex-plugin 的子目录 const versions = fs .readdirSync(pluginPath) .filter((d) => fs.existsSync(path.join(pluginPath, d, ".codex-plugin", "plugin.json"))); if (!versions.length) return ""; const jsonPath = path.join(pluginPath, versions[0], ".codex-plugin", "plugin.json"); const meta = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); const raw = String(meta.description || ""); if (!raw) return ""; // 取首段(首个空行前),压平空白,截断到 160 字符 const first = raw.split(/\n\s*\n/)[0].replace(/\s+/g, " ").trim(); return first.length > 160 ? first.slice(0, 157) + "…" : first; } catch { return ""; } } // 探测插件是否平台专属:目录里含 macOS 应用包(*.app)即视为“仅 macOS”。 // 返回 "macos"(平台专属)或 ""(空 = 全平台 / 未知)。 function readPluginPlatform(pluginPath) { try { if (!pluginPath || !fs.existsSync(pluginPath)) return ""; const stack = [pluginPath]; let steps = 0; while (stack.length && steps < 50000) { const dir = stack.pop(); let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const ent of ents) { steps++; if (ent.isDirectory()) { if (ent.name.endsWith(".app")) return "macos"; stack.push(path.join(dir, ent.name)); } } } return ""; } catch { return ""; } } // 把选中的【插件】项转成市场登记条目(技能不登记) function buildEntries(selected, owner, repo, branch = "main") { return selected .filter((it) => it.category.startsWith("插件")) .map((it) => ({ name: it.name, source: it.source, category: it.category, rel: it.rel, repo: `${owner}/${repo}`, branch, web_url: `https://github.com/${owner}/${repo}/tree/${branch}/${it.rel}`, size: it.size, description: readPluginDescription(it.path), platform: readPluginPlatform(it.path), })); } // 把前端传来的 rels(相对路径)映射回带 path 的 item(基于某次扫描结果) function selectByRels(items, rels) { const set = new Set(rels); return items.filter((it) => set.has(it.rel)); } // --------------------------------------------------------------------------- // 技能商店(cocoloop 镜像)—— 内置 catalog + 一键安装到 ~/.codex/skills // --------------------------------------------------------------------------- // 内置技能目录:随软件打包在 renderer/catalog.json(离线可用,避免受服务器 raw 截断/登录限制)。 // 走 APP_ROOT,使热更包能更新技能库。 const SKILL_CATALOG_PATH = path.join(APP_ROOT, "renderer", "catalog.json"); // 用户自建镜像仓库(clone 地址,展示给用户;raw 带令牌方可读,故安装默认走原始 CDN) const SKILL_REPO_CLONE = "https://up.tokendun.cc/mhgmh/skills.git"; let _skillCatalog = null; function skillCatalog() { if (!_skillCatalog) { const raw = fs.readFileSync(SKILL_CATALOG_PATH, "utf8"); _skillCatalog = JSON.parse(raw); _skillCatalog.repo = SKILL_REPO_CLONE; } return _skillCatalog; } // 安装一个技能:实体解压到工具箱仓库,~/.codex/skills 只放软链供 Codex 扫描。 async function skillInstall(skill) { const url = skill.origin; if (!url) throw new Error("该技能没有可用的下载地址"); const safeName = safeSkillDir(skill.name); const storeDir = path.join(SKILL_STORE, safeName); const codexLink = path.join(codex.CODEX_HOME, "skills", safeName); log(`下载技能 ${skill.name} …`); const res = await fetch(url, { headers: { "User-Agent": "codex-market" } }); if (!res.ok) throw new Error(`下载失败(${res.status})`); const buf = Buffer.from(await res.arrayBuffer()); fs.mkdirSync(storeDir, { recursive: true }); // 清空旧内容(覆盖安装) for (const f of fs.readdirSync(storeDir)) fs.rmSync(path.join(storeDir, f), { recursive: true, force: true }); const n = extractZip(buf, storeDir); log(` 已解压 ${n} 个文件到工具箱仓库 ${storeDir} ✅`); ensureSymlink(storeDir, codexLink); log(` 已建立软链 ${codexLink} → ${storeDir} ✅`); // gen-image 生图技能:重装会清空目录,装好后按「中转」里勾选的生图分组重写 .env try { if (readSkillMeta(storeDir, safeName).name === "gen-image") { const r = router.syncGenImage(); if (r && r.wrote) log(` 已按中转分组「${r.group}」写入生图凭证 .env ✅`); } } catch {} return { ok: true, path: storeDir, link: codexLink, files: n }; } // --------------------------------------------------------------------------- // 插件市场(tokendun 官方自研插件)—— 内置目录 + 从自建 gitea 下载安装 // --------------------------------------------------------------------------- const PLUGIN_CATALOG_PATH = path.join(APP_ROOT, "renderer", "plugins.json"); let _pluginCatalog = null; function pluginCatalog() { if (!_pluginCatalog) { _pluginCatalog = JSON.parse(fs.readFileSync(PLUGIN_CATALOG_PATH, "utf8")); } return _pluginCatalog; } function normalizePluginRel(rel) { const relNorm = String(rel || "").replace(/\\/g, "/"); const parts = relNorm.split("/"); if ( parts.length !== 4 || parts[0] !== "plugins" || parts[1] !== "cache" || parts.some((p) => !p || p === "." || p === "..") ) { throw new Error("插件路径非法"); } return { rel: parts.join("/"), source: parts[2], name: parts[3] }; } function knownPluginKeys() { const known = new Set(); try { const plugins = (pluginCatalog() || {}).plugins || []; for (const p of plugins) { if (!p || !p.name) continue; known.add(`${p.source || "tokendun"}/${p.name}`); } } catch {} return known; } // 安装插件:实体解压到工具箱仓库,~/.codex/plugins/cache 只放软链供 Codex 扫描。 async function pluginInstall(plugin) { if (!plugin || !plugin.url) throw new Error("该插件没有可用的下载地址"); const { rel, source, name } = normalizePluginRel(plugin.rel || `plugins/cache/${plugin.source || "tokendun"}/${plugin.name}`); const storeDir = path.join(PLUGIN_STORE, source, name); const codexLink = path.join(codex.CODEX_HOME, rel); log(`下载插件 ${plugin.name} …`); const res = await fetch(plugin.url, { headers: { "User-Agent": "codex-market" } }); if (!res.ok) throw new Error(`下载失败(${res.status})`); const buf = Buffer.from(await res.arrayBuffer()); fs.mkdirSync(storeDir, { recursive: true }); for (const f of fs.readdirSync(storeDir)) fs.rmSync(path.join(storeDir, f), { recursive: true, force: true }); const n = extractZip(buf, storeDir); log(` 已解压 ${n} 个文件到工具箱仓库 ${storeDir} ✅`); ensureSymlink(storeDir, codexLink); log(` 已建立软链 ${codexLink} → ${storeDir} ✅`); return { ok: true, path: storeDir, link: codexLink, files: n }; } // --------------------------------------------------------------------------- // 已安装管理(列出 / 卸载)—— 以磁盘为准,解决“看不到装了啥、无处卸载” // --------------------------------------------------------------------------- // 递归统计一个目录占用的字节数(浅封顶,避免异常目录卡死) function dirSize(dir) { let total = 0; const stack = [dir]; let steps = 0; while (stack.length && steps < 100000) { const d = stack.pop(); let ents; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; } for (const ent of ents) { steps++; const p = path.join(d, ent.name); if (ent.isDirectory()) stack.push(p); else { try { total += fs.statSync(p).size; } catch {} } } } return total; } // 从 SKILL.md 前置元数据里读 name / description(读不到就用目录名兜底) function readSkillMeta(skillDir, fallbackName) { const meta = { name: fallbackName, description: "" }; try { const md = path.join(skillDir, "SKILL.md"); if (!fs.existsSync(md)) return meta; const txt = fs.readFileSync(md, "utf8"); const fm = /^---\s*\n([\s\S]*?)\n---/.exec(txt); const scope = fm ? fm[1] : txt.slice(0, 800); const nm = /^\s*name\s*:\s*(.+)$/mi.exec(scope); const de = /^\s*description\s*:\s*(.+)$/mi.exec(scope); if (nm) meta.name = nm[1].trim().replace(/^["']|["']$/g, "") || fallbackName; if (de) meta.description = de[1].trim().replace(/^["']|["']$/g, "").slice(0, 200); } catch {} return meta; } // 把名称规整成磁盘技能目录名(与 renderer 的 safeSkillDir 同算法)。统一小写,避免同一技能的大小写变体重复安装、软链名与实体名错位。 function safeSkillDir(name) { return (String(name || "skill") .toLowerCase() .replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill"; } // 列出已安装技能:扫描 ~/.codex/skills// function skillInstalledList() { const base = path.join(codex.CODEX_HOME, "skills"); if (!fs.existsSync(base)) return []; // 建索引:磁盘目录名 -> catalog 条目(中文名/中文简介)。读不到 catalog 就空索引,回退 SKILL.md。 let byDir = null; try { const list = (skillCatalog() || []); const items = Array.isArray(list) ? list : (list.skills || list.items || list.data || []); byDir = new Map(); for (const s of items) { if (s && s.name) byDir.set(safeSkillDir(s.name), s); } } catch {} const out = []; for (const ent of fs.readdirSync(base, { withFileTypes: true })) { const isDir = ent.isDirectory(); const isLink = ent.isSymbolicLink(); if ((!isDir && !isLink) || ent.name.startsWith(".")) continue; const dir = path.join(base, ent.name); let mtime = 0; try { mtime = fs.statSync(dir).mtimeMs; } catch {} const meta = readSkillMeta(dir, ent.name); // 优先用 catalog 的中文名/中文简介覆盖显示(SKILL.md 的 name 多为英文 slug,中文展示名在 catalog) const info = byDir && byDir.get(ent.name); out.push({ dir: ent.name, name: (info && info.name) || meta.name, subtitle: (info && info.subtitle) || "", description: (info && (info.brief || info.subtitle)) || meta.description, size: dirSize(dir), mtime, symlink: isLink, }); } return out.sort((a, b) => b.mtime - a.mtime); } // 按 name 匹配内置 plugins.json 里的中文标题/中文介绍(匹配不到返回 null) // 用于「已安装插件」卡片:磁盘 plugin.json 是 OpenAI 原版英文,这里用中文名/中文简介覆盖显示 function builtinPluginInfo(name) { try { const plugins = (pluginCatalog() || {}).plugins || []; return plugins.find((p) => p.name === name) || null; } catch { return null; } } // 列出已安装插件:扫描 ~/.codex/plugins/cache/// function pluginInstalledList() { const base = path.join(codex.CODEX_HOME, "plugins", "cache"); if (!fs.existsSync(base)) return []; const out = []; for (const src of fs.readdirSync(base, { withFileTypes: true })) { const isSrcDir = src.isDirectory(); const isSrcLink = src.isSymbolicLink(); if ((!isSrcDir && !isSrcLink) || src.name.startsWith(".")) continue; const srcDir = path.join(base, src.name); for (const ent of fs.readdirSync(srcDir, { withFileTypes: true })) { const isDir = ent.isDirectory(); const isLink = ent.isSymbolicLink(); if ((!isDir && !isLink) || ent.name.startsWith(".")) continue; const dir = path.join(srcDir, ent.name); let mtime = 0; try { mtime = fs.statSync(dir).mtimeMs; } catch {} // 中文标题/中文介绍优先取内置 plugins.json;磁盘 plugin.json 作兜底 const info = builtinPluginInfo(ent.name); out.push({ source: src.name, name: ent.name, displayName: (info && info.displayName) || ent.name, // 中文名,匹配不到回退英文 slug description: (info && info.description) || readPluginDescription(dir), rel: `plugins/cache/${src.name}/${ent.name}`, size: dirSize(dir), mtime, symlink: isLink, protected: src.name.startsWith("openai-") && !isLink, }); } } return out.sort((a, b) => b.mtime - a.mtime); } // 卸载技能:删 Codex 侧软链/旧实体,同时删除工具箱仓库实体。 function skillUninstall(dirName) { const base = path.join(codex.CODEX_HOME, "skills"); const safeName = String(dirName || ""); if (!safeName || safeName.includes("..") || /[\\/]/.test(safeName)) throw new Error("路径非法"); const dest = path.join(base, safeName); if (path.dirname(dest) !== base) throw new Error("路径非法"); const st = fs.lstatSync(dest, { throwIfNoEntry: false }); if (!st) throw new Error("技能不存在或路径非法"); if (st.isSymbolicLink()) fs.unlinkSync(dest); else fs.rmSync(dest, { recursive: true, force: true }); let removedSource = null; const storeDir = path.join(SKILL_STORE, safeName); if (pathContains(SKILL_STORE, storeDir) && fs.existsSync(storeDir)) { fs.rmSync(storeDir, { recursive: true, force: true }); removedSource = storeDir; log(`已删除工具箱仓库源 ${storeDir} ✅`); } log(`已卸载技能 ${dirName} ✅`); return { ok: true, removedSource }; } // 卸载插件:删 Codex 侧软链/旧实体,同时删除工具箱仓库实体。保护 Codex 官方 openai-* 缓存。 function pluginUninstall(rel) { const { rel: relNorm, source, name } = normalizePluginRel(rel); const dest = path.join(codex.CODEX_HOME, relNorm); const st = fs.lstatSync(dest, { throwIfNoEntry: false }); if (!st) throw new Error("插件不存在或路径非法"); if (source.startsWith("openai-") && !st.isSymbolicLink()) { throw new Error("Codex 官方插件缓存不允许从工具箱卸载"); } if (st.isSymbolicLink()) fs.unlinkSync(dest); else fs.rmSync(dest, { recursive: true, force: true }); let removedSource = null; const storeDir = path.join(PLUGIN_STORE, source, name); if (pathContains(PLUGIN_STORE, storeDir) && fs.existsSync(storeDir)) { fs.rmSync(storeDir, { recursive: true, force: true }); removedSource = storeDir; log(`已删除工具箱仓库源 ${storeDir} ✅`); } // 若来源目录已空,一并清掉,保持整洁 try { const srcDir = path.dirname(dest); if (fs.existsSync(srcDir) && !fs.readdirSync(srcDir).length) fs.rmdirSync(srcDir); } catch {} log(`已卸载插件 ${rel} ✅`); return { ok: true, removedSource }; } function migrateLegacySkills() { const base = path.join(codex.CODEX_HOME, "skills"); if (!fs.existsSync(base)) return { migrated: [], skipped: [], errors: [] }; const migrated = []; const skipped = []; const errors = []; fs.mkdirSync(SKILL_STORE, { recursive: true }); for (const ent of fs.readdirSync(base, { withFileTypes: true })) { if (ent.name.startsWith(".")) { skipped.push(ent.name); continue; } const codexDir = path.join(base, ent.name); const st = fs.lstatSync(codexDir, { throwIfNoEntry: false }); if (!st) continue; if (st.isSymbolicLink()) { skipped.push(ent.name); continue; } if (!st.isDirectory()) continue; const storeDir = path.join(SKILL_STORE, ent.name); try { if (!pathContains(SKILL_STORE, storeDir)) throw new Error("目标路径非法"); if (fs.existsSync(storeDir)) fs.rmSync(storeDir, { recursive: true, force: true }); moveDirSync(codexDir, storeDir); ensureSymlink(storeDir, codexDir); migrated.push(ent.name); log(`迁移 skill ${ent.name}: 实体 → 工具箱仓库 + 软链 ✅`); } catch (e) { errors.push({ name: ent.name, error: e.message }); log(`迁移 skill ${ent.name} 失败: ${e.message}`); } } return { migrated, skipped, errors }; } function migrateLegacyPlugins() { const base = path.join(codex.CODEX_HOME, "plugins", "cache"); if (!fs.existsSync(base)) return { migrated: [], skipped: [], errors: [] }; const known = knownPluginKeys(); const migrated = []; const skipped = []; const errors = []; fs.mkdirSync(PLUGIN_STORE, { recursive: true }); for (const src of fs.readdirSync(base, { withFileTypes: true })) { if ((!src.isDirectory() && !src.isSymbolicLink()) || src.name.startsWith(".")) continue; if (src.name.startsWith("openai-")) { skipped.push(`${src.name}/*`); continue; } const srcDir = path.join(base, src.name); let ents = []; try { ents = fs.readdirSync(srcDir, { withFileTypes: true }); } catch { continue; } for (const ent of ents) { if (ent.name.startsWith(".")) continue; const key = `${src.name}/${ent.name}`; if (!known.has(key)) { skipped.push(key); continue; } const codexDir = path.join(srcDir, ent.name); const st = fs.lstatSync(codexDir, { throwIfNoEntry: false }); if (!st) continue; if (st.isSymbolicLink()) { skipped.push(key); continue; } if (!st.isDirectory()) continue; const storeDir = path.join(PLUGIN_STORE, src.name, ent.name); try { if (!pathContains(PLUGIN_STORE, storeDir)) throw new Error("目标路径非法"); if (fs.existsSync(storeDir)) fs.rmSync(storeDir, { recursive: true, force: true }); moveDirSync(codexDir, storeDir); ensureSymlink(storeDir, codexDir); migrated.push(key); log(`迁移 plugin ${key}: 实体 → 工具箱仓库 + 软链 ✅`); } catch (e) { errors.push({ name: key, error: e.message }); log(`迁移 plugin ${key} 失败: ${e.message}`); } } } return { migrated, skipped, errors }; } // --------------------------------------------------------------------------- // IPC 实现 // --------------------------------------------------------------------------- ipcMain.handle("skill-catalog", async () => skillCatalog()); ipcMain.handle("skill-install", async (_e, { skill }) => skillInstall(skill)); ipcMain.handle("skill-installed", async () => skillInstalledList()); ipcMain.handle("skill-uninstall", async (_e, { dir }) => skillUninstall(dir)); ipcMain.handle("skill-migrate", async () => migrateLegacySkills()); ipcMain.handle("plugin-catalog", async () => pluginCatalog()); ipcMain.handle("plugin-install", async (_e, { plugin }) => pluginInstall(plugin)); ipcMain.handle("plugin-installed", async () => pluginInstalledList()); ipcMain.handle("plugin-uninstall", async (_e, { rel }) => pluginUninstall(rel)); ipcMain.handle("plugin-migrate", async () => migrateLegacyPlugins()); ipcMain.handle("scan-local", async () => { return codex.scanBase(codex.CODEX_HOME); }); ipcMain.handle("pick-dir", async () => { const r = await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory", "createDirectory"], title: "选择导出目录", }); if (r.canceled || !r.filePaths.length) return null; return r.filePaths[0]; }); ipcMain.handle("pack-local", async (_e, { rels, destDir }) => { const all = codex.scanBase(codex.CODEX_HOME); const selected = selectByRels(all, rels); if (!selected.length) throw new Error("没有选中任何可打包的项"); codex.packageItems(selected, STAGING, log); // 拷到目标目录,命名带时间戳(避免用不可用的 Date:用文件系统计数) const base = "codex-backup"; let out = path.join(destDir, base); let i = 1; while (fs.existsSync(out)) out = path.join(destDir, `${base}-${i++}`); codex.copyClean(STAGING, out); log(`已导出到:${out}`); return { path: out }; }); ipcMain.handle("upload", async (_e, { rels, repo, token, isPrivate }) => { if (!token) throw new Error("请先填写 GitHub token"); const name = repoNameFrom(repo); const all = codex.scanBase(codex.CODEX_HOME); const selected = selectByRels(all, rels); if (!selected.length) throw new Error("没有选中任何要上传的项"); codex.packageItems(selected, STAGING, log); const owner = await uploadToGithub(STAGING, token, name, isPrivate); const entries = buildEntries(selected, owner, name); let published = 0; let marketSkipped = false; if (entries.length) { try { log(`发布 ${entries.length} 个插件到市场…`); published = await marketPublish(entries); log(` 已登记 ${published} 个插件到市场 ✅`); } catch (e) { if (e && e.noAdminKey) { marketSkipped = true; log(" ⚠️ 未设置管理员密钥:已备份到你的 GitHub 仓库,但未上架到市场(上架需管理员)"); } else { throw e; // 密钥无效等真错误照常抛出 } } } else { log(" (只含技能,不登记市场)"); } return { owner, published, marketSkipped }; }); ipcMain.handle("market-list", async () => { return marketList(); }); ipcMain.handle("market-install", async (_e, { entry }) => { const token = secrets.getSecret("github"); // 私有仓库才需要,公开可空 const safe = entry.repo.replace(/\//g, "_"); const dest = path.join(CLONE_ROOT, safe); await cloneRepo(entry.repo, token || null, dest); const items = codex.scanBase(dest); const target = items.find((it) => it.rel === entry.rel); if (!target) { throw new Error(`仓库 ${entry.repo} 里找不到 ${entry.rel},可能已删除或改名`); } codex.restoreItems([target], log, true); return { ok: true }; }); ipcMain.handle("gh-pull", async (_e, { repoInput, token }) => { const safe = String(repoInput).replace(/[^\w.-]/g, "_"); const dest = path.join(CLONE_ROOT, `pull_${safe}`); await cloneRepo(repoInput, token || null, dest); const items = codex.scanBase(dest); lastPull = { base: dest, items }; log(`发现 ${items.length} 个可还原项`); return items; }); ipcMain.handle("gh-restore", async (_e, { rels, enable }) => { if (!lastPull) throw new Error("请先执行「拉取」再还原"); const selected = selectByRels(lastPull.items, rels); if (!selected.length) throw new Error("没有选中任何要还原的项"); codex.restoreItems(selected, log, !!enable); return { ok: true }; }); ipcMain.handle("get-secret", async (_e, { name }) => { return secrets.getSecret(name); }); ipcMain.handle("set-secret", async (_e, { name, value }) => { return secrets.setSecret(name, value); }); // —— 会话记录:浏览 / 读取 / 导出 / 备份 / 导入 —— ipcMain.handle("sessions-list", async (_e, opts = {}) => { return sessions.listConversations(opts || {}); }); ipcMain.handle("session-read", async (_e, { path: filePath }) => { return sessions.readConversation(filePath); }); ipcMain.handle("session-export", async (_e, { item }) => { const conv = sessions.readConversation(item.path); const md = sessions.toMarkdown(item, conv); const safeName = String(item.title || "会话").replace(/[\\/:*?"<>|\n]/g, "_").slice(0, 40); const r = await dialog.showSaveDialog(mainWindow, { title: "导出会话为 Markdown", defaultPath: `${safeName}.md`, filters: [{ name: "Markdown", extensions: ["md"] }], }); if (r.canceled || !r.filePath) return { canceled: true }; fs.writeFileSync(r.filePath, md, "utf-8"); log(`已导出会话:${r.filePath}`); return { path: r.filePath }; }); ipcMain.handle("sessions-backup", async () => { const r = await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory", "createDirectory"], title: "选择备份到的目录", }); if (r.canceled || !r.filePaths.length) return { canceled: true }; return sessions.backupAll(r.filePaths[0], log); }); ipcMain.handle("sessions-import", async () => { const r = await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory"], title: "选择要导入的备份目录(含 sessions 的文件夹)", }); if (r.canceled || !r.filePaths.length) return { canceled: true }; return sessions.importSessions(r.filePaths[0], log); }); // —— 会话记忆:状态 / 检索 / 开关 / 手动摘要 —— ipcMain.handle("memory-status", async () => memory.statusInfo()); ipcMain.handle("memory-list", async (_e, opts) => memory.list(opts || {})); ipcMain.handle("memory-set-enabled", async (_e, { enabled }) => { memory.setEnabled(enabled); return memory.statusInfo(); }); ipcMain.handle("memory-toggle-project", async (_e, { cwd, enabled }) => { memory.toggleProject(cwd, enabled); return memory.statusInfo(); }); ipcMain.handle("memory-resummarize", async (_e, { path: p }) => { await memory.resummarize(p); return { ok: true }; }); ipcMain.handle("memory-delete", async (_e, { id }) => { await memory.deleteRow(id); return { ok: true }; }); ipcMain.handle("memory-open-handoff", async (_e, { cwd }) => { const p = memory.handoffPath(cwd); if (fs.existsSync(p)) shell.showItemInFolder(p); else throw new Error("该项目还没有 HANDOFF.md(可能项目目录已删除或未开启写入)"); }); ipcMain.handle("provider-sync-status", async () => { return { running: providerSync.codexRunning(), target: providerSync.targetProvider() }; }); ipcMain.handle("provider-sync", async () => { const onProgress = (p) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("provider-sync-progress", p); }; return providerSync.syncProviderToCurrent(log, onProgress); }); // —— 本地路由 —— ipcMain.handle("router-status", async () => router.status()); ipcMain.handle("router-upsert-group", async (_e, { group }) => { const s = router.upsertGroup(group); rebuildTrayMenu(); return s; }); ipcMain.handle("router-delete-group", async (_e, { id }) => { const s = router.deleteGroup(id); rebuildTrayMenu(); return s; }); ipcMain.handle("router-set-active", async (_e, { id }) => { const s = router.setActive(id); rebuildTrayMenu(); return s; }); ipcMain.handle("router-set-port", async (_e, { port }) => { const wasRunning = router.isRunning(); if (wasRunning) router.stop(); const s = router.setPort(port); if (wasRunning) await router.start(); rebuildTrayMenu(); return s; }); ipcMain.handle("router-start", async () => { await router.start(); rebuildTrayMenu(); return router.status(); }); ipcMain.handle("router-stop", async () => { router.stop(); rebuildTrayMenu(); return router.status(); }); ipcMain.handle("router-apply-codex", async () => { const r = router.applyToCodex(); if (!router.isRunning()) await router.start().catch(() => {}); rebuildTrayMenu(); return { ...r, status: router.status() }; }); // 把指定分组的直连中转配置写进 Codex(不经路由、不起进程) ipcMain.handle("router-apply-direct", async (_e, { id } = {}) => { const r = router.applyDirectToCodex(id); rebuildTrayMenu(); return { ...r, status: router.status() }; }); ipcMain.handle("router-snippet", async () => router.codexSnippet()); ipcMain.handle("router-apply-claude", async () => { const r = router.applyToClaudeCode(); if (!router.isRunning()) await router.start().catch(() => {}); rebuildTrayMenu(); return { ...r, status: router.status() }; }); ipcMain.handle("router-restore-claude", async () => { const r = router.restoreClaudeCode(); return { ...r, status: router.status() }; }); ipcMain.handle("router-fetch-models", async (_e, { baseUrl, key, groupId }) => { const k = key || (groupId ? router.getGroupKey(groupId) : ""); return router.fetchModels(baseUrl, k); }); // —— 创意画布(中转执行层)—— ipcMain.handle("canvas-status", () => canvas.status()); ipcMain.handle("canvas-run", (e, { kind, params }) => canvas.run({ kind, params }, (dataUrl) => { try { e.sender.send("canvas-progress", { runId: params && params.runId, kind, dataUrl }); } catch {} }) ); ipcMain.handle("canvas-groups", () => canvas.groups()); ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId)); // —— Codex 语音输入注入(CDP)—— ipcMain.handle("voice-status", () => voice.status()); ipcMain.handle("voice-connect", (e, opts) => voice.connect((s) => { try { e.sender.send("voice-status", s); } catch {} }, opts || {}) ); ipcMain.handle("voice-disconnect", () => voice.disconnect()); ipcMain.handle("voice-stt-info", () => voice.sttInfo()); ipcMain.handle("voice-probe-stt", async () => voice.probeStt()); // —— 全局 / 系统代理 —— ipcMain.handle("proxy-status", async () => proxy.status()); ipcMain.handle("proxy-get-config", async () => proxy.getConfig()); ipcMain.handle("proxy-set-config", async (_e, cfg) => proxy.setConfig(cfg)); ipcMain.handle("proxy-start", async (_e, { mode }) => proxy.start(mode)); ipcMain.handle("proxy-stop", async () => proxy.stop()); ipcMain.handle("proxy-test", async () => proxy.testProxy()); // 有效内容版本:优先已确认健康的热更版本,其次包内 hotContentVersion,兜底 app.getVersion()。 // 让设置页版本号随热更走(热更不替换 package.json,单纯 app.getVersion() 会永远停在打包版本)。 function effectiveAppVersion() { if (APP_ROOT !== BUNDLE_ROOT) return path.basename(APP_ROOT); try { const v = hotstate.effectiveVersion(global.__HOT_BASE__); if (v) return v; } catch {} return bundledHotVersion(); } ipcMain.handle("app-version", async () => effectiveAppVersion()); ipcMain.handle("check-update", async () => { if (!app.isPackaged) return { dev: true }; try { // 所有平台的「检查更新」按钮统一走 gitea 优先的手动检测(国内可达); // Windows 的后台全自动更新仍由 electron-updater 处理(能连 GitHub 的用户受益)。 const r = await checkUpdateManual(); return { ok: true, manual: true, hasNew: r.hasNew, version: r.hasNew ? r.latest : null, url: r.url, notes: r.notes, mandatory: r.mandatory }; } catch (e) { if (e && e.network) return { error: "网络无法连接更新服务器,请稍后再试或手动前往下载页。", network: true, url: GITEA_RELEASES_PAGE }; return { error: String(e && e.message ? e.message : e) }; } }); ipcMain.handle("open-external", async (_e, { url }) => { if (url) shell.openExternal(url); }); ipcMain.handle("app-quit", async () => { quitApp(); }); // —— 热更新 IPC —— // 随包内置的内容版本(BUNDLE 的 package.json.hotContentVersion,缺省用 appVersion) function bundledHotVersion() { try { return require(path.join(BUNDLE_ROOT, "package.json")).hotContentVersion || app.getVersion(); } catch { return app.getVersion(); } } let _lastManifest = null; ipcMain.handle("hot-check", async () => { if (!app.isPackaged) return { hasHot: false, dev: true }; try { const m = await hotupdate.fetchManifest(); _lastManifest = m; if (!m) return { hasHot: false }; return hotupdate.evaluate(m, app.getVersion(), bundledHotVersion(), global.__HOT_BASE__); } catch (e) { return { hasHot: false, error: String(e && e.message ? e.message : e) }; } }); ipcMain.handle("hot-apply", async () => { if (!_lastManifest) _lastManifest = await hotupdate.fetchManifest(); if (!_lastManifest) throw new Error("拉取热更清单失败"); return hotupdate.applyManifest(_lastManifest, global.__HOT_BASE__, (p) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("hot-progress", p); }); }); ipcMain.handle("hot-confirm", async () => { if (APP_ROOT !== BUNDLE_ROOT) { try { hotstate.confirmHealthy(global.__HOT_BASE__, path.basename(APP_ROOT)); } catch {} } return { ok: true }; }); ipcMain.handle("hot-restart", async () => { app.relaunch(); quitApp(); // 走清理流程(sing-box/router)后退出,relaunch 生效 return { ok: true }; }); // —— 检测 / 强制清除本机 Codex —— ipcMain.handle("codex-detect", async () => { return purge.detectInstalls(); }); ipcMain.handle("codex-purge", async (_e, { paths }) => { const targets = Array.isArray(paths) ? paths : []; if (!targets.length) return { canceled: false, results: [] }; // 二次确认:原生对话框,避免误删 const r = await dialog.showMessageBox(mainWindow, { type: "warning", buttons: ["取消", "彻底清除"], defaultId: 0, cancelId: 0, title: "强制清除 Codex", message: "确定要彻底清除本机 Codex 吗?", detail: "将删除以下 " + targets.length + " 个位置(含配置、登录凭证、会话、插件与可执行文件),此操作不可撤销:\n\n" + targets.join("\n") + "\n\n清除后需要重新安装并登录 Codex。", noLink: true, }); if (r.response !== 1) return { canceled: true, results: [] }; log("开始强制清除本机 Codex…"); const results = await purge.purge(targets); for (const it of results) { if (it.ok) log(` 已删除:${it.path}`); else log(` ⚠️ 删除失败:${it.path} —— ${it.error}`); } const okCount = results.filter((x) => x.ok).length; log(`清除完成:成功 ${okCount} / ${results.length}`); return { canceled: false, results }; });