Files
codex-market-app/electron/main.js
T
2026-07-15 18:06:58 +08:00

1448 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}
const UPDATE_OWNER = "mhgmh";
const UPDATE_REPO = "codex-market-app";
// 自建 gitea 发布源(国内可达、匿名可读):完整包更新和热更都走这里。
// up 域名给 API/直连,update 域名给用户浏览下载页(管理登录会话在此)。
const GITEA_RELEASES_API = `https://up.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<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() {
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 };
});