02fb553c9e
- 增强会话同步:确保所有会话完全同步到新账号 - 强制更新 model_provider 字段 - 添加 session_index.jsonl 重建功能 - 确保数据库 UPSERT 逻辑正确处理所有情况 - 新增语音输入功能(voice_cdp.js, voice_inject.js) - 测试脚本和增强模块 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1462 lines
57 KiB
JavaScript
1462 lines
57 KiB
JavaScript
// main.js —— 主进程:窗口、IPC、GitHub(isomorphic-git)、市场 API。
|
||
// 市场地址/密钥【只在主进程】,绝不下发前端。契约见 CONTRACT.md。
|
||
const { app, BrowserWindow, ipcMain, dialog, shell, Tray, Menu, nativeImage } = require("electron");
|
||
const { autoUpdater } = require("electron-updater");
|
||
const path = require("path");
|
||
const os = require("os");
|
||
const fs = require("fs");
|
||
const git = require("isomorphic-git");
|
||
const gitHttp = require("isomorphic-git/http/node");
|
||
|
||
const codex = require("./lib/codex");
|
||
const purge = require("./lib/purge");
|
||
const secrets = require("./lib/secrets");
|
||
const sessions = require("./lib/sessions");
|
||
const providerSync = require("./lib/provider_sync");
|
||
const router = require("./lib/router");
|
||
const canvas = require("./lib/canvas");
|
||
const voice = require("./lib/voice_cdp");
|
||
const proxy = require("./lib/proxy");
|
||
const memory = require("./lib/memory");
|
||
const { extractZip } = require("./lib/zip");
|
||
const hotstate = require("./lib/hotstate");
|
||
const hotupdate = require("./lib/hotupdate");
|
||
|
||
// —— 应用根目录(热更新)——
|
||
// APP_ROOT:有效代码/界面所在根(可能是包内,也可能是 userData/hot/<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://update.tokendun.cc/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`;
|
||
const GITEA_RELEASES_PAGE = `https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases`;
|
||
|
||
// 比较版本号:a>b 返回 1,a<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 = safeSkillDir(skill.name);
|
||
const storeDir = path.join(SKILL_STORE, safeName);
|
||
const codexLink = path.join(codex.CODEX_HOME, "skills", safeName);
|
||
log(`下载技能 ${skill.name} …`);
|
||
const res = await fetch(url, { headers: { "User-Agent": "codex-market" } });
|
||
if (!res.ok) throw new Error(`下载失败(${res.status})`);
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
fs.mkdirSync(storeDir, { recursive: true });
|
||
// 清空旧内容(覆盖安装)
|
||
for (const f of fs.readdirSync(storeDir)) fs.rmSync(path.join(storeDir, f), { recursive: true, force: true });
|
||
const n = extractZip(buf, storeDir);
|
||
log(` 已解压 ${n} 个文件到工具箱仓库 ${storeDir} ✅`);
|
||
ensureSymlink(storeDir, codexLink);
|
||
log(` 已建立软链 ${codexLink} → ${storeDir} ✅`);
|
||
// gen-image 生图技能:重装会清空目录,装好后按「中转」里勾选的生图分组重写 .env
|
||
try {
|
||
if (readSkillMeta(storeDir, safeName).name === "gen-image") {
|
||
const r = router.syncGenImage();
|
||
if (r && r.wrote) log(` 已按中转分组「${r.group}」写入生图凭证 .env ✅`);
|
||
}
|
||
} catch {}
|
||
return { ok: true, path: storeDir, link: codexLink, files: n };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 插件市场(tokendun 官方自研插件)—— 内置目录 + 从自建 gitea 下载安装
|
||
// ---------------------------------------------------------------------------
|
||
const PLUGIN_CATALOG_PATH = path.join(APP_ROOT, "renderer", "plugins.json");
|
||
let _pluginCatalog = null;
|
||
function pluginCatalog() {
|
||
if (!_pluginCatalog) {
|
||
_pluginCatalog = JSON.parse(fs.readFileSync(PLUGIN_CATALOG_PATH, "utf8"));
|
||
}
|
||
return _pluginCatalog;
|
||
}
|
||
|
||
function normalizePluginRel(rel) {
|
||
const relNorm = String(rel || "").replace(/\\/g, "/");
|
||
const parts = relNorm.split("/");
|
||
if (
|
||
parts.length !== 4 ||
|
||
parts[0] !== "plugins" ||
|
||
parts[1] !== "cache" ||
|
||
parts.some((p) => !p || p === "." || p === "..")
|
||
) {
|
||
throw new Error("插件路径非法");
|
||
}
|
||
return { rel: parts.join("/"), source: parts[2], name: parts[3] };
|
||
}
|
||
|
||
function knownPluginKeys() {
|
||
const known = new Set();
|
||
try {
|
||
const plugins = (pluginCatalog() || {}).plugins || [];
|
||
for (const p of plugins) {
|
||
if (!p || !p.name) continue;
|
||
known.add(`${p.source || "tokendun"}/${p.name}`);
|
||
}
|
||
} catch {}
|
||
return known;
|
||
}
|
||
|
||
// 安装插件:实体解压到工具箱仓库,~/.codex/plugins/cache 只放软链供 Codex 扫描。
|
||
async function pluginInstall(plugin) {
|
||
if (!plugin || !plugin.url) throw new Error("该插件没有可用的下载地址");
|
||
const { rel, source, name } = normalizePluginRel(plugin.rel || `plugins/cache/${plugin.source || "tokendun"}/${plugin.name}`);
|
||
const storeDir = path.join(PLUGIN_STORE, source, name);
|
||
const codexLink = path.join(codex.CODEX_HOME, rel);
|
||
log(`下载插件 ${plugin.name} …`);
|
||
const res = await fetch(plugin.url, { headers: { "User-Agent": "codex-market" } });
|
||
if (!res.ok) throw new Error(`下载失败(${res.status})`);
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
fs.mkdirSync(storeDir, { recursive: true });
|
||
for (const f of fs.readdirSync(storeDir)) fs.rmSync(path.join(storeDir, f), { recursive: true, force: true });
|
||
const n = extractZip(buf, storeDir);
|
||
log(` 已解压 ${n} 个文件到工具箱仓库 ${storeDir} ✅`);
|
||
ensureSymlink(storeDir, codexLink);
|
||
log(` 已建立软链 ${codexLink} → ${storeDir} ✅`);
|
||
return { ok: true, path: storeDir, link: codexLink, files: n };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 已安装管理(列出 / 卸载)—— 以磁盘为准,解决“看不到装了啥、无处卸载”
|
||
// ---------------------------------------------------------------------------
|
||
// 递归统计一个目录占用的字节数(浅封顶,避免异常目录卡死)
|
||
function dirSize(dir) {
|
||
let total = 0;
|
||
const stack = [dir];
|
||
let steps = 0;
|
||
while (stack.length && steps < 100000) {
|
||
const d = stack.pop();
|
||
let ents;
|
||
try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
||
for (const ent of ents) {
|
||
steps++;
|
||
const p = path.join(d, ent.name);
|
||
if (ent.isDirectory()) stack.push(p);
|
||
else { try { total += fs.statSync(p).size; } catch {} }
|
||
}
|
||
}
|
||
return total;
|
||
}
|
||
|
||
// 从 SKILL.md 前置元数据里读 name / description(读不到就用目录名兜底)
|
||
function readSkillMeta(skillDir, fallbackName) {
|
||
const meta = { name: fallbackName, description: "" };
|
||
try {
|
||
const md = path.join(skillDir, "SKILL.md");
|
||
if (!fs.existsSync(md)) return meta;
|
||
const txt = fs.readFileSync(md, "utf8");
|
||
const fm = /^---\s*\n([\s\S]*?)\n---/.exec(txt);
|
||
const scope = fm ? fm[1] : txt.slice(0, 800);
|
||
const nm = /^\s*name\s*:\s*(.+)$/mi.exec(scope);
|
||
const de = /^\s*description\s*:\s*(.+)$/mi.exec(scope);
|
||
if (nm) meta.name = nm[1].trim().replace(/^["']|["']$/g, "") || fallbackName;
|
||
if (de) meta.description = de[1].trim().replace(/^["']|["']$/g, "").slice(0, 200);
|
||
} catch {}
|
||
return meta;
|
||
}
|
||
|
||
// 把名称规整成磁盘技能目录名(与 renderer 的 safeSkillDir 同算法)。统一小写,避免同一技能的大小写变体重复安装、软链名与实体名错位。
|
||
function safeSkillDir(name) {
|
||
return (String(name || "skill")
|
||
.toLowerCase()
|
||
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
|
||
}
|
||
|
||
// 列出已安装技能:扫描 ~/.codex/skills/<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));
|
||
|
||
// —— Codex 语音输入注入(CDP)——
|
||
ipcMain.handle("voice-status", () => voice.status());
|
||
ipcMain.handle("voice-connect", (e, opts) =>
|
||
voice.connect((s) => {
|
||
try {
|
||
e.sender.send("voice-status", s);
|
||
} catch {}
|
||
}, opts || {})
|
||
);
|
||
ipcMain.handle("voice-disconnect", () => voice.disconnect());
|
||
ipcMain.handle("voice-stt-info", () => voice.sttInfo());
|
||
ipcMain.handle("voice-probe-stt", async () => voice.probeStt());
|
||
|
||
// —— 全局 / 系统代理 ——
|
||
ipcMain.handle("proxy-status", async () => proxy.status());
|
||
ipcMain.handle("proxy-get-config", async () => proxy.getConfig());
|
||
ipcMain.handle("proxy-set-config", async (_e, cfg) => proxy.setConfig(cfg));
|
||
ipcMain.handle("proxy-start", async (_e, { mode }) => proxy.start(mode));
|
||
ipcMain.handle("proxy-stop", async () => proxy.stop());
|
||
ipcMain.handle("proxy-test", async () => proxy.testProxy());
|
||
|
||
// 有效内容版本:优先已确认健康的热更版本,其次包内 hotContentVersion,兜底 app.getVersion()。
|
||
// 让设置页版本号随热更走(热更不替换 package.json,单纯 app.getVersion() 会永远停在打包版本)。
|
||
function effectiveAppVersion() {
|
||
if (APP_ROOT !== BUNDLE_ROOT) return path.basename(APP_ROOT);
|
||
try {
|
||
const v = hotstate.effectiveVersion(global.__HOT_BASE__);
|
||
if (v) return v;
|
||
} catch {}
|
||
return bundledHotVersion();
|
||
}
|
||
ipcMain.handle("app-version", async () => effectiveAppVersion());
|
||
|
||
ipcMain.handle("check-update", async () => {
|
||
if (!app.isPackaged) return { dev: true };
|
||
try {
|
||
// 所有平台的「检查更新」按钮统一走 gitea 优先的手动检测(国内可达);
|
||
// Windows 的后台全自动更新仍由 electron-updater 处理(能连 GitHub 的用户受益)。
|
||
const r = await checkUpdateManual();
|
||
return { ok: true, manual: true, hasNew: r.hasNew, version: r.hasNew ? r.latest : null, url: r.url, notes: r.notes, mandatory: r.mandatory };
|
||
} catch (e) {
|
||
if (e && e.network) return { error: "网络无法连接更新服务器,请稍后再试或手动前往下载页。", network: true, url: GITEA_RELEASES_PAGE };
|
||
return { error: String(e && e.message ? e.message : e) };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle("open-external", async (_e, { url }) => {
|
||
if (url) shell.openExternal(url);
|
||
});
|
||
ipcMain.handle("app-quit", async () => {
|
||
quitApp();
|
||
});
|
||
|
||
// —— 热更新 IPC ——
|
||
// 随包内置的内容版本(BUNDLE 的 package.json.hotContentVersion,缺省用 appVersion)
|
||
function bundledHotVersion() {
|
||
try {
|
||
return require(path.join(BUNDLE_ROOT, "package.json")).hotContentVersion || app.getVersion();
|
||
} catch { return app.getVersion(); }
|
||
}
|
||
let _lastManifest = null;
|
||
ipcMain.handle("hot-check", async () => {
|
||
if (!app.isPackaged) return { hasHot: false, dev: true };
|
||
try {
|
||
const m = await hotupdate.fetchManifest();
|
||
_lastManifest = m;
|
||
if (!m) return { hasHot: false };
|
||
return hotupdate.evaluate(m, app.getVersion(), bundledHotVersion(), global.__HOT_BASE__);
|
||
} catch (e) {
|
||
return { hasHot: false, error: String(e && e.message ? e.message : e) };
|
||
}
|
||
});
|
||
ipcMain.handle("hot-apply", async () => {
|
||
if (!_lastManifest) _lastManifest = await hotupdate.fetchManifest();
|
||
if (!_lastManifest) throw new Error("拉取热更清单失败");
|
||
return hotupdate.applyManifest(_lastManifest, global.__HOT_BASE__, (p) => {
|
||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("hot-progress", p);
|
||
});
|
||
});
|
||
ipcMain.handle("hot-confirm", async () => {
|
||
if (APP_ROOT !== BUNDLE_ROOT) {
|
||
try { hotstate.confirmHealthy(global.__HOT_BASE__, path.basename(APP_ROOT)); } catch {}
|
||
}
|
||
return { ok: true };
|
||
});
|
||
ipcMain.handle("hot-restart", async () => {
|
||
app.relaunch();
|
||
quitApp(); // 走清理流程(sing-box/router)后退出,relaunch 生效
|
||
return { ok: true };
|
||
});
|
||
|
||
// —— 检测 / 强制清除本机 Codex ——
|
||
ipcMain.handle("codex-detect", async () => {
|
||
return purge.detectInstalls();
|
||
});
|
||
|
||
ipcMain.handle("codex-purge", async (_e, { paths }) => {
|
||
const targets = Array.isArray(paths) ? paths : [];
|
||
if (!targets.length) return { canceled: false, results: [] };
|
||
|
||
// 二次确认:原生对话框,避免误删
|
||
const r = await dialog.showMessageBox(mainWindow, {
|
||
type: "warning",
|
||
buttons: ["取消", "彻底清除"],
|
||
defaultId: 0,
|
||
cancelId: 0,
|
||
title: "强制清除 Codex",
|
||
message: "确定要彻底清除本机 Codex 吗?",
|
||
detail:
|
||
"将删除以下 " +
|
||
targets.length +
|
||
" 个位置(含配置、登录凭证、会话、插件与可执行文件),此操作不可撤销:\n\n" +
|
||
targets.join("\n") +
|
||
"\n\n清除后需要重新安装并登录 Codex。",
|
||
noLink: true,
|
||
});
|
||
if (r.response !== 1) return { canceled: true, results: [] };
|
||
|
||
log("开始强制清除本机 Codex…");
|
||
const results = await purge.purge(targets);
|
||
for (const it of results) {
|
||
if (it.ok) log(` 已删除:${it.path}`);
|
||
else log(` ⚠️ 删除失败:${it.path} —— ${it.error}`);
|
||
}
|
||
const okCount = results.filter((x) => x.ok).length;
|
||
log(`清除完成:成功 ${okCount} / ${results.length}`);
|
||
return { canceled: false, results };
|
||
});
|