feat: 路由不再开机自动接管 + 保存分组即写直连配置 + 官方登录完整备份 (v1.5.16)
- 启动不再自动 router.start():只想用其它功能的用户不再被接管 - 新增 applyDirectToCodex():保存 responses 中转分组即把直连配置写进 config.toml+auth.json(非本地路由),重启 Codex 即用;本地路由降级为 '启用本地路由(高级)'显式选项 - chat 协议转换分组(DeepSeek/GLM/Kimi)保存时只提示需启用路由,不自动起 - 新增 snapshotOfficialLogin():首次接管前完整快照官方 auth.json(含OAuth tokens)+config.toml(router.json加密存+落盘副本);restoreDirect 优先用它 100%还原官方登录态,并清理 td-direct 残段 - setActive 按 isRunning 分流:路由在跑走本地路由秒切,否则写直连需重启 - 隔离测试:登录态21/21 + Mac custom回归9/9 全绿 - 版本 1.5.15 → 1.5.16 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+169
-12
@@ -12,7 +12,8 @@ const translate = require("./translate");
|
||||
|
||||
const CODEX_HOME = path.join(os.homedir(), ".codex");
|
||||
const CLAUDE_HOME = path.join(os.homedir(), ".claude");
|
||||
const PROVIDER_ID = "local-router"; // config.toml 里的 provider 名
|
||||
const PROVIDER_ID = "local-router"; // config.toml 里的 provider 名(走本地路由进程时用)
|
||||
const DIRECT_PROVIDER_ID = "td-direct"; // config.toml 里的 provider 名(直连中转、不经路由时用)
|
||||
|
||||
function cfgPath() {
|
||||
return path.join(app.getPath("userData"), "router.json");
|
||||
@@ -46,7 +47,14 @@ function load() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(cfgPath(), "utf-8"));
|
||||
raw.routerKey = raw.routerKey ? dec(raw.routerKey) : defaultConfig().routerKey;
|
||||
raw.groups = (raw.groups || []).map((g) => ({ ...g, key: dec(g.key) }));
|
||||
raw.groups = (raw.groups || []).map((g) => {
|
||||
const out = { ...g, key: dec(g.key) };
|
||||
// officialBackup.authJson 含 OAuth tokens,落盘时加密,读时解密
|
||||
if (out.officialBackup && out.officialBackup.authJson) {
|
||||
out.officialBackup = { ...out.officialBackup, authJson: dec(out.officialBackup.authJson) };
|
||||
}
|
||||
return out;
|
||||
});
|
||||
config = { ...defaultConfig(), ...raw };
|
||||
} catch {
|
||||
config = defaultConfig();
|
||||
@@ -58,7 +66,13 @@ function save() {
|
||||
const out = {
|
||||
...config,
|
||||
routerKey: enc(config.routerKey),
|
||||
groups: config.groups.map((g) => ({ ...g, key: enc(g.key) })),
|
||||
groups: config.groups.map((g) => {
|
||||
const o = { ...g, key: enc(g.key) };
|
||||
if (o.officialBackup && o.officialBackup.authJson) {
|
||||
o.officialBackup = { ...o.officialBackup, authJson: enc(o.officialBackup.authJson) };
|
||||
}
|
||||
return o;
|
||||
}),
|
||||
};
|
||||
const p = cfgPath();
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
@@ -139,6 +153,36 @@ function backupConfig(text) {
|
||||
fs.writeFileSync(path.join(dir, "config.toml." + Date.now() + ".bak"), text || "");
|
||||
} catch {}
|
||||
}
|
||||
// 首次接管 Codex 配置前,把官方登录信息(含 OAuth tokens 的 auth.json + 当前 config.toml)
|
||||
// 完整快照进默认分组 def.officialBackup,并落一份磁盘副本。幂等:只在第一次真正执行。
|
||||
// 保证任何时候「切回默认」都能 100% 还原回官方登录态。
|
||||
function snapshotOfficialLogin(def) {
|
||||
if (!def) return;
|
||||
if (def.officialBackup && def.officialBackup.done) return; // 幂等守卫
|
||||
let configToml = "";
|
||||
let authJson = "";
|
||||
try { configToml = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8"); } catch {}
|
||||
try { authJson = fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8"); } catch {}
|
||||
// 磁盘副本(灾备):仅当不存在时写,避免二次接管覆盖原始官方备份
|
||||
try {
|
||||
const dir = path.join(CODEX_HOME, "backups_state", "router", "official-login");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const cfgOrig = path.join(dir, "config.toml.orig");
|
||||
const authOrig = path.join(dir, "auth.json.orig");
|
||||
if (configToml && !fs.existsSync(cfgOrig)) fs.writeFileSync(cfgOrig, configToml, "utf-8");
|
||||
if (authJson && !fs.existsSync(authOrig)) {
|
||||
fs.writeFileSync(authOrig, authJson, "utf-8");
|
||||
try { fs.chmodSync(authOrig, 0o600); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
def.officialBackup = {
|
||||
done: true,
|
||||
ts: Date.now(),
|
||||
configToml,
|
||||
authJson,
|
||||
hadTokens: isLoggedIn(),
|
||||
};
|
||||
}
|
||||
// 首次使用时,把当前(原始)Codex 配置快照为不可删除的「默认」分组。
|
||||
// 铁律:默认分组必须代表「我的账号 · 直连」的登录态,绝不能是接入中转的状态,
|
||||
// 否则「切回默认」会把 Codex 重新指向中转,用户就再也复原不回自己账号了。
|
||||
@@ -147,6 +191,7 @@ function ensureDefault() {
|
||||
const loggedIn = isLoggedIn();
|
||||
const existing = config.groups.find((g) => g.isDefault);
|
||||
if (existing) {
|
||||
snapshotOfficialLogin(existing); // 幂等:确保已装用户也补上官方登录完整备份
|
||||
// 自愈:已用自己账号登录,但历史版本把默认分组错误地快照成了中转 provider →
|
||||
// 纠正为「纯登录态」,保证「切回默认」永远能干净复原自己账号。
|
||||
if (loggedIn && (existing.modelProvider || existing.providerBlock || existing.authKey)) {
|
||||
@@ -169,6 +214,7 @@ function ensureDefault() {
|
||||
authKey: "",
|
||||
origModel: currentRootModel(), // 记住原始根级 model(新版桌面登录态通常无 → ""),复原时精确还原
|
||||
});
|
||||
snapshotOfficialLogin(config.groups[0]);
|
||||
if (!config.activeId) config.activeId = "default";
|
||||
save();
|
||||
return;
|
||||
@@ -186,6 +232,7 @@ function ensureDefault() {
|
||||
authKey: readAuthKey(),
|
||||
origModel: currentRootModel(),
|
||||
});
|
||||
snapshotOfficialLogin(config.groups[0]);
|
||||
if (!config.activeId) config.activeId = "default";
|
||||
save();
|
||||
}
|
||||
@@ -246,8 +293,9 @@ function restoreDirect() {
|
||||
const cfgFile = path.join(CODEX_HOME, "config.toml");
|
||||
let text = readConfigToml();
|
||||
backupConfig(text);
|
||||
// 先移除本地路由段(无论哪种复原都要清掉)
|
||||
// 先移除本地路由段 + 直连中转段(无论哪种复原都要清掉,避免残留污染)
|
||||
text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
|
||||
text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`);
|
||||
if (def.modelProvider) {
|
||||
// 有原始直连 provider(纯 API key 用户,如 Mac 的 custom):设根键 + 追加原始段,避免重复键。
|
||||
// 此分支完全保持旧行为(不动根级 model 行),确保已在用的 Mac 环境零回归。
|
||||
@@ -267,6 +315,14 @@ function restoreDirect() {
|
||||
text = text.replace(/\n{3,}/g, "\n\n");
|
||||
fs.writeFileSync(cfgFile, text, "utf-8");
|
||||
const af = path.join(CODEX_HOME, "auth.json");
|
||||
// 优先用官方登录完整备份整体还原(含 OAuth tokens)→ 100% 复原官方登录态
|
||||
if (def.officialBackup && def.officialBackup.done && def.officialBackup.hadTokens && def.officialBackup.authJson) {
|
||||
try {
|
||||
fs.writeFileSync(af, def.officialBackup.authJson, "utf-8");
|
||||
fs.chmodSync(af, 0o600);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const a = JSON.parse(fs.readFileSync(af, "utf-8"));
|
||||
// 登录态:清空 OPENAI_API_KEY、保留 tokens → Codex 用 OAuth 直连;纯 key 用户则还原原始 key
|
||||
@@ -727,7 +783,32 @@ function upsertGroup(g) {
|
||||
}
|
||||
save();
|
||||
try { syncGenImage(); } catch {}
|
||||
return status();
|
||||
// 保存中转分组后:把它的直连中转配置写进 Codex config.toml(不经路由、不起进程),
|
||||
// 并设为激活分组。用户重启 Codex 即用当前分组。只有显式「启用本地路由」才走路由进程。
|
||||
const st = status();
|
||||
if (cur && !cur.isDefault) {
|
||||
// 路由进程正在跑(用户处于「启用路由」高级模式)→ 保持走本地路由,不改成直连
|
||||
if (isRunning()) {
|
||||
st._directSkipped = true;
|
||||
st._directReason = "本地路由运行中,保存的分组走路由生效(切分组无需重启)";
|
||||
} else if (!cur.baseUrl || !cur.key) {
|
||||
st._directSkipped = true;
|
||||
st._directReason = "该分组缺少 API 地址或密钥,未写入 Codex 配置";
|
||||
} else if (cur.mode === "chat") {
|
||||
st._directSkipped = true;
|
||||
st._directNeedRouter = true;
|
||||
st._directReason = "该分组为 Chat 协议转换模式,需启用本地路由才能接入 Codex";
|
||||
} else {
|
||||
try {
|
||||
const r = applyDirectToCodex(cur.id);
|
||||
if (r.ok) { st._directApplied = true; st.activeId = config.activeId; }
|
||||
else { st._directSkipped = true; st._directReason = r.error || "写入失败"; }
|
||||
} catch (e) {
|
||||
st._directError = String((e && e.message) || e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return st;
|
||||
}
|
||||
function deleteGroup(id) {
|
||||
load();
|
||||
@@ -744,24 +825,34 @@ function setActive(id) {
|
||||
const target = config.groups.find((g) => g.id === id);
|
||||
if (!target) return status();
|
||||
const before = currentModelProvider();
|
||||
const wasTakenOver = before === PROVIDER_ID || before === DIRECT_PROVIDER_ID;
|
||||
let needsRestart = false;
|
||||
let mode = "proxy";
|
||||
if (target.isDefault) {
|
||||
// 切回默认 = 还原原始配置,Codex 直连自己账号(需重启 Codex)
|
||||
restoreDirect();
|
||||
mode = "direct";
|
||||
needsRestart = before === PROVIDER_ID; // 之前在走路由 → 需重启
|
||||
} else {
|
||||
// 切到中转分组:确保 config.toml 指向本地路由
|
||||
needsRestart = wasTakenOver; // 之前在走路由或直连中转 → 需重启
|
||||
} else if (isRunning()) {
|
||||
// 路由进程在跑(高级模式):确保 config.toml 指向本地路由,秒切无需重启
|
||||
mode = "proxy";
|
||||
if (before !== PROVIDER_ID) {
|
||||
applyToCodex();
|
||||
needsRestart = true; // 从直连切到路由 → 需重启一次
|
||||
needsRestart = true; // 从直连/默认切到路由 → 需重启一次
|
||||
}
|
||||
// 若分组指定了模型:写模型目录窗口 + 设 config 模型,让 Codex 按真实窗口压缩
|
||||
if (target.model) {
|
||||
applyModelCatalog(target);
|
||||
setConfigModel(target.model);
|
||||
}
|
||||
} else {
|
||||
// 默认模式:直连中转,改 config.toml 指向该分组地址(必然需重启 Codex)
|
||||
mode = "direct";
|
||||
const r = applyDirectToCodex(id);
|
||||
// applyDirectToCodex 内部已 setActive + save;chat/缺 key 则不写配置
|
||||
needsRestart = !!r.ok && !r.restored;
|
||||
config.activeId = id;
|
||||
save();
|
||||
return { ...status(), _needsRestart: needsRestart, _mode: mode, _directSkipped: r.skipped, _directReason: r.error };
|
||||
}
|
||||
config.activeId = id;
|
||||
save();
|
||||
@@ -837,8 +928,73 @@ function applyToCodex() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// —— 自动接入 Claude Code —— 写 ~/.claude/settings.json 的 env:ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN
|
||||
// Claude Code 会把 settings.json 里 env 的键当环境变量注入每个会话,从而走本地路由。
|
||||
// —— 直连中转写配置(不经本地路由,不起进程)——
|
||||
// 把指定分组的中转 base_url/key 直接写进 config.toml + auth.json,Codex 直连中转站。
|
||||
// 用户保存分组即调用;重启 Codex 生效。chat 模式分组需协议转换、无法直连 → 拒绝。
|
||||
function applyDirectToCodex(groupId) {
|
||||
load();
|
||||
const g = config.groups.find((x) => x.id === (groupId || config.activeId));
|
||||
if (!g) return { ok: false, error: "分组不存在" };
|
||||
if (g.isDefault) {
|
||||
restoreDirect(); // 默认分组语义 = 回到官方登录直连
|
||||
config.activeId = g.id;
|
||||
save();
|
||||
return { ok: true, restored: true };
|
||||
}
|
||||
if (!g.baseUrl || !g.key) {
|
||||
return { ok: false, skipped: true, error: "该分组缺少 API 地址或密钥,未写入 Codex 配置" };
|
||||
}
|
||||
if (g.mode === "chat") {
|
||||
return { ok: false, skipped: true, needRouter: true, error: "该分组为 Chat 协议转换模式,直连 Codex 不支持,需启用本地路由" };
|
||||
}
|
||||
|
||||
// 接管前先确保官方登录已完整备份(幂等)
|
||||
const def = config.groups.find((x) => x.isDefault);
|
||||
if (def) snapshotOfficialLogin(def);
|
||||
|
||||
const cfgFile = path.join(CODEX_HOME, "config.toml");
|
||||
let text = "";
|
||||
try { text = fs.readFileSync(cfgFile, "utf-8"); } catch {}
|
||||
backupConfig(text);
|
||||
|
||||
// 清理其他接管痕迹:本地路由段、上次直连段、默认分组原始 provider 段
|
||||
text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
|
||||
text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`);
|
||||
if (def && def.modelProvider && def.modelProvider !== DIRECT_PROVIDER_ID) {
|
||||
text = removeTomlSection(text, `model_providers.${def.modelProvider}`);
|
||||
}
|
||||
// 替换/插入根键 → 指向直连 provider
|
||||
if (/^\s*model_provider\s*=/m.test(text)) {
|
||||
text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${DIRECT_PROVIDER_ID}"`);
|
||||
} else {
|
||||
text = `model_provider = "${DIRECT_PROVIDER_ID}"\n` + text;
|
||||
}
|
||||
// 追加 [model_providers.td-direct] 段:base_url = 分组真实中转地址(非 127.0.0.1)
|
||||
const base = g.baseUrl.replace(/\/+$/, "");
|
||||
const block = `[model_providers.${DIRECT_PROVIDER_ID}]\nname = "${(g.name || "中转直连").replace(/"/g, "")}"\nbase_url = "${base}"\nwire_api = "responses"\nrequires_openai_auth = true\n`;
|
||||
text = text.trimEnd() + "\n\n" + block;
|
||||
text = text.replace(/\n{3,}/g, "\n\n");
|
||||
fs.writeFileSync(cfgFile, text, "utf-8");
|
||||
|
||||
// auth.json 写分组真实 key(Codex 直连中转站时用它作 Bearer)
|
||||
const authFile = path.join(CODEX_HOME, "auth.json");
|
||||
let auth = {};
|
||||
try { auth = JSON.parse(fs.readFileSync(authFile, "utf-8")); } catch {}
|
||||
auth.OPENAI_API_KEY = g.key;
|
||||
try {
|
||||
fs.writeFileSync(authFile, JSON.stringify(auth, null, 2), "utf-8");
|
||||
fs.chmodSync(authFile, 0o600);
|
||||
} catch {}
|
||||
|
||||
// 分组指定模型 → 写模型目录窗口 + 设根级 model
|
||||
if (g.model) {
|
||||
applyModelCatalog(g);
|
||||
setConfigModel(g.model);
|
||||
}
|
||||
config.activeId = g.id;
|
||||
save();
|
||||
return { ok: true, direct: true, group: g.name };
|
||||
}
|
||||
function claudeSettingsPath() {
|
||||
return path.join(CLAUDE_HOME, "settings.json");
|
||||
}
|
||||
@@ -932,6 +1088,7 @@ module.exports = {
|
||||
setActive,
|
||||
setPort,
|
||||
applyToCodex,
|
||||
applyDirectToCodex,
|
||||
codexSnippet,
|
||||
applyToClaudeCode,
|
||||
restoreClaudeCode,
|
||||
|
||||
+11
-7
@@ -213,15 +213,13 @@ app.whenReady().then(async () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("memory-changed");
|
||||
},
|
||||
});
|
||||
// 有分组就自动启动本地路由
|
||||
// 启动时不再自动接管/启动本地路由:用户可能只想用其它功能。
|
||||
// 保存分组已直接写 Codex 直连配置;只有用户显式「启用本地路由」才 start() 路由进程。
|
||||
// 仅刷新一次托盘菜单以反映当前分组/状态(不 start)。
|
||||
try {
|
||||
const st = router.status();
|
||||
if (st.groups.length) {
|
||||
await router.start();
|
||||
rebuildTrayMenu();
|
||||
}
|
||||
rebuildTrayMenu();
|
||||
} catch (e) {
|
||||
log(`本地路由启动失败:${e.message || e}`);
|
||||
log(`刷新托盘失败:${e.message || e}`);
|
||||
}
|
||||
try {
|
||||
const r = migrateLegacySkills();
|
||||
@@ -1303,6 +1301,12 @@ ipcMain.handle("router-apply-codex", async () => {
|
||||
rebuildTrayMenu();
|
||||
return { ...r, status: router.status() };
|
||||
});
|
||||
// 把指定分组的直连中转配置写进 Codex(不经路由、不起进程)
|
||||
ipcMain.handle("router-apply-direct", async (_e, { id } = {}) => {
|
||||
const r = router.applyDirectToCodex(id);
|
||||
rebuildTrayMenu();
|
||||
return { ...r, status: router.status() };
|
||||
});
|
||||
ipcMain.handle("router-snippet", async () => router.codexSnippet());
|
||||
ipcMain.handle("router-apply-claude", async () => {
|
||||
const r = router.applyToClaudeCode();
|
||||
|
||||
@@ -71,6 +71,7 @@ contextBridge.exposeInMainWorld("api", {
|
||||
routerStart: () => ipcRenderer.invoke("router-start"),
|
||||
routerStop: () => ipcRenderer.invoke("router-stop"),
|
||||
routerApplyCodex: () => ipcRenderer.invoke("router-apply-codex"),
|
||||
routerApplyDirect: (id) => ipcRenderer.invoke("router-apply-direct", { id }),
|
||||
routerApplyClaude: () => ipcRenderer.invoke("router-apply-claude"),
|
||||
routerRestoreClaude: () => ipcRenderer.invoke("router-restore-claude"),
|
||||
routerSnippet: () => ipcRenderer.invoke("router-snippet"),
|
||||
|
||||
Reference in New Issue
Block a user