2026-07-15 18:01:56 +08:00
|
|
|
|
// main.js —— 主进程:窗口、IPC、GitHub(isomorphic-git)、市场 API。
|
|
|
|
|
|
// 市场地址/密钥【只在主进程】,绝不下发前端。契约见 CONTRACT.md。
|
2026-07-20 08:48:56 +08:00
|
|
|
|
const { app, BrowserWindow, ipcMain, dialog, shell, Tray, Menu, nativeImage, clipboard } = require("electron");
|
2026-07-15 18:01:56 +08:00
|
|
|
|
const { autoUpdater } = require("electron-updater");
|
|
|
|
|
|
const path = require("path");
|
|
|
|
|
|
const os = require("os");
|
|
|
|
|
|
const fs = require("fs");
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const crypto = require("crypto");
|
|
|
|
|
|
const { execFile } = require("child_process");
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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");
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const codexDataDir = require("./lib/codex_data_dir");
|
2026-07-15 18:01:56 +08:00
|
|
|
|
const providerSync = require("./lib/provider_sync");
|
|
|
|
|
|
const router = require("./lib/router");
|
2026-09-11 09:06:41 +08:00
|
|
|
|
const deeplink = require("./lib/deeplink");
|
2026-07-15 18:01:56 +08:00
|
|
|
|
const canvas = require("./lib/canvas");
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const canvasStore = require("./lib/canvas_store");
|
2026-07-20 08:48:56 +08:00
|
|
|
|
const team = require("./lib/team");
|
|
|
|
|
|
const promptAnalysis = require("./lib/prompt-analysis");
|
|
|
|
|
|
const claudedesk = require("./lib/claudedesk");
|
2026-07-16 19:16:45 +08:00
|
|
|
|
const voice = require("./lib/voice_cdp");
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const codexDesktop = require("./lib/codex_desktop");
|
2026-09-11 09:06:41 +08:00
|
|
|
|
const globalDictation = require("./lib/global_dictation");
|
2026-07-20 08:48:56 +08:00
|
|
|
|
const tokenWatch = require("./lib/token_watch");
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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 的结果
|
2026-08-16 12:45:32 +08:00
|
|
|
|
let voiceSessionDeleteBusy = false;
|
|
|
|
|
|
let stateWriteOperation = "";
|
|
|
|
|
|
const sessionDeleteClipboard = new Map();
|
2026-09-11 09:06:41 +08:00
|
|
|
|
let pendingStartupDeepLink = null;
|
|
|
|
|
|
let globalDictationCleanupTimer = null;
|
|
|
|
|
|
let globalDictationCleanupAttempt = 0;
|
2026-08-16 12:45:32 +08:00
|
|
|
|
|
|
|
|
|
|
async function withStateWriteLock(label, fn) {
|
|
|
|
|
|
if (stateWriteOperation) throw new Error(`正在${stateWriteOperation},请完成后再${label}`);
|
|
|
|
|
|
stateWriteOperation = label;
|
|
|
|
|
|
try { return await fn(); } finally { stateWriteOperation = ""; }
|
|
|
|
|
|
}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
|
|
|
|
|
|
// —— 日志:主进程 → 前端 api.onLog ——
|
|
|
|
|
|
function log(msg) {
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
|
|
|
|
mainWindow.webContents.send("log", String(msg));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 12:45:32 +08:00
|
|
|
|
function deferStartupWork(label, delayMs, fn) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const t0 = Date.now();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = fn();
|
|
|
|
|
|
const done = (out) => {
|
|
|
|
|
|
const ms = Date.now() - t0;
|
|
|
|
|
|
if (ms > 300) log(`${label}完成:${ms}ms`);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
};
|
|
|
|
|
|
if (r && typeof r.then === "function") r.then(done).catch((e) => log(`${label}失败:${e.message || e}`));
|
|
|
|
|
|
else done(r);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log(`${label}异常:${e.message || e}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, delayMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 18:01:56 +08:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 窗口
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 09:06:41 +08:00
|
|
|
|
function configureGlobalDictation(codexHome) {
|
|
|
|
|
|
globalDictation.setEndpointOwnerVerifier((port, options) => codexDesktop.debugPortOwner(port, options));
|
|
|
|
|
|
const result = globalDictation.startGuard({ codexHome, log });
|
|
|
|
|
|
if (result.changed) log("已关闭 Codex 全局听写栏常驻显示");
|
|
|
|
|
|
else if (!result.ok) {
|
|
|
|
|
|
log(`关闭 Codex 全局听写栏常驻显示失败:${result.error || result.reason}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function cleanupGlobalDictation(reason = "") {
|
|
|
|
|
|
const cdp = await globalDictation.closeGlobalDictationTargets(voice.DEFAULT_PORT);
|
|
|
|
|
|
if (cdp.available || !codexDesktop.hideGlobalDictationWindows) {
|
|
|
|
|
|
return cdp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const native = await codexDesktop.hideGlobalDictationWindows();
|
|
|
|
|
|
if (native && native.hidden) {
|
|
|
|
|
|
log(`已将 ChatGPT 全局听写悬浮栏移出屏幕${reason ? `(${reason})` : ""}`);
|
|
|
|
|
|
return {
|
|
|
|
|
|
...cdp,
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
status: "native-hidden",
|
|
|
|
|
|
native,
|
|
|
|
|
|
closed: native.hidden,
|
|
|
|
|
|
remaining: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...cdp, native };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scheduleGlobalDictationCleanup() {
|
|
|
|
|
|
if (globalDictationCleanupTimer) clearTimeout(globalDictationCleanupTimer);
|
|
|
|
|
|
globalDictationCleanupTimer = null;
|
|
|
|
|
|
globalDictationCleanupAttempt = 0;
|
|
|
|
|
|
const delays = [1500, 3000, 6000, 12000, 24000];
|
|
|
|
|
|
|
|
|
|
|
|
const run = async () => {
|
|
|
|
|
|
globalDictationCleanupTimer = null;
|
|
|
|
|
|
const result = await cleanupGlobalDictation("启动清理");
|
|
|
|
|
|
if (result.status === "closed" || result.status === "native-hidden" || result.status === "not-found") {
|
|
|
|
|
|
if (result.closed) log(`已清理 ChatGPT 全局听写悬浮栏 ${result.closed} 个`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (globalDictationCleanupAttempt >= delays.length || isQuitting) return;
|
|
|
|
|
|
const delay = delays[globalDictationCleanupAttempt++];
|
|
|
|
|
|
globalDictationCleanupTimer = setTimeout(() => {
|
|
|
|
|
|
run().catch((e) => log(`重试清理 ChatGPT 全局听写悬浮栏失败:${e.message || e}`));
|
|
|
|
|
|
}, delay);
|
|
|
|
|
|
globalDictationCleanupTimer.unref?.();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
run().catch((e) => log(`清理 ChatGPT 全局听写悬浮栏失败:${e.message || e}`));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function notifyRouterChanged(status) {
|
|
|
|
|
|
rebuildTrayMenu();
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("router-changed", status);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleTdToolboxDeepLink(rawUrl) {
|
|
|
|
|
|
const parsed = deeplink.parseTdToolboxImport(rawUrl);
|
|
|
|
|
|
if (!parsed.ok) {
|
|
|
|
|
|
await dialog.showMessageBox({
|
|
|
|
|
|
type: "warning",
|
|
|
|
|
|
title: "TD工具箱导入失败",
|
|
|
|
|
|
message: "配置深链无法识别",
|
|
|
|
|
|
detail: parsed.error,
|
|
|
|
|
|
buttons: ["知道了"],
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showWindow();
|
|
|
|
|
|
const input = parsed.input;
|
|
|
|
|
|
const answer = await dialog.showMessageBox(mainWindow, {
|
|
|
|
|
|
type: "question",
|
|
|
|
|
|
title: "导入 Sub2API 分组",
|
|
|
|
|
|
message: `导入「${input.name}」到路由中转?`,
|
|
|
|
|
|
detail: "将保存 API 地址、密钥和默认模型到本机分组。不会自动启用该分组,也不会修改 ~/.codex 配置。",
|
|
|
|
|
|
buttons: ["取消", "导入"],
|
|
|
|
|
|
defaultId: 1,
|
|
|
|
|
|
cancelId: 0,
|
|
|
|
|
|
noLink: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (answer.response !== 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const status = router.importSub2apiProvider(input);
|
|
|
|
|
|
notifyRouterChanged(status);
|
|
|
|
|
|
await dialog.showMessageBox(mainWindow, {
|
|
|
|
|
|
type: "info",
|
|
|
|
|
|
title: "导入完成",
|
|
|
|
|
|
message: status._updated ? "已更新 Sub2API 分组" : "已导入 Sub2API 分组",
|
|
|
|
|
|
detail: "请到「路由中转」选择该分组后再接入 Codex。",
|
|
|
|
|
|
buttons: ["知道了"],
|
|
|
|
|
|
});
|
|
|
|
|
|
notifyRouterChanged(router.status());
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log("Sub2API 分组导入失败:" + ((e && e.message) || e));
|
|
|
|
|
|
await dialog.showMessageBox(mainWindow, {
|
|
|
|
|
|
type: "error",
|
|
|
|
|
|
title: "导入失败",
|
|
|
|
|
|
message: "保存 Sub2API 分组失败",
|
|
|
|
|
|
detail: String((e && e.message) || e),
|
|
|
|
|
|
buttons: ["知道了"],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 18:01:56 +08:00
|
|
|
|
function rebuildTrayMenu() {
|
|
|
|
|
|
if (!tray) return;
|
|
|
|
|
|
const st = router.status();
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const groupItems = st.groups.map((g) => {
|
|
|
|
|
|
const toolOnly = !g.isDefault && (g.codexImage || g.codexVideo || g.stt);
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: (g.id === st.activeId ? "● " : "○ ") + g.name + (toolOnly ? "(插件专用)" : ""),
|
|
|
|
|
|
enabled: !toolOnly,
|
|
|
|
|
|
click: () => {
|
|
|
|
|
|
if (toolOnly) return;
|
|
|
|
|
|
router.setActive(g.id);
|
|
|
|
|
|
rebuildTrayMenu();
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("router-changed", router.status());
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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 {
|
2026-09-11 09:06:41 +08:00
|
|
|
|
app.on("second-instance", (_e, argv) => {
|
|
|
|
|
|
const rawUrl = argv.find((item) => item.startsWith(`${deeplink.SCHEME}://`));
|
|
|
|
|
|
if (rawUrl) {
|
|
|
|
|
|
handleTdToolboxDeepLink(rawUrl).catch((e) => log("处理深链失败:" + ((e && e.message) || e)));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
showWindow();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
app.on("open-url", (event, rawUrl) => {
|
|
|
|
|
|
if (!rawUrl || !rawUrl.startsWith(`${deeplink.SCHEME}://`)) return;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
if (app.isReady()) {
|
|
|
|
|
|
handleTdToolboxDeepLink(rawUrl).catch((e) => log("处理深链失败:" + ((e && e.message) || e)));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pendingStartupDeepLink = rawUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (process.defaultApp && process.argv.length > 1) {
|
|
|
|
|
|
app.setAsDefaultProtocolClient(deeplink.SCHEME, process.execPath, [path.resolve(process.argv[1])]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
app.setAsDefaultProtocolClient(deeplink.SCHEME);
|
2026-07-15 18:01:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 09:06:41 +08:00
|
|
|
|
const startupDeepLink = process.argv.find((item) => item.startsWith(`${deeplink.SCHEME}://`));
|
|
|
|
|
|
if (startupDeepLink) pendingStartupDeepLink = startupDeepLink;
|
|
|
|
|
|
|
2026-07-15 18:01:56 +08:00
|
|
|
|
app.whenReady().then(async () => {
|
2026-09-11 09:06:41 +08:00
|
|
|
|
const activeCodexDataDir = codexDataDir.get();
|
|
|
|
|
|
providerSync.setCodexDataDir(activeCodexDataDir);
|
|
|
|
|
|
router.setCodexDataDir(activeCodexDataDir);
|
|
|
|
|
|
configureGlobalDictation(activeCodexDataDir);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const startupRouter = router.status();
|
|
|
|
|
|
if (startupRouter.repair && startupRouter.repair.repaired) {
|
|
|
|
|
|
log(`启动修复 Codex provider:${startupRouter.repair.reason || startupRouter.repair.mode}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log(`启动修复 Codex provider 失败:${e.message || e}`);
|
|
|
|
|
|
}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
createWindow();
|
2026-09-11 09:06:41 +08:00
|
|
|
|
if (pendingStartupDeepLink) {
|
|
|
|
|
|
const rawUrl = pendingStartupDeepLink;
|
|
|
|
|
|
pendingStartupDeepLink = null;
|
|
|
|
|
|
handleTdToolboxDeepLink(rawUrl).catch((e) => log("处理深链失败:" + ((e && e.message) || e)));
|
|
|
|
|
|
}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
setupTray();
|
|
|
|
|
|
app.on("activate", () => showWindow());
|
|
|
|
|
|
setupAutoUpdate();
|
|
|
|
|
|
// 全局代理:状态变化推送到前端 + 刷新托盘;启动时清理上次崩溃残留的系统代理
|
|
|
|
|
|
proxy.setOnChange((st) => {
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("proxy-changed", st);
|
|
|
|
|
|
rebuildTrayMenu();
|
|
|
|
|
|
});
|
2026-08-16 12:45:32 +08:00
|
|
|
|
deferStartupWork("启动清理代理残留", 15000, () => proxy.cleanupStale());
|
2026-07-15 18:01:56 +08:00
|
|
|
|
// 会话记忆:后台监视 ~/.codex/sessions,会话静默后自动摘要入库 + 写 HANDOFF.md
|
|
|
|
|
|
memory.start({
|
|
|
|
|
|
log,
|
|
|
|
|
|
notify: () => {
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("memory-changed");
|
|
|
|
|
|
},
|
2026-08-16 12:45:32 +08:00
|
|
|
|
startupScan: false,
|
2026-07-15 18:01:56 +08:00
|
|
|
|
});
|
2026-07-16 22:36:56 +08:00
|
|
|
|
// 启动时不再自动接管/启动本地路由:用户可能只想用其它功能。
|
|
|
|
|
|
// 保存分组已直接写 Codex 直连配置;只有用户显式「启用本地路由」才 start() 路由进程。
|
|
|
|
|
|
// 仅刷新一次托盘菜单以反映当前分组/状态(不 start)。
|
2026-07-15 18:01:56 +08:00
|
|
|
|
try {
|
2026-07-16 22:36:56 +08:00
|
|
|
|
rebuildTrayMenu();
|
2026-07-15 18:01:56 +08:00
|
|
|
|
} catch (e) {
|
2026-07-16 22:36:56 +08:00
|
|
|
|
log(`刷新托盘失败:${e.message || e}`);
|
2026-07-15 18:01:56 +08:00
|
|
|
|
}
|
2026-08-16 12:45:32 +08:00
|
|
|
|
deferStartupWork("启动迁移 skill", 60000, () => {
|
2026-07-15 18:01:56 +08:00
|
|
|
|
const r = migrateLegacySkills();
|
|
|
|
|
|
if (r.migrated.length) log(`启动迁移 skill: ${r.migrated.length} 个转为软链`);
|
|
|
|
|
|
if (r.errors.length) log(`启动迁移 skill 错误: ${JSON.stringify(r.errors)}`);
|
2026-08-16 12:45:32 +08:00
|
|
|
|
});
|
|
|
|
|
|
deferStartupWork("启动迁移 plugin", 75000, () => {
|
2026-07-15 18:01:56 +08:00
|
|
|
|
const r = migrateLegacyPlugins();
|
|
|
|
|
|
if (r.migrated.length) log(`启动迁移 plugin: ${r.migrated.length} 个转为软链`);
|
|
|
|
|
|
if (r.errors.length) log(`启动迁移 plugin 错误: ${JSON.stringify(r.errors)}`);
|
2026-08-16 12:45:32 +08:00
|
|
|
|
});
|
2026-09-11 09:06:41 +08:00
|
|
|
|
deferStartupWork("启动修复会话目录", 5000, async () => {
|
|
|
|
|
|
await providerSync.repairLegacySessionVisibility(log);
|
|
|
|
|
|
});
|
|
|
|
|
|
scheduleGlobalDictationCleanup();
|
|
|
|
|
|
// 启动自愈:把当前激活分组的同族文本模型回填进 Codex 模型目录。
|
2026-07-20 08:48:56 +08:00
|
|
|
|
// 目的——用户在 Codex 本体的模型选择器里切模型(不经本 App)时,若目标模型不在目录里,
|
|
|
|
|
|
// Codex 会以未知模型启动、侧栏历史还原失败 = "切模型后会话记录消失"。先回填即根治,
|
|
|
|
|
|
// 且既有安装无需重新保存分组即可自动修复。异步、失败不动目录(离线安全)。
|
2026-08-16 12:45:32 +08:00
|
|
|
|
deferStartupWork("启动同步模型目录", 30000, async () => {
|
2026-07-20 08:48:56 +08:00
|
|
|
|
const active = router.llmGroup(); // 当前激活且配齐 baseUrl+key+model 的中转分组(排除默认直连)
|
2026-08-16 12:45:32 +08:00
|
|
|
|
if (!active) return;
|
|
|
|
|
|
const r = await router.syncCatalogFromProvider(active);
|
2026-09-11 09:06:41 +08:00
|
|
|
|
if (r && r.ok && (r.added || r.revealed || r.hidden)) {
|
|
|
|
|
|
log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 隐藏 ${r.hidden} · 共 ${r.total}`);
|
|
|
|
|
|
}
|
2026-08-16 12:45:32 +08:00
|
|
|
|
});
|
2026-07-15 18:01:56 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 统一退出:清理代理(杀 sing-box + 还原网络) → 停本地路由 → 销毁托盘 → 退出。
|
|
|
|
|
|
// 带强制兜底,保证托盘「退出」后不残留任何进程。
|
|
|
|
|
|
let quitting = false;
|
|
|
|
|
|
async function quitApp() {
|
|
|
|
|
|
if (quitting) return;
|
|
|
|
|
|
quitting = true;
|
|
|
|
|
|
isQuitting = true;
|
2026-09-11 09:06:41 +08:00
|
|
|
|
if (globalDictationCleanupTimer) {
|
|
|
|
|
|
clearTimeout(globalDictationCleanupTimer);
|
|
|
|
|
|
globalDictationCleanupTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await Promise.race([
|
|
|
|
|
|
voice.disconnect(),
|
|
|
|
|
|
new Promise((resolve) => setTimeout(resolve, 2500)),
|
|
|
|
|
|
]);
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await Promise.race([
|
|
|
|
|
|
cleanupGlobalDictation("退出清理"),
|
|
|
|
|
|
new Promise((resolve) => setTimeout(resolve, 2500)),
|
|
|
|
|
|
]);
|
|
|
|
|
|
} catch {}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
try {
|
|
|
|
|
|
memory.stop(); // 停监视 + 记忆库落盘
|
|
|
|
|
|
} catch {}
|
2026-09-11 09:06:41 +08:00
|
|
|
|
try {
|
|
|
|
|
|
globalDictation.stopGuard();
|
|
|
|
|
|
} catch {}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 最多等 5 秒完成网络还原 / 拆 TUN / 杀引擎,超时也继续退出
|
|
|
|
|
|
await Promise.race([proxy.shutdown(), new Promise((r) => setTimeout(r, 5000))]);
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
router.stop();
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
if (tray) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
tray.destroy();
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
tray = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
app.quit();
|
|
|
|
|
|
// 兜底:若 1.2 秒后仍未退干净(有句柄/子进程挂着),强制结束所有进程
|
|
|
|
|
|
setTimeout(() => app.exit(0), 1200);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 拦截所有退出入口(托盘退出 / Cmd+Q / 自动更新),统一走 quitApp 做彻底清理
|
|
|
|
|
|
app.on("before-quit", (e) => {
|
|
|
|
|
|
if (quitting) return; // 已在退出流程中 → 放行,让其正常结束
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
quitApp();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 在线更新(electron-updater + Gitea Releases)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
function sendUpdate(channel, data) {
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 18:04:01 +08:00
|
|
|
|
const UPDATE_OWNER = "mhgmh";
|
|
|
|
|
|
const UPDATE_REPO = "codex-market-app";
|
2026-07-15 18:01:56 +08:00
|
|
|
|
// 自建 gitea 发布源(国内可达、匿名可读):完整包更新和热更都走这里。
|
|
|
|
|
|
// up 域名给 API/直连,update 域名给用户浏览下载页(管理登录会话在此)。
|
2026-07-15 19:18:03 +08:00
|
|
|
|
const GITEA_RELEASES_API = `https://update.tokendun.cc/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`;
|
2026-07-15 18:01:56 +08:00
|
|
|
|
const GITEA_RELEASES_PAGE = `https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases`;
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const CODEX_INSTALLER_RELEASE_TAG = "codex-installers";
|
|
|
|
|
|
const CODEX_WINDOWS_MSIX_NAME = "Codex-Windows-x64.msix";
|
|
|
|
|
|
const CODEX_MAC_DMG_NAME = "ChatGPT.dmg";
|
|
|
|
|
|
const CODEX_WINDOWS_MSIX_URL =
|
|
|
|
|
|
`https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases/download/${CODEX_INSTALLER_RELEASE_TAG}/${CODEX_WINDOWS_MSIX_NAME}`;
|
|
|
|
|
|
const CODEX_WINDOWS_MSIX_SHA256_URL = `${CODEX_WINDOWS_MSIX_URL}.sha256`;
|
|
|
|
|
|
const CODEX_MAC_DMG_URL =
|
|
|
|
|
|
`https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases/download/${CODEX_INSTALLER_RELEASE_TAG}/${CODEX_MAC_DMG_NAME}`;
|
|
|
|
|
|
const CODEX_MAC_DMG_SHA256_URL = `${CODEX_MAC_DMG_URL}.sha256`;
|
|
|
|
|
|
const codexInstallerDownloadControllers = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
function codexInstallerTarget(platform) {
|
|
|
|
|
|
if (platform === "windows") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
platform,
|
|
|
|
|
|
fileName: CODEX_WINDOWS_MSIX_NAME,
|
|
|
|
|
|
url: CODEX_WINDOWS_MSIX_URL,
|
|
|
|
|
|
sha256Url: CODEX_WINDOWS_MSIX_SHA256_URL,
|
|
|
|
|
|
label: "Codex Windows 版",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (platform === "mac") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
platform,
|
|
|
|
|
|
fileName: CODEX_MAC_DMG_NAME,
|
|
|
|
|
|
url: CODEX_MAC_DMG_URL,
|
|
|
|
|
|
sha256Url: CODEX_MAC_DMG_SHA256_URL,
|
|
|
|
|
|
label: "Codex Mac 版",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error("未知安装平台");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sendCodexInstallerProgress(data) {
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
|
|
|
|
mainWindow.webContents.send("codex-installer-progress", data);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function codexInstallerCachePath(fileName) {
|
|
|
|
|
|
return path.join(app.getPath("userData"), "installers", fileName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isAbortError(e) {
|
|
|
|
|
|
return (e && e.name === "AbortError") || /aborted|abort/i.test(String(e && e.message ? e.message : e));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function assertCodexInstallerNotCanceled(signal) {
|
|
|
|
|
|
if (signal && signal.aborted) {
|
|
|
|
|
|
const err = new Error("已取消下载");
|
|
|
|
|
|
err.name = "AbortError";
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseSha256(text) {
|
|
|
|
|
|
const m = String(text || "").match(/\b[a-fA-F0-9]{64}\b/);
|
|
|
|
|
|
return m ? m[0].toLowerCase() : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hashFile(file) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const h = crypto.createHash("sha256");
|
|
|
|
|
|
fs.createReadStream(file)
|
|
|
|
|
|
.on("data", (chunk) => h.update(chunk))
|
|
|
|
|
|
.on("error", reject)
|
|
|
|
|
|
.on("end", () => resolve(h.digest("hex")));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchInstallerSha256(sha256Url, signal) {
|
|
|
|
|
|
const res = await fetch(sha256Url, {
|
|
|
|
|
|
headers: { "User-Agent": "codex-market", "Cache-Control": "no-cache" },
|
|
|
|
|
|
signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok) throw new Error(`安装包校验文件未发布或不可访问:HTTP ${res.status}`);
|
|
|
|
|
|
const sha256 = parseSha256(await res.text());
|
|
|
|
|
|
if (!sha256) throw new Error("安装包校验文件格式不正确");
|
|
|
|
|
|
return sha256;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function downloadCodexInstaller({ fileName, url, expectedSha256, platform, signal }) {
|
|
|
|
|
|
const installerPath = codexInstallerCachePath(fileName);
|
|
|
|
|
|
const tmpPath = `${installerPath}.download`;
|
|
|
|
|
|
fs.mkdirSync(path.dirname(installerPath), { recursive: true });
|
|
|
|
|
|
assertCodexInstallerNotCanceled(signal);
|
|
|
|
|
|
|
|
|
|
|
|
if (fs.existsSync(installerPath)) {
|
|
|
|
|
|
sendCodexInstallerProgress({ platform, phase: "verify", message: "校验本地缓存…", percent: 60 });
|
|
|
|
|
|
assertCodexInstallerNotCanceled(signal);
|
|
|
|
|
|
const cachedSha256 = await hashFile(installerPath);
|
|
|
|
|
|
assertCodexInstallerNotCanceled(signal);
|
|
|
|
|
|
if (cachedSha256 === expectedSha256) {
|
|
|
|
|
|
sendCodexInstallerProgress({ platform, phase: "cached", message: "已使用本地缓存", percent: 62 });
|
|
|
|
|
|
return installerPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
try { fs.rmSync(installerPath, { force: true }); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sendCodexInstallerProgress({ platform, phase: "download", message: "开始下载安装包…", downloaded: 0, total: 0, percent: 0 });
|
|
|
|
|
|
const res = await fetch(url, { headers: { "User-Agent": "codex-market" }, signal });
|
|
|
|
|
|
if (!res.ok) throw new Error(`安装包下载失败:HTTP ${res.status}`);
|
|
|
|
|
|
if (!res.body || typeof res.body.getReader !== "function") throw new Error("当前运行环境不支持流式下载");
|
|
|
|
|
|
|
|
|
|
|
|
const total = Number(res.headers.get("content-length") || 0);
|
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
|
|
|
const out = fs.createWriteStream(tmpPath);
|
|
|
|
|
|
const streamError = new Promise((_, reject) => out.once("error", reject));
|
|
|
|
|
|
let downloaded = 0;
|
|
|
|
|
|
let lastEmit = 0;
|
|
|
|
|
|
try {
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
|
assertCodexInstallerNotCanceled(signal);
|
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
|
if (done) break;
|
|
|
|
|
|
const chunk = Buffer.from(value);
|
|
|
|
|
|
downloaded += chunk.length;
|
|
|
|
|
|
if (!out.write(chunk)) await Promise.race([streamError, new Promise((resolve) => out.once("drain", resolve))]);
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (now - lastEmit > 300) {
|
|
|
|
|
|
lastEmit = now;
|
|
|
|
|
|
sendCodexInstallerProgress({
|
|
|
|
|
|
platform,
|
|
|
|
|
|
phase: "download",
|
|
|
|
|
|
downloaded,
|
|
|
|
|
|
total,
|
|
|
|
|
|
percent: total ? Math.round((downloaded / total) * 100) : 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await Promise.race([streamError, new Promise((resolve) => out.end(resolve))]);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
try { out.destroy(); } catch {}
|
|
|
|
|
|
try { fs.rmSync(tmpPath, { force: true }); } catch {}
|
|
|
|
|
|
throw e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sendCodexInstallerProgress({ platform, phase: "verify", message: "校验安装包…", percent: 60 });
|
|
|
|
|
|
assertCodexInstallerNotCanceled(signal);
|
|
|
|
|
|
const sha256 = await hashFile(tmpPath);
|
|
|
|
|
|
assertCodexInstallerNotCanceled(signal);
|
|
|
|
|
|
if (sha256 !== expectedSha256) {
|
|
|
|
|
|
try { fs.rmSync(tmpPath, { force: true }); } catch {}
|
|
|
|
|
|
throw new Error("安装包 SHA256 校验失败,已停止安装");
|
|
|
|
|
|
}
|
|
|
|
|
|
fs.renameSync(tmpPath, installerPath);
|
|
|
|
|
|
return installerPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function downloadCodexInstallerForPlatform(platform) {
|
|
|
|
|
|
const target = codexInstallerTarget(platform);
|
|
|
|
|
|
if (codexInstallerDownloadControllers.has(platform)) throw new Error(`${target.label}安装包正在下载中`);
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
codexInstallerDownloadControllers.set(platform, controller);
|
|
|
|
|
|
try {
|
|
|
|
|
|
sendCodexInstallerProgress({ platform, phase: "checking", message: "读取安装包校验信息…", percent: 2 });
|
|
|
|
|
|
const expectedSha256 = await fetchInstallerSha256(target.sha256Url, controller.signal);
|
|
|
|
|
|
const installerPath = await downloadCodexInstaller({
|
|
|
|
|
|
fileName: target.fileName,
|
|
|
|
|
|
url: target.url,
|
|
|
|
|
|
expectedSha256,
|
|
|
|
|
|
platform,
|
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
|
});
|
|
|
|
|
|
sendCodexInstallerProgress({ platform, phase: "downloaded", message: `${target.label}安装包已下载并校验完成`, percent: 100 });
|
|
|
|
|
|
return { ok: true, platform, path: installerPath };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (isAbortError(e)) {
|
|
|
|
|
|
try { fs.rmSync(`${codexInstallerCachePath(target.fileName)}.download`, { force: true }); } catch {}
|
|
|
|
|
|
sendCodexInstallerProgress({ platform, phase: "canceled", message: `${target.label}安装包下载已取消`, percent: 0 });
|
|
|
|
|
|
throw new Error("已取消下载");
|
|
|
|
|
|
}
|
|
|
|
|
|
throw e;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (codexInstallerDownloadControllers.get(platform) === controller) {
|
|
|
|
|
|
codexInstallerDownloadControllers.delete(platform);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cancelCodexInstallerDownload(platform) {
|
|
|
|
|
|
const target = codexInstallerTarget(platform);
|
|
|
|
|
|
const controller = codexInstallerDownloadControllers.get(platform);
|
|
|
|
|
|
if (!controller) return { ok: true, canceled: false };
|
|
|
|
|
|
controller.abort();
|
|
|
|
|
|
sendCodexInstallerProgress({ platform, phase: "canceling", message: `正在取消${target.label}安装包下载…`, percent: 0 });
|
|
|
|
|
|
return { ok: true, canceled: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function execFileAsync(file, args, opts = {}) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
execFile(file, args, opts, (err, stdout, stderr) => {
|
|
|
|
|
|
if (!err) return resolve({ stdout, stderr });
|
|
|
|
|
|
const msg = String(stderr || stdout || err.message || err).trim();
|
|
|
|
|
|
reject(new Error(msg || `${path.basename(file)} 执行失败`));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runCodexWindowsMsix(installerPath) {
|
|
|
|
|
|
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
|
|
|
|
|
const bundledPowerShell = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
|
|
|
|
const powerShell = fs.existsSync(bundledPowerShell) ? bundledPowerShell : "powershell.exe";
|
|
|
|
|
|
const args = [
|
|
|
|
|
|
"-NoProfile",
|
|
|
|
|
|
"-ExecutionPolicy", "Bypass",
|
|
|
|
|
|
"-Command",
|
|
|
|
|
|
"& { param([string]$pkg) Add-AppxPackage -Path $pkg }",
|
|
|
|
|
|
installerPath,
|
|
|
|
|
|
];
|
|
|
|
|
|
return execFileAsync(powerShell, args, { windowsHide: false, timeout: 15 * 60 * 1000 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function installCodexWindows() {
|
|
|
|
|
|
if (process.platform !== "win32") {
|
|
|
|
|
|
throw new Error("Windows 一键安装只能在 Windows 上运行;可先在软件内下载 MSIX,再发给 Windows 用户安装。");
|
|
|
|
|
|
}
|
|
|
|
|
|
const { path: installerPath } = await downloadCodexInstallerForPlatform("windows");
|
|
|
|
|
|
sendCodexInstallerProgress({ platform: "windows", phase: "install", message: "正在调用 PowerShell 安装…", percent: 85 });
|
|
|
|
|
|
await runCodexWindowsMsix(installerPath);
|
|
|
|
|
|
sendCodexInstallerProgress({ platform: "windows", phase: "done", message: "Codex Windows 版安装完成", percent: 100 });
|
|
|
|
|
|
return { ok: true, path: installerPath };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findMountedAppBundle(mountDir) {
|
|
|
|
|
|
const items = fs.readdirSync(mountDir, { withFileTypes: true });
|
|
|
|
|
|
const appItem = items.find((ent) => ent.isDirectory() && ent.name.endsWith(".app"));
|
|
|
|
|
|
if (!appItem) throw new Error("DMG 中没有找到 .app 应用");
|
|
|
|
|
|
return path.join(mountDir, appItem.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runCodexMacDmg(dmgPath) {
|
|
|
|
|
|
const mountDir = path.join(app.getPath("temp"), `codex-installer-${Date.now()}`);
|
|
|
|
|
|
fs.rmSync(mountDir, { recursive: true, force: true });
|
|
|
|
|
|
fs.mkdirSync(mountDir, { recursive: true });
|
|
|
|
|
|
let mounted = false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: "正在挂载 DMG…", percent: 68 });
|
|
|
|
|
|
await execFileAsync("/usr/bin/hdiutil", ["attach", dmgPath, "-nobrowse", "-readonly", "-mountpoint", mountDir], { timeout: 5 * 60 * 1000 });
|
|
|
|
|
|
mounted = true;
|
|
|
|
|
|
|
|
|
|
|
|
const appPath = findMountedAppBundle(mountDir);
|
|
|
|
|
|
const targetPath = path.join("/Applications", path.basename(appPath));
|
|
|
|
|
|
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: `正在复制到 /Applications/${path.basename(appPath)}…`, percent: 82 });
|
|
|
|
|
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
|
|
|
|
await execFileAsync("/usr/bin/ditto", [appPath, targetPath], { timeout: 15 * 60 * 1000 });
|
|
|
|
|
|
|
|
|
|
|
|
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: "正在卸载 DMG…", percent: 94 });
|
|
|
|
|
|
await execFileAsync("/usr/bin/hdiutil", ["detach", mountDir], { timeout: 2 * 60 * 1000 });
|
|
|
|
|
|
mounted = false;
|
|
|
|
|
|
return { appPath: targetPath };
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
try { await execFileAsync("/usr/bin/hdiutil", ["detach", mountDir], { timeout: 2 * 60 * 1000 }); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
try { fs.rmSync(mountDir, { recursive: true, force: true }); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function installCodexMac() {
|
|
|
|
|
|
if (process.platform !== "darwin") {
|
|
|
|
|
|
throw new Error("Mac 一键安装只能在 macOS 上运行;可先在软件内下载 DMG,再发给 Mac 用户安装。");
|
|
|
|
|
|
}
|
|
|
|
|
|
const { path: installerPath } = await downloadCodexInstallerForPlatform("mac");
|
|
|
|
|
|
const result = await runCodexMacDmg(installerPath);
|
|
|
|
|
|
sendCodexInstallerProgress({ platform: "mac", phase: "done", message: "Codex Mac 版安装完成", percent: 100 });
|
|
|
|
|
|
return { ok: true, path: installerPath, appPath: result.appPath };
|
|
|
|
|
|
}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 比较版本号: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;
|
|
|
|
|
|
}
|
2026-09-11 09:06:41 +08:00
|
|
|
|
if (r.needFull || r.needFullAfterHot) {
|
|
|
|
|
|
sendUpdate("update-status", {
|
|
|
|
|
|
state: "available-manual",
|
|
|
|
|
|
version: r.fullAppVersion || r.contentVersion,
|
|
|
|
|
|
url: GITEA_RELEASES_PAGE,
|
|
|
|
|
|
notes: `${r.notes || ""}${r.needFullAfterHot ? "\n\n本次更新还需要安装完整包,以注册 TD 工具箱深链协议。" : ""}`.trim(),
|
|
|
|
|
|
mandatory: r.mandatory,
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
} 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("该技能没有可用的下载地址");
|
2026-07-16 19:16:45 +08:00
|
|
|
|
const safeName = safeSkillDir(skill.name);
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 19:16:45 +08:00
|
|
|
|
// 把名称规整成磁盘技能目录名(与 renderer 的 safeSkillDir 同算法)。统一小写,避免同一技能的大小写变体重复安装、软链名与实体名错位。
|
2026-07-15 18:01:56 +08:00
|
|
|
|
function safeSkillDir(name) {
|
|
|
|
|
|
return (String(name || "skill")
|
2026-07-16 19:16:45 +08:00
|
|
|
|
.toLowerCase()
|
2026-07-15 18:01:56 +08:00
|
|
|
|
.replace(/[^\w一-龥.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80)) || "skill";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 列出已安装技能:扫描 ~/.codex/skills/<name>/
|
2026-08-16 12:45:32 +08:00
|
|
|
|
function skillInstalledList(opts = {}) {
|
2026-07-15 18:01:56 +08:00
|
|
|
|
const base = path.join(codex.CODEX_HOME, "skills");
|
|
|
|
|
|
if (!fs.existsSync(base)) return [];
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const includeSize = !!opts.includeSize;
|
|
|
|
|
|
const includeMeta = opts.includeMeta !== false;
|
2026-07-15 18:01:56 +08:00
|
|
|
|
// 建索引:磁盘目录名 -> catalog 条目(中文名/中文简介)。读不到 catalog 就空索引,回退 SKILL.md。
|
|
|
|
|
|
let byDir = null;
|
2026-08-16 12:45:32 +08:00
|
|
|
|
if (includeMeta) {
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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 {}
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const meta = includeMeta ? readSkillMeta(dir, ent.name) : { name: ent.name, description: "" };
|
2026-07-15 18:01:56 +08:00
|
|
|
|
// 优先用 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,
|
2026-08-16 12:45:32 +08:00
|
|
|
|
size: includeSize ? dirSize(dir) : 0,
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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>/
|
2026-08-16 12:45:32 +08:00
|
|
|
|
function pluginInstalledList(opts = {}) {
|
2026-07-15 18:01:56 +08:00
|
|
|
|
const base = path.join(codex.CODEX_HOME, "plugins", "cache");
|
|
|
|
|
|
if (!fs.existsSync(base)) return [];
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const includeSize = !!opts.includeSize;
|
|
|
|
|
|
const includeMeta = opts.includeMeta !== false;
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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 作兜底
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const info = includeMeta ? builtinPluginInfo(ent.name) : null;
|
2026-07-15 18:01:56 +08:00
|
|
|
|
out.push({
|
|
|
|
|
|
source: src.name,
|
|
|
|
|
|
name: ent.name,
|
|
|
|
|
|
displayName: (info && info.displayName) || ent.name, // 中文名,匹配不到回退英文 slug
|
2026-08-16 12:45:32 +08:00
|
|
|
|
description: includeMeta ? ((info && info.description) || readPluginDescription(dir)) : "",
|
2026-07-15 18:01:56 +08:00
|
|
|
|
rel: `plugins/cache/${src.name}/${ent.name}`,
|
2026-08-16 12:45:32 +08:00
|
|
|
|
size: includeSize ? dirSize(dir) : 0,
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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));
|
2026-08-16 12:45:32 +08:00
|
|
|
|
ipcMain.handle("skill-installed", async (_e, opts = {}) => skillInstalledList(opts || {}));
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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));
|
2026-08-16 12:45:32 +08:00
|
|
|
|
ipcMain.handle("plugin-installed", async (_e, opts = {}) => pluginInstalledList(opts || {}));
|
2026-07-15 18:01:56 +08:00
|
|
|
|
ipcMain.handle("plugin-uninstall", async (_e, { rel }) => pluginUninstall(rel));
|
|
|
|
|
|
ipcMain.handle("plugin-migrate", async () => migrateLegacyPlugins());
|
2026-08-16 12:45:32 +08:00
|
|
|
|
ipcMain.handle("codex-installer-info", async () => ({
|
|
|
|
|
|
platform: process.platform,
|
|
|
|
|
|
isWindows: process.platform === "win32",
|
|
|
|
|
|
isMac: process.platform === "darwin",
|
|
|
|
|
|
windowsUrl: CODEX_WINDOWS_MSIX_URL,
|
|
|
|
|
|
windowsSha256Url: CODEX_WINDOWS_MSIX_SHA256_URL,
|
|
|
|
|
|
macUrl: CODEX_MAC_DMG_URL,
|
|
|
|
|
|
macSha256Url: CODEX_MAC_DMG_SHA256_URL,
|
|
|
|
|
|
windowsCachePath: codexInstallerCachePath(CODEX_WINDOWS_MSIX_NAME),
|
|
|
|
|
|
macCachePath: codexInstallerCachePath(CODEX_MAC_DMG_NAME),
|
|
|
|
|
|
}));
|
|
|
|
|
|
ipcMain.handle("codex-download-installer", async (_e, { platform }) => downloadCodexInstallerForPlatform(platform));
|
|
|
|
|
|
ipcMain.handle("codex-cancel-installer-download", async (_e, { platform }) => cancelCodexInstallerDownload(platform));
|
|
|
|
|
|
ipcMain.handle("codex-install-windows", async () => installCodexWindows());
|
|
|
|
|
|
ipcMain.handle("codex-install-mac", async () => installCodexMac());
|
2026-07-15 18:01:56 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// —— 会话记录:浏览 / 读取 / 导出 / 备份 / 导入 ——
|
2026-08-16 12:45:32 +08:00
|
|
|
|
ipcMain.handle("sessions-data-dir-info", async () => codexDataDir.info());
|
|
|
|
|
|
ipcMain.handle("sessions-data-dir-select", async () => {
|
|
|
|
|
|
const current = codexDataDir.info();
|
|
|
|
|
|
const picked = await dialog.showOpenDialog(mainWindow, {
|
|
|
|
|
|
title: "选择 Codex 会话数据目录",
|
|
|
|
|
|
defaultPath: current.path,
|
|
|
|
|
|
properties: ["openDirectory"],
|
|
|
|
|
|
});
|
|
|
|
|
|
if (picked.canceled || !picked.filePaths.length) return { canceled: true, ...current };
|
|
|
|
|
|
const next = codexDataDir.set(picked.filePaths[0]);
|
|
|
|
|
|
providerSync.setCodexDataDir(next.path);
|
|
|
|
|
|
router.setCodexDataDir(next.path);
|
2026-09-11 09:06:41 +08:00
|
|
|
|
configureGlobalDictation(next.path);
|
2026-08-16 12:45:32 +08:00
|
|
|
|
memory.restartWatching();
|
|
|
|
|
|
log(`会话数据目录已切换:${next.path}`);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("sessions-data-dir-reset", async () => {
|
|
|
|
|
|
const next = codexDataDir.reset();
|
|
|
|
|
|
providerSync.setCodexDataDir(next.path);
|
|
|
|
|
|
router.setCodexDataDir(next.path);
|
2026-09-11 09:06:41 +08:00
|
|
|
|
configureGlobalDictation(next.path);
|
2026-08-16 12:45:32 +08:00
|
|
|
|
memory.restartWatching();
|
|
|
|
|
|
log(`会话数据目录已恢复默认:${next.path}`);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
2026-07-15 18:01:56 +08:00
|
|
|
|
ipcMain.handle("sessions-list", async (_e, opts = {}) => {
|
|
|
|
|
|
return sessions.listConversations(opts || {});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
ipcMain.handle("session-read", async (_e, { path: filePath }) => {
|
|
|
|
|
|
return sessions.readConversation(filePath);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-11 09:06:41 +08:00
|
|
|
|
ipcMain.handle("sessions-clear-all", async () => {
|
|
|
|
|
|
return withStateWriteLock("清空全部会话", async () => {
|
|
|
|
|
|
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再清空会话记录");
|
|
|
|
|
|
const result = await sessions.clearAllConversations();
|
|
|
|
|
|
let memoryRemoved = 0;
|
|
|
|
|
|
let cleanupWarning = "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const memoryResult = await memory.clearAllSessions();
|
|
|
|
|
|
memoryRemoved = memoryResult.count || 0;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
cleanupWarning = String((e && e.message) || e);
|
|
|
|
|
|
log("会话主体已清空,但工具箱会话记忆清理失败:" + cleanupWarning);
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 停止待处理的摘要任务,避免已删除 rollout 被重新写回记忆库。
|
|
|
|
|
|
memory.restartWatching();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const warning = String((e && e.message) || e);
|
|
|
|
|
|
cleanupWarning = cleanupWarning ? cleanupWarning + ";" + warning : warning;
|
|
|
|
|
|
log("会话已清空,但重启会话记忆监视器失败:" + warning);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...result, memoryRemoved, cleanupWarning };
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-16 12:45:32 +08:00
|
|
|
|
ipcMain.handle("session-delete-permanently", async (_e, { path: filePath } = {}) => {
|
|
|
|
|
|
return withStateWriteLock("永久删除会话", async () => {
|
|
|
|
|
|
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再永久删除会话");
|
|
|
|
|
|
const result = await sessions.deleteConversation(filePath);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await memory.deleteSession(result.id, result.cwd);
|
|
|
|
|
|
return { ok: true, ...result };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log("会话主体已删除,但工具箱记忆清理失败:" + (e.message || e));
|
|
|
|
|
|
return { ok: true, cleanupWarning: String(e.message || e), ...result };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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 };
|
2026-08-16 12:45:32 +08:00
|
|
|
|
return withStateWriteLock("导入并恢复会话", async () => {
|
|
|
|
|
|
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再导入并恢复会话");
|
|
|
|
|
|
const imported = sessions.importSessions(r.filePaths[0], log);
|
|
|
|
|
|
const onProgress = (p) => {
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("provider-sync-progress", p);
|
|
|
|
|
|
};
|
|
|
|
|
|
const synced = await providerSync.syncProviderToCurrent(log, onProgress);
|
|
|
|
|
|
return { ...imported, ...synced, restored: true };
|
|
|
|
|
|
});
|
2026-07-15 18:01:56 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// —— 会话记忆:状态 / 检索 / 开关 / 手动摘要 ——
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-08-16 12:45:32 +08:00
|
|
|
|
return withStateWriteLock("同步会话账号", () => providerSync.syncProviderToCurrent(log, onProgress));
|
2026-07-15 18:01:56 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// —— 本地路由 ——
|
|
|
|
|
|
ipcMain.handle("router-status", async () => router.status());
|
2026-08-16 12:45:32 +08:00
|
|
|
|
ipcMain.handle("router-default-config", async () => router.defaultConfigInfo());
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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 () => {
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const r = router.stopAndRestoreDirect();
|
2026-07-15 18:01:56 +08:00
|
|
|
|
rebuildTrayMenu();
|
2026-08-16 12:45:32 +08:00
|
|
|
|
return { ...r, status: router.status() };
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("router-disable-codex", async () => {
|
|
|
|
|
|
const r = router.disableCodex();
|
|
|
|
|
|
rebuildTrayMenu();
|
|
|
|
|
|
return { ...r, status: router.status() };
|
2026-07-15 18:01:56 +08:00
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("router-apply-codex", async () => {
|
|
|
|
|
|
const r = router.applyToCodex();
|
|
|
|
|
|
if (!router.isRunning()) await router.start().catch(() => {});
|
|
|
|
|
|
rebuildTrayMenu();
|
|
|
|
|
|
return { ...r, status: router.status() };
|
|
|
|
|
|
});
|
2026-07-16 22:36:56 +08:00
|
|
|
|
// 把指定分组的直连中转配置写进 Codex(不经路由、不起进程)
|
|
|
|
|
|
ipcMain.handle("router-apply-direct", async (_e, { id } = {}) => {
|
|
|
|
|
|
const r = router.applyDirectToCodex(id);
|
|
|
|
|
|
rebuildTrayMenu();
|
|
|
|
|
|
return { ...r, status: router.status() };
|
|
|
|
|
|
});
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-20 08:48:56 +08:00
|
|
|
|
// —— 接入 Claude 桌面 App(第三方推理 / gateway 模式)——
|
|
|
|
|
|
ipcMain.handle("claudedesk-status", () => claudedesk.status());
|
|
|
|
|
|
ipcMain.handle("claudedesk-apply", (_e, { groupId }) => claudedesk.applyGroup(groupId));
|
|
|
|
|
|
ipcMain.handle("claudedesk-restore", () => claudedesk.restoreOfficial());
|
|
|
|
|
|
ipcMain.handle("claudedesk-snippet", (_e, { groupId }) => {
|
|
|
|
|
|
const r = claudedesk.snippet(groupId);
|
|
|
|
|
|
if (r.ok) { try { clipboard.writeText(r.text); } catch {} }
|
|
|
|
|
|
return r.ok ? { ok: true } : r;
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("claudedesk-export", async (_e, { groupId, format }) => {
|
|
|
|
|
|
const r = claudedesk.exportConfig(groupId, format);
|
|
|
|
|
|
if (!r.ok) return r;
|
|
|
|
|
|
const dl = await dialog.showSaveDialog(mainWindow, {
|
|
|
|
|
|
title: "导出 Claude 桌面配置",
|
|
|
|
|
|
defaultPath: r.filename,
|
|
|
|
|
|
filters: [{ name: r.ext.toUpperCase(), extensions: [r.ext] }],
|
|
|
|
|
|
});
|
|
|
|
|
|
if (dl.canceled || !dl.filePath) return { ok: false, canceled: true };
|
|
|
|
|
|
try { fs.writeFileSync(dl.filePath, r.content); } catch (e) { return { ok: false, error: String(e.message || e) }; }
|
|
|
|
|
|
return { ok: true, path: dl.filePath };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-15 18:01:56 +08:00
|
|
|
|
// —— 创意画布(中转执行层)——
|
|
|
|
|
|
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));
|
2026-08-16 12:45:32 +08:00
|
|
|
|
ipcMain.handle("canvas-workspaces", () => ({ ok: true, workspaces: canvasStore.list() }));
|
|
|
|
|
|
ipcMain.handle("canvas-workspace-create", (_e, { name } = {}) => {
|
|
|
|
|
|
try { return { ok: true, workspace: canvasStore.create(name) }; }
|
|
|
|
|
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("canvas-workspace-load", (_e, { id } = {}) => {
|
|
|
|
|
|
try { return { ok: true, workspace: canvasStore.load(id) }; }
|
|
|
|
|
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("canvas-workspace-rename", (_e, { id, name } = {}) => {
|
|
|
|
|
|
try { return { ok: true, workspace: canvasStore.rename(id, name) }; }
|
|
|
|
|
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("canvas-workspace-save", (_e, { workspace } = {}) => {
|
|
|
|
|
|
try { return { ok: true, workspace: canvasStore.save(workspace) }; }
|
|
|
|
|
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.on("canvas-workspace-save-now", (_e, workspace) => {
|
|
|
|
|
|
try { canvasStore.save(workspace); } catch {}
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("canvas-media-cache", async (_e, payload = {}) => {
|
|
|
|
|
|
try { return { ok: true, ...(await canvasStore.cacheMedia(payload)) }; }
|
|
|
|
|
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("canvas-media-read", async (_e, payload = {}) => {
|
|
|
|
|
|
try { return { ok: true, ...(await canvasStore.readMedia(payload)) }; }
|
|
|
|
|
|
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
|
|
|
|
|
});
|
2026-07-15 18:01:56 +08:00
|
|
|
|
|
2026-07-20 08:48:56 +08:00
|
|
|
|
// —— 多 Agent 员工团队生成(需求 → 团队 → 打包 zip)——
|
|
|
|
|
|
ipcMain.handle("team-generate", async (_e, params) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await team.generate(params || {});
|
|
|
|
|
|
return { ok: true, result };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return { ok: false, error: String((err && err.message) || err) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("team-preview", (_e, params) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return { ok: true, ...team.preview(params || {}) };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return { ok: false, error: String((err && err.message) || err) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("team-pack", async (_e, params) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { buffer, filename } = team.pack(params || {});
|
|
|
|
|
|
const r = await dialog.showSaveDialog(mainWindow, {
|
|
|
|
|
|
title: "保存员工团队配置包",
|
|
|
|
|
|
defaultPath: filename,
|
|
|
|
|
|
filters: [{ name: "Zip 压缩包", extensions: ["zip"] }],
|
|
|
|
|
|
});
|
|
|
|
|
|
if (r.canceled || !r.filePath) return { ok: false, canceled: true };
|
|
|
|
|
|
fs.writeFileSync(r.filePath, buffer);
|
|
|
|
|
|
return { ok: true, path: r.filePath };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return { ok: false, error: String((err && err.message) || err) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// —— 提示词提取(图片 → 反推生图提示词)——
|
|
|
|
|
|
ipcMain.handle("prompt-groups", () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return { ok: true, groups: promptAnalysis.listGroups() };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return { ok: false, error: String((err && err.message) || err) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("prompt-models", async (_e, { groupId }) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const models = await promptAnalysis.listModels(groupId);
|
|
|
|
|
|
return { ok: true, models };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return { ok: false, error: String((err && err.message) || err) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("prompt-analyze", async (_e, params) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await promptAnalysis.analyze(params || {});
|
|
|
|
|
|
return { ok: true, ...result };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
return { ok: false, error: String((err && err.message) || err) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-16 19:16:45 +08:00
|
|
|
|
// —— 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());
|
2026-08-16 12:45:32 +08:00
|
|
|
|
ipcMain.handle("voice-restart-codex", async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await voice.disconnect();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log("重启 Codex:断开注入失败:" + (e && e.message ? e.message : e));
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const stopped = await codexDesktop.killCodex();
|
|
|
|
|
|
if (!stopped || !stopped.exited) {
|
|
|
|
|
|
return { ok: false, error: "Codex 未完全退出,已停止重启以保护运行中会话" };
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: "退出 Codex 失败:" + (e && e.message ? e.message : e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const restarted = await voice.connect(null, {});
|
|
|
|
|
|
return { ok: true, phase: restarted.phase, lastError: restarted.lastError };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: "重新启动 Codex 失败:" + (e && e.message ? e.message : e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-07-16 19:16:45 +08:00
|
|
|
|
ipcMain.handle("voice-stt-info", () => voice.sttInfo());
|
|
|
|
|
|
ipcMain.handle("voice-probe-stt", async () => voice.probeStt());
|
2026-08-16 12:45:32 +08:00
|
|
|
|
voice.onSessionDelete(async (request) => {
|
|
|
|
|
|
const payload = typeof request === "string" ? { kind: "delete-session", threadId: request } : (request || {});
|
|
|
|
|
|
const requestId = String(payload.requestId || "");
|
|
|
|
|
|
if (payload.kind === "delete-session-clipboard-prepare") {
|
|
|
|
|
|
if (requestId) {
|
|
|
|
|
|
sessionDeleteClipboard.set(requestId, { text: clipboard.readText(), expires: Date.now() + 5000 });
|
|
|
|
|
|
setTimeout(() => sessionDeleteClipboard.delete(requestId), 6000);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let threadId = String(payload.threadId || "");
|
|
|
|
|
|
if (payload.kind === "delete-session-clipboard-read") {
|
|
|
|
|
|
const saved = sessionDeleteClipboard.get(requestId);
|
|
|
|
|
|
sessionDeleteClipboard.delete(requestId);
|
|
|
|
|
|
const copied = clipboard.readText();
|
|
|
|
|
|
if (saved && saved.expires > Date.now()) {
|
|
|
|
|
|
try { clipboard.writeText(saved.text); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
const hit = copied.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
|
|
|
|
|
if (!hit) throw new Error("未能从 Codex 原生菜单读取会话 ID,已停止删除");
|
|
|
|
|
|
threadId = hit[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (voiceSessionDeleteBusy) return;
|
|
|
|
|
|
voiceSessionDeleteBusy = true;
|
|
|
|
|
|
const item = sessions.findConversationById(threadId);
|
|
|
|
|
|
if (!item) { voiceSessionDeleteBusy = false; throw new Error("未找到对应的本地会话,已停止删除"); }
|
|
|
|
|
|
try {
|
|
|
|
|
|
await withStateWriteLock("永久删除会话", async () => {
|
|
|
|
|
|
const answer = await dialog.showMessageBox(mainWindow, {
|
|
|
|
|
|
type: "warning",
|
|
|
|
|
|
title: "彻底删除本地会话",
|
|
|
|
|
|
message: "永久删除“" + (item.title || "未命名会话") + "”?",
|
|
|
|
|
|
detail: "将退出 Codex,然后删除会话正文、归档副本、state SQLite 线程索引、标题索引和工具箱会话记忆。此操作不可恢复。",
|
|
|
|
|
|
buttons: ["取消", "彻底删除"],
|
|
|
|
|
|
defaultId: 0,
|
|
|
|
|
|
cancelId: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (answer.response !== 1) return;
|
2026-09-11 09:06:41 +08:00
|
|
|
|
await voice.disconnect();
|
2026-08-16 12:45:32 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const stopped = await codexDesktop.killCodex();
|
|
|
|
|
|
if (!stopped || !stopped.exited) throw new Error("Codex 未完全退出,已停止删除以保护 SQLite");
|
|
|
|
|
|
const result = await sessions.deleteConversation(item.path);
|
|
|
|
|
|
try { await memory.deleteSession(result.id, result.cwd); } catch (e) { log("会话主体已删除,但工具箱记忆清理失败:" + (e.message || e)); }
|
|
|
|
|
|
log("已彻底删除会话 " + result.id + ":正文 " + result.filesRemoved + ",SQLite 命中 " + result.stateRowsRemoved + " 个");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const restarted = await voice.connect(null, {});
|
|
|
|
|
|
if (!restarted || restarted.phase !== "connected") log("删除后重新启动 Codex 未连接:" + ((restarted && restarted.lastError) || "未知原因"));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
log("删除后重新启动 Codex 失败:" + (e.message || e));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
voiceSessionDeleteBusy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-07-16 19:16:45 +08:00
|
|
|
|
|
2026-07-20 08:48:56 +08:00
|
|
|
|
// —— Token 用量监控(注入 Codex 页面,复用 CDP 连接)——
|
|
|
|
|
|
ipcMain.handle("token-status", async () => tokenWatch.tokenStatus());
|
|
|
|
|
|
ipcMain.handle("token-reset", async () => tokenWatch.tokenReset());
|
|
|
|
|
|
|
2026-07-15 18:01:56 +08:00
|
|
|
|
// —— 全局 / 系统代理 ——
|
|
|
|
|
|
ipcMain.handle("proxy-status", async () => proxy.status());
|
|
|
|
|
|
ipcMain.handle("proxy-get-config", async () => proxy.getConfig());
|
|
|
|
|
|
ipcMain.handle("proxy-set-config", async (_e, cfg) => proxy.setConfig(cfg));
|
|
|
|
|
|
ipcMain.handle("proxy-start", async (_e, { mode }) => proxy.start(mode));
|
|
|
|
|
|
ipcMain.handle("proxy-stop", async () => proxy.stop());
|
|
|
|
|
|
ipcMain.handle("proxy-test", async () => proxy.testProxy());
|
|
|
|
|
|
|
|
|
|
|
|
// 有效内容版本:优先已确认健康的热更版本,其次包内 hotContentVersion,兜底 app.getVersion()。
|
|
|
|
|
|
// 让设置页版本号随热更走(热更不替换 package.json,单纯 app.getVersion() 会永远停在打包版本)。
|
|
|
|
|
|
function effectiveAppVersion() {
|
2026-07-15 18:06:58 +08:00
|
|
|
|
if (APP_ROOT !== BUNDLE_ROOT) return path.basename(APP_ROOT);
|
2026-07-15 18:01:56 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const v = hotstate.effectiveVersion(global.__HOT_BASE__);
|
|
|
|
|
|
if (v) return v;
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
return bundledHotVersion();
|
|
|
|
|
|
}
|
|
|
|
|
|
ipcMain.handle("app-version", async () => effectiveAppVersion());
|
|
|
|
|
|
|
|
|
|
|
|
ipcMain.handle("check-update", async () => {
|
|
|
|
|
|
if (!app.isPackaged) return { dev: true };
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 所有平台的「检查更新」按钮统一走 gitea 优先的手动检测(国内可达);
|
|
|
|
|
|
// Windows 的后台全自动更新仍由 electron-updater 处理(能连 GitHub 的用户受益)。
|
|
|
|
|
|
const r = await checkUpdateManual();
|
|
|
|
|
|
return { ok: true, manual: true, hasNew: r.hasNew, version: r.hasNew ? r.latest : null, url: r.url, notes: r.notes, mandatory: r.mandatory };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (e && e.network) return { error: "网络无法连接更新服务器,请稍后再试或手动前往下载页。", network: true, url: GITEA_RELEASES_PAGE };
|
|
|
|
|
|
return { error: String(e && e.message ? e.message : e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
ipcMain.handle("open-external", async (_e, { url }) => {
|
|
|
|
|
|
if (url) shell.openExternal(url);
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("app-quit", async () => {
|
|
|
|
|
|
quitApp();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// —— 热更新 IPC ——
|
|
|
|
|
|
// 随包内置的内容版本(BUNDLE 的 package.json.hotContentVersion,缺省用 appVersion)
|
|
|
|
|
|
function bundledHotVersion() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return require(path.join(BUNDLE_ROOT, "package.json")).hotContentVersion || app.getVersion();
|
|
|
|
|
|
} catch { return app.getVersion(); }
|
|
|
|
|
|
}
|
|
|
|
|
|
let _lastManifest = null;
|
|
|
|
|
|
ipcMain.handle("hot-check", async () => {
|
|
|
|
|
|
if (!app.isPackaged) return { hasHot: false, dev: true };
|
|
|
|
|
|
try {
|
|
|
|
|
|
const m = await hotupdate.fetchManifest();
|
|
|
|
|
|
_lastManifest = m;
|
|
|
|
|
|
if (!m) return { hasHot: false };
|
|
|
|
|
|
return hotupdate.evaluate(m, app.getVersion(), bundledHotVersion(), global.__HOT_BASE__);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { hasHot: false, error: String(e && e.message ? e.message : e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("hot-apply", async () => {
|
|
|
|
|
|
if (!_lastManifest) _lastManifest = await hotupdate.fetchManifest();
|
|
|
|
|
|
if (!_lastManifest) throw new Error("拉取热更清单失败");
|
|
|
|
|
|
return hotupdate.applyManifest(_lastManifest, global.__HOT_BASE__, (p) => {
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("hot-progress", p);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("hot-confirm", async () => {
|
|
|
|
|
|
if (APP_ROOT !== BUNDLE_ROOT) {
|
|
|
|
|
|
try { hotstate.confirmHealthy(global.__HOT_BASE__, path.basename(APP_ROOT)); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
});
|
|
|
|
|
|
ipcMain.handle("hot-restart", async () => {
|
|
|
|
|
|
app.relaunch();
|
|
|
|
|
|
quitApp(); // 走清理流程(sing-box/router)后退出,relaunch 生效
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// —— 检测 / 强制清除本机 Codex ——
|
|
|
|
|
|
ipcMain.handle("codex-detect", async () => {
|
|
|
|
|
|
return purge.detectInstalls();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
ipcMain.handle("codex-purge", async (_e, { paths }) => {
|
|
|
|
|
|
const targets = Array.isArray(paths) ? paths : [];
|
|
|
|
|
|
if (!targets.length) return { canceled: false, results: [] };
|
|
|
|
|
|
|
|
|
|
|
|
// 二次确认:原生对话框,避免误删
|
|
|
|
|
|
const r = await dialog.showMessageBox(mainWindow, {
|
|
|
|
|
|
type: "warning",
|
|
|
|
|
|
buttons: ["取消", "彻底清除"],
|
|
|
|
|
|
defaultId: 0,
|
|
|
|
|
|
cancelId: 0,
|
|
|
|
|
|
title: "强制清除 Codex",
|
|
|
|
|
|
message: "确定要彻底清除本机 Codex 吗?",
|
|
|
|
|
|
detail:
|
|
|
|
|
|
"将删除以下 " +
|
|
|
|
|
|
targets.length +
|
|
|
|
|
|
" 个位置(含配置、登录凭证、会话、插件与可执行文件),此操作不可撤销:\n\n" +
|
|
|
|
|
|
targets.join("\n") +
|
|
|
|
|
|
"\n\n清除后需要重新安装并登录 Codex。",
|
|
|
|
|
|
noLink: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (r.response !== 1) return { canceled: true, results: [] };
|
|
|
|
|
|
|
|
|
|
|
|
log("开始强制清除本机 Codex…");
|
|
|
|
|
|
const results = await purge.purge(targets);
|
|
|
|
|
|
for (const it of results) {
|
|
|
|
|
|
if (it.ok) log(` 已删除:${it.path}`);
|
|
|
|
|
|
else log(` ⚠️ 删除失败:${it.path} —— ${it.error}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const okCount = results.filter((x) => x.ok).length;
|
|
|
|
|
|
log(`清除完成:成功 ${okCount} / ${results.length}`);
|
|
|
|
|
|
return { canceled: false, results };
|
|
|
|
|
|
});
|