Files
codex-market-app/electron/main.js
T

1448 lines
56 KiB
JavaScript
Raw Normal View History

2026-07-15 18:01:56 +08:00
// 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 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/<ver>),由 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");
},
});
// 有分组就自动启动本地路由
try {
const st = router.status();
if (st.groups.length) {
await router.start();
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);
}
2026-07-15 18:04:01 +08:00
const UPDATE_OWNER = "mhgmh";
const UPDATE_REPO = "codex-market-app";
2026-07-15 18:01:56 +08:00
// 自建 gitea 发布源(国内可达、匿名可读):完整包更新和热更都走这里。
// up 域名给 API/直连,update 域名给用户浏览下载页(管理登录会话在此)。
2026-07-15 19:18:03 +08:00
const GITEA_RELEASES_API = `https://update.tokendun.cc/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`;
2026-07-15 18:01:56 +08:00
const GITEA_RELEASES_PAGE = `https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases`;
// 比较版本号:a>b 返回 1,a<b 返回 -1,相等 0
function cmpVersion(a, b) {
const pa = String(a).replace(/^v/, "").split(".").map((n) => 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 = String(skill.name || "skill")
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "skill";
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;
}
// 把名称规整成磁盘技能目录名(与 skillInstall 的 safeName / renderer 的 safeSkillDir 同算法)
function safeSkillDir(name) {
return (String(name || "skill")
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
}
// 列出已安装技能:扫描 ~/.codex/skills/<name>/
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/<source>/<name>/
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() };
});
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));
// —— 全局 / 系统代理 ——
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() {
2026-07-15 18:06:58 +08:00
if (APP_ROOT !== BUNDLE_ROOT) return path.basename(APP_ROOT);
2026-07-15 18:01:56 +08:00
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 };
});