260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
|
|
// claudedesk.js —— 把中转分组接入 Claude 桌面 App(图形客户端),免登录直用中转。
|
|||
|
|
// 关键:走 App 自己的「已保存配置库」(configLibrary),不是托管偏好(defaults/.mobileconfig)。
|
|||
|
|
// 托管偏好那条路是企业 MDM 通道,会强制 SSO 登录,个人账号卡登录页 —— 早期踩过坑,已弃用。
|
|||
|
|
// 做法对照 cc-switch 源码 src-tauri/src/claude_desktop_config.rs(farion1231/cc-switch)核实。
|
|||
|
|
// 详见 memory/claude-desktop-3p-config.md。
|
|||
|
|
"use strict";
|
|||
|
|
const fs = require("fs");
|
|||
|
|
const os = require("os");
|
|||
|
|
const path = require("path");
|
|||
|
|
const router = require("./router");
|
|||
|
|
|
|||
|
|
const PLATFORM = process.platform; // 'darwin' | 'win32' | ...
|
|||
|
|
const CONFIG_FILE = "claude_desktop_config.json";
|
|||
|
|
const CONFIG_LIBRARY_DIR = "configLibrary";
|
|||
|
|
const PROFILE_ID = "00000000-0000-4000-8000-000000157210";
|
|||
|
|
const PROFILE_NAME = "TokenDun";
|
|||
|
|
// 备份根:复用现有 ~/.codex/backups_state 约定
|
|||
|
|
const BACKUP_DIR = path.join(os.homedir(), ".codex", "backups_state", "claudedesk", "official");
|
|||
|
|
|
|||
|
|
// —— 平台路径 ——
|
|||
|
|
// 返回 { normalConfig, threepConfig, libDir, profilePath, metaPath } 或 null(不支持)
|
|||
|
|
function platformPaths() {
|
|||
|
|
if (PLATFORM === "darwin") {
|
|||
|
|
const appSupport = path.join(os.homedir(), "Library", "Application Support");
|
|||
|
|
return dirsToPaths(path.join(appSupport, "Claude"), path.join(appSupport, "Claude-3p"));
|
|||
|
|
}
|
|||
|
|
if (PLATFORM === "win32") {
|
|||
|
|
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|||
|
|
return dirsToPaths(pickWinDir(localAppData, "Claude"), pickWinDir(localAppData, "Claude-3p"));
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
// Windows 下 App 目录名可能带后缀,先精确后模糊匹配
|
|||
|
|
function pickWinDir(base, exact) {
|
|||
|
|
const p = path.join(base, exact);
|
|||
|
|
if (fs.existsSync(p)) return p;
|
|||
|
|
try {
|
|||
|
|
const hit = fs.readdirSync(base).find((n) => {
|
|||
|
|
const full = path.join(base, n);
|
|||
|
|
const isDir = (() => { try { return fs.statSync(full).isDirectory(); } catch { return false; } })();
|
|||
|
|
if (!isDir) return false;
|
|||
|
|
return exact === "Claude-3p" ? /claude.*3p/i.test(n) : (/claude/i.test(n) && !/3p/i.test(n));
|
|||
|
|
});
|
|||
|
|
if (hit) return path.join(base, hit);
|
|||
|
|
} catch {}
|
|||
|
|
return p;
|
|||
|
|
}
|
|||
|
|
function dirsToPaths(normalDir, threepDir) {
|
|||
|
|
const libDir = path.join(threepDir, CONFIG_LIBRARY_DIR);
|
|||
|
|
return {
|
|||
|
|
normalConfig: path.join(normalDir, CONFIG_FILE),
|
|||
|
|
threepConfig: path.join(threepDir, CONFIG_FILE),
|
|||
|
|
libDir,
|
|||
|
|
profilePath: path.join(libDir, `${PROFILE_ID}.json`),
|
|||
|
|
metaPath: path.join(libDir, "_meta.json"),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// —— JSON 读写小工具 ——
|
|||
|
|
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return null; } };
|
|||
|
|
const readJsonOrEmpty = (p) => { const v = readJson(p); return v && typeof v === "object" && !Array.isArray(v) ? v : {}; };
|
|||
|
|
function writeJson(p, obj) {
|
|||
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|||
|
|
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
|
|||
|
|
}
|
|||
|
|
const isSupported = () => PLATFORM === "darwin" || PLATFORM === "win32";
|
|||
|
|
|
|||
|
|
// —— gateway profile 本体(写进 configLibrary/<id>.json)——
|
|||
|
|
function buildGatewayProfile(group) {
|
|||
|
|
const oneM = new Set(Array.isArray(group.oneMillionModels) ? group.oneMillionModels : []);
|
|||
|
|
const profile = {
|
|||
|
|
coworkEgressAllowedHosts: ["*"],
|
|||
|
|
disableDeploymentModeChooser: true,
|
|||
|
|
inferenceGatewayApiKey: group.key || "",
|
|||
|
|
inferenceGatewayAuthScheme: "bearer",
|
|||
|
|
inferenceGatewayBaseUrl: String(group.baseUrl || "").replace(/\/+$/, ""),
|
|||
|
|
inferenceProvider: "gateway",
|
|||
|
|
};
|
|||
|
|
if (group.model) {
|
|||
|
|
const item = { id: group.model };
|
|||
|
|
if (oneM.has(group.model)) item.supports1m = true; // 1M 长上下文标记(不是 beta 头)
|
|||
|
|
profile.inferenceModels = [item];
|
|||
|
|
}
|
|||
|
|
return profile;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// —— deploymentMode 写入(就地改,不动其它字段,尤其保留官方账号绑定 preferences)——
|
|||
|
|
function writeDeploymentMode(p, mode) {
|
|||
|
|
const v = readJsonOrEmpty(p);
|
|||
|
|
v.deploymentMode = mode;
|
|||
|
|
writeJson(p, v);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// —— _meta.json:登记/清除本 profile ——
|
|||
|
|
function writeMeta(metaPath, applied) {
|
|||
|
|
const v = readJsonOrEmpty(metaPath);
|
|||
|
|
let entries = Array.isArray(v.entries) ? v.entries : [];
|
|||
|
|
entries = entries.filter((e) => !(e && e.id === PROFILE_ID)); // 去重
|
|||
|
|
if (applied) {
|
|||
|
|
entries.push({ id: PROFILE_ID, name: PROFILE_NAME });
|
|||
|
|
v.appliedId = PROFILE_ID;
|
|||
|
|
} else if (v.appliedId === PROFILE_ID) {
|
|||
|
|
// 取消:若当前 applied 指向本 profile,切到剩余任一或清空
|
|||
|
|
const next = entries.find((e) => e && e.id);
|
|||
|
|
if (next) v.appliedId = next.id; else delete v.appliedId;
|
|||
|
|
}
|
|||
|
|
v.entries = entries;
|
|||
|
|
writeJson(metaPath, v);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 备份 / 还原(防「回不去官方登录」)—— 幂等 write-once 快照那 4 个文件
|
|||
|
|
// ============================================================================
|
|||
|
|
const SNAP_FILES = (p) => [
|
|||
|
|
["normalConfig", p.normalConfig],
|
|||
|
|
["threepConfig", p.threepConfig],
|
|||
|
|
["profile", p.profilePath],
|
|||
|
|
["meta", p.metaPath],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function snapshotOfficial() {
|
|||
|
|
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
|||
|
|
const marker = path.join(BACKUP_DIR, "done.json");
|
|||
|
|
if (fs.existsSync(marker)) return { firstTime: false, dir: BACKUP_DIR };
|
|||
|
|
const p = platformPaths();
|
|||
|
|
const meta = { ts: Date.now(), platform: PLATFORM, files: {} };
|
|||
|
|
if (p) {
|
|||
|
|
for (const [key, src] of SNAP_FILES(p)) {
|
|||
|
|
const dst = path.join(BACKUP_DIR, key + ".json");
|
|||
|
|
if (fs.existsSync(src)) {
|
|||
|
|
try { fs.copyFileSync(src, dst); meta.files[key] = { existed: true }; try { fs.chmodSync(dst, 0o600); } catch {} }
|
|||
|
|
catch { meta.files[key] = { existed: true, copyFailed: true }; }
|
|||
|
|
} else {
|
|||
|
|
meta.files[key] = { existed: false }; // 记录原本不存在 → 还原时删除
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
fs.writeFileSync(marker, JSON.stringify(meta, null, 2));
|
|||
|
|
return { firstTime: true, dir: BACKUP_DIR };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 还原官方:把中转配置抹掉,deploymentMode 回 1p,App 恢复官方账号登录。
|
|||
|
|
// 优先用首份快照精确复原;无快照则按规则清理(删 profile、meta 清 entry、config 回 1p)。
|
|||
|
|
function restoreOfficial() {
|
|||
|
|
if (!isSupported()) return { ok: false, error: "当前系统不支持(仅 macOS / Windows)" };
|
|||
|
|
const p = platformPaths();
|
|||
|
|
if (!p) return { ok: false, error: "无法定位 Claude 桌面 App 配置目录" };
|
|||
|
|
const marker = path.join(BACKUP_DIR, "done.json");
|
|||
|
|
const meta = readJson(marker);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (meta && meta.files) {
|
|||
|
|
// 精确复原:有备份写回,标记「原本不存在」的删掉
|
|||
|
|
for (const [key, src] of SNAP_FILES(p)) {
|
|||
|
|
const info = meta.files[key] || {};
|
|||
|
|
const bak = path.join(BACKUP_DIR, key + ".json");
|
|||
|
|
if (info.existed && fs.existsSync(bak)) {
|
|||
|
|
fs.mkdirSync(path.dirname(src), { recursive: true });
|
|||
|
|
fs.copyFileSync(bak, src);
|
|||
|
|
} else if (!info.existed && fs.existsSync(src)) {
|
|||
|
|
try { fs.unlinkSync(src); } catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 无备份:按规则清理回官方
|
|||
|
|
writeDeploymentMode(p.normalConfig, "1p");
|
|||
|
|
writeDeploymentMode(p.threepConfig, "1p");
|
|||
|
|
if (fs.existsSync(p.profilePath)) { try { fs.unlinkSync(p.profilePath); } catch {} }
|
|||
|
|
writeMeta(p.metaPath, null);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
return { ok: false, error: "还原失败:" + (e.message || e) };
|
|||
|
|
}
|
|||
|
|
return { ok: true, message: "已还原 Claude 官方配置,请重启 Claude 桌面 App(会回到账号登录)" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================================
|
|||
|
|
// 一键接入(写 configLibrary,免登录)
|
|||
|
|
// ============================================================================
|
|||
|
|
function applyGroup(groupId) {
|
|||
|
|
if (!isSupported()) return { ok: false, error: "当前系统暂不支持(仅 macOS / Windows)" };
|
|||
|
|
const g = router.groupById(groupId);
|
|||
|
|
if (!g) return { ok: false, error: "未找到该分组" };
|
|||
|
|
if (g.isDefault) return { ok: false, error: "默认直连分组不能接入" };
|
|||
|
|
if (!g.baseUrl || !g.key) return { ok: false, error: "该分组缺少 API 地址或密钥" };
|
|||
|
|
if ((g.mode || "responses") !== "claude") {
|
|||
|
|
return { ok: false, error: "Claude 桌面 App 只接受 Anthropic 协议(/v1/messages)分组,请把该分组的接口类型改成 Claude Code" };
|
|||
|
|
}
|
|||
|
|
const p = platformPaths();
|
|||
|
|
if (!p) return { ok: false, error: "无法定位 Claude 桌面 App 配置目录(未安装?)" };
|
|||
|
|
|
|||
|
|
// 写前强制备份官方现状(幂等 write-once)
|
|||
|
|
let backup;
|
|||
|
|
try { backup = snapshotOfficial(); } catch (e) { return { ok: false, error: "备份官方配置失败:" + (e.message || e) }; }
|
|||
|
|
|
|||
|
|
// 失败回滚:先记住本次要写的 4 个文件的当前内容
|
|||
|
|
const rollback = SNAP_FILES(p).map(([k, f]) => ({ f, content: fs.existsSync(f) ? fs.readFileSync(f) : null }));
|
|||
|
|
try {
|
|||
|
|
const profile = buildGatewayProfile(g);
|
|||
|
|
writeDeploymentMode(p.normalConfig, "3p");
|
|||
|
|
writeDeploymentMode(p.threepConfig, "3p");
|
|||
|
|
writeJson(p.profilePath, profile);
|
|||
|
|
writeMeta(p.metaPath, true);
|
|||
|
|
} catch (e) {
|
|||
|
|
// 回滚
|
|||
|
|
for (const r of rollback) {
|
|||
|
|
try { if (r.content === null) { if (fs.existsSync(r.f)) fs.unlinkSync(r.f); } else fs.writeFileSync(r.f, r.content); } catch {}
|
|||
|
|
}
|
|||
|
|
return { ok: false, error: "写入 Claude 桌面配置失败:" + (e.message || e) };
|
|||
|
|
}
|
|||
|
|
const has1m = Array.isArray(g.oneMillionModels) && g.model && g.oneMillionModels.includes(g.model);
|
|||
|
|
return {
|
|||
|
|
ok: true,
|
|||
|
|
firstTimeBackup: backup.firstTime,
|
|||
|
|
has1m: !!has1m,
|
|||
|
|
message: "已接入 Claude 桌面 App,请重启 App 生效(无需登录,直接用中转)。官方配置已备份,可随时一键还原。",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 导出 profile JSON(供高级用户手动放置 / 备份)
|
|||
|
|
function exportConfig(groupId, format) {
|
|||
|
|
const g = router.groupById(groupId);
|
|||
|
|
if (!g) return { ok: false, error: "未找到该分组" };
|
|||
|
|
if (!g.baseUrl || !g.key) return { ok: false, error: "该分组缺少 API 地址或密钥" };
|
|||
|
|
const profile = buildGatewayProfile(g);
|
|||
|
|
const content = JSON.stringify(profile, null, 2) + "\n";
|
|||
|
|
return { ok: true, content, filename: `${PROFILE_ID}.json`, ext: "json" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 复制片段(configLibrary profile 内容)
|
|||
|
|
function snippet(groupId) {
|
|||
|
|
const r = exportConfig(groupId, "json");
|
|||
|
|
return r.ok ? { ok: true, text: r.content } : r;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function status() {
|
|||
|
|
const marker = path.join(BACKUP_DIR, "done.json");
|
|||
|
|
const meta = readJson(marker);
|
|||
|
|
const p = platformPaths();
|
|||
|
|
let applied = false;
|
|||
|
|
if (p) {
|
|||
|
|
const cfg = readJson(p.threepConfig) || readJson(p.normalConfig);
|
|||
|
|
const m = readJson(p.metaPath);
|
|||
|
|
applied = !!(cfg && cfg.deploymentMode === "3p" && m && m.appliedId === PROFILE_ID);
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
supported: isSupported(),
|
|||
|
|
platform: PLATFORM,
|
|||
|
|
hasBackup: !!meta,
|
|||
|
|
backupTs: meta ? meta.ts : 0,
|
|||
|
|
currentApplied: applied,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
status, applyGroup, restoreOfficial, exportConfig, snippet,
|
|||
|
|
// 供测试
|
|||
|
|
buildGatewayProfile, platformPaths, snapshotOfficial, PROFILE_ID,
|
|||
|
|
};
|