Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59644b29e6 | ||
|
|
5427ae04e4 | ||
|
|
a2aa5f8434 | ||
|
|
06010ff78e | ||
|
|
e77eab2116 | ||
|
|
ed9dd7bbc3 | ||
|
|
3d20245a80 |
26
README.md
26
README.md
@@ -6,6 +6,10 @@
|
||||
|
||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||
|
||||
> **📢 重要通知**:CC Switch 即将进行大规模重构,请暂缓提交新的 PR,感谢理解与配合!
|
||||
|
||||
> v3.5.0 :新增 **MCP 管理**、**配置导入/导出**、**端点速度测试**功能,完善国际化覆盖,新增 Longcat、kat-coder 预设,标准化发布文件命名规范。
|
||||
|
||||
> v3.4.0 :新增 i18next 国际化(还有部分未完成)、对新模型(qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp)的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等。
|
||||
|
||||
> v3.3.0 :VS Code Codex 插件一键配置/移除(默认自动同步)、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。(该 VS Code 写入功能已在 v3.4.x 停用)
|
||||
@@ -16,15 +20,25 @@
|
||||
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
|
||||
|
||||
## 功能特性(v3.4.0)
|
||||
## 功能特性(v3.5.0)
|
||||
|
||||
- **国际化与语言切换**:内置 i18next,默认显示中文,可在设置中快速切换到英文,界面文文案自动实时刷新。
|
||||
- **MCP (Model Context Protocol) 管理**:完整的 MCP 服务器配置管理系统
|
||||
- 支持 stdio 和 http 服务器类型,并提供命令校验
|
||||
- 内置常用 MCP 服务器模板(如 mcp-fetch 等)
|
||||
- 实时启用/禁用 MCP 服务器,原子文件写入防止配置损坏
|
||||
- **配置导入/导出**:备份和恢复你的供应商配置
|
||||
- 一键导出所有配置到 JSON 文件
|
||||
- 导入配置时自动验证并备份,自动轮换备份(保留最近 10 个)
|
||||
- 带有详细状态反馈的进度模态框
|
||||
- **端点速度测试**:测试 API 端点响应时间
|
||||
- 测量不同供应商端点的延迟,可视化连接质量指示器
|
||||
- 帮助用户选择最快的供应商
|
||||
- **国际化与语言切换**:完整的 i18next 国际化覆盖,默认显示中文,可在设置中快速切换到英文,界面文案自动实时刷新。
|
||||
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
|
||||
- **VS Code Codex 设置停用**:由于新版 Codex 插件无需修改 `settings.json`,应用不再写入 VS Code 设置,避免潜在冲突。
|
||||
- **供应商预设扩展**:新增 DeepSeek--V3.2-Exp、Qwen3-Max、GLM-4.6 等最新模型。
|
||||
- **供应商预设扩展**:新增 Longcat、kat-coder 等预设,更新 GLM 供应商配置至最新模型。
|
||||
- **系统托盘与窗口行为**:窗口关闭可最小化到托盘,macOS 支持托盘模式下隐藏/显示 Dock,托盘切换时同步 Claude/Codex/插件状态。
|
||||
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
|
||||
- **UI 与安装体验优化**:设置面板改为可滚动布局并加入保存图标,按钮宽度与状态一致性加强,Windows MSI 安装默认写入 per-user LocalAppData 并改进组件跟踪,Windows 便携版现在指向最新 release 页面,不再自动更为为安装版。
|
||||
- **标准化发布命名**:所有平台发布文件使用一致的版本标签命名(macOS: `.tar.gz` / `.zip`,Windows: `.msi` / `-Portable.zip`,Linux: `.AppImage` / `.deb`)。
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -211,7 +225,7 @@ cargo test
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
欢迎提交 Issue 反馈问题和建议!
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.5.0",
|
||||
"version": "3.5.1",
|
||||
"description": "Claude Code & Codex 供应商切换工具",
|
||||
"scripts": {
|
||||
"dev": "pnpm tauri dev",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 200 KiB |
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -563,7 +563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.5.0"
|
||||
version = "3.5.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.5.0"
|
||||
version = "3.5.1"
|
||||
description = "Claude Code & Codex 供应商配置管理工具"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -841,15 +841,59 @@ pub async fn upsert_mcp_server_in_config(
|
||||
app: Option<String>,
|
||||
id: String,
|
||||
spec: serde_json::Value,
|
||||
sync_other_side: Option<bool>,
|
||||
) -> Result<bool, String> {
|
||||
let mut cfg = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
|
||||
let changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec)?;
|
||||
let mut sync_targets: Vec<crate::app_config::AppType> = Vec::new();
|
||||
|
||||
let changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec.clone())?;
|
||||
|
||||
let should_sync_current = cfg
|
||||
.mcp_for(&app_ty)
|
||||
.servers
|
||||
.get(&id)
|
||||
.and_then(|entry| entry.get("enabled"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if should_sync_current {
|
||||
sync_targets.push(app_ty.clone());
|
||||
}
|
||||
|
||||
if sync_other_side.unwrap_or(false) {
|
||||
let other_app = match app_ty.clone() {
|
||||
crate::app_config::AppType::Claude => crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Codex => crate::app_config::AppType::Claude,
|
||||
};
|
||||
crate::mcp::upsert_in_config_for(&mut cfg, &other_app, &id, spec)?;
|
||||
|
||||
let should_sync_other = cfg
|
||||
.mcp_for(&other_app)
|
||||
.servers
|
||||
.get(&id)
|
||||
.and_then(|entry| entry.get("enabled"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if should_sync_other {
|
||||
sync_targets.push(other_app.clone());
|
||||
}
|
||||
}
|
||||
drop(cfg);
|
||||
state.save()?;
|
||||
|
||||
let cfg2 = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
for app_ty_to_sync in sync_targets {
|
||||
match app_ty_to_sync {
|
||||
crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?,
|
||||
crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?,
|
||||
};
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.5.0",
|
||||
"version": "3.5.1",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Save, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { X, Save, AlertCircle, ChevronDown, ChevronUp, AlertTriangle } from "lucide-react";
|
||||
import { McpServer, McpServerSpec } from "../../types";
|
||||
import {
|
||||
mcpPresets,
|
||||
@@ -24,7 +24,11 @@ interface McpFormModalProps {
|
||||
appType: AppType;
|
||||
editingId?: string;
|
||||
initialData?: McpServer;
|
||||
onSave: (id: string, server: McpServer) => Promise<void>;
|
||||
onSave: (
|
||||
id: string,
|
||||
server: McpServer,
|
||||
options?: { syncOtherSide?: boolean },
|
||||
) => Promise<void>;
|
||||
onClose: () => void;
|
||||
existingIds?: string[];
|
||||
onNotify?: (
|
||||
@@ -113,9 +117,65 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
const [idError, setIdError] = useState("");
|
||||
const [syncOtherSide, setSyncOtherSide] = useState(false);
|
||||
const [otherSideHasConflict, setOtherSideHasConflict] = useState(false);
|
||||
|
||||
// 判断是否使用 TOML 格式
|
||||
const useToml = appType === "codex";
|
||||
const syncTargetLabel =
|
||||
appType === "claude" ? t("apps.codex") : t("apps.claude");
|
||||
const otherAppType: AppType = appType === "claude" ? "codex" : "claude";
|
||||
const syncCheckboxId = useMemo(
|
||||
() => `sync-other-side-${appType}`,
|
||||
[appType],
|
||||
);
|
||||
|
||||
// 检测另一侧是否有同名 MCP
|
||||
useEffect(() => {
|
||||
const checkOtherSide = async () => {
|
||||
const currentId = formId.trim();
|
||||
if (!currentId) {
|
||||
setOtherSideHasConflict(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const otherConfig = await window.api.getMcpConfig(otherAppType);
|
||||
const hasConflict = Object.keys(otherConfig.servers || {}).includes(currentId);
|
||||
setOtherSideHasConflict(hasConflict);
|
||||
} catch (error) {
|
||||
console.error("检查另一侧 MCP 配置失败:", error);
|
||||
setOtherSideHasConflict(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOtherSide();
|
||||
}, [formId, otherAppType]);
|
||||
|
||||
const wizardInitialSpec = useMemo(() => {
|
||||
const fallback = initialData?.server;
|
||||
if (!formConfig.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (useToml) {
|
||||
try {
|
||||
return tomlToMcpServer(formConfig);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(formConfig);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as McpServerSpec;
|
||||
}
|
||||
return fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}, [formConfig, initialData, useToml]);
|
||||
|
||||
// 预设选择状态(仅新增模式显示;-1 表示自定义)
|
||||
const [selectedPreset, setSelectedPreset] = useState<number | null>(
|
||||
@@ -407,7 +467,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
}
|
||||
|
||||
// 显式等待父组件保存流程
|
||||
await onSave(trimmedId, entry);
|
||||
await onSave(trimmedId, entry, { syncOtherSide });
|
||||
} catch (error: any) {
|
||||
const detail = extractErrorMessage(error);
|
||||
const mapped = translateMcpBackendError(detail, t);
|
||||
@@ -633,25 +693,56 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-shrink-0 flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || (!isEditing && !!idError)}
|
||||
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving
|
||||
? t("common.saving")
|
||||
: isEditing
|
||||
? t("common.save")
|
||||
: t("common.add")}
|
||||
</button>
|
||||
<div className="flex-shrink-0 flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
{/* 双端同步选项 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={syncCheckboxId}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
checked={syncOtherSide}
|
||||
onChange={(event) => setSyncOtherSide(event.target.checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={syncCheckboxId}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
title={t("mcp.form.syncOtherSideHint", { target: syncTargetLabel })}
|
||||
>
|
||||
{t("mcp.form.syncOtherSide", { target: syncTargetLabel })}
|
||||
</label>
|
||||
</div>
|
||||
{syncOtherSide && otherSideHasConflict && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={14} />
|
||||
<span className="text-xs font-medium">
|
||||
{t("mcp.form.willOverwriteWarning", { target: syncTargetLabel })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || (!isEditing && !!idError)}
|
||||
className={`inline-flex items-center gap-2 ${buttonStyles.mcp}`}
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving
|
||||
? t("common.saving")
|
||||
: isEditing
|
||||
? t("common.save")
|
||||
: t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -661,6 +752,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||
onClose={() => setIsWizardOpen(false)}
|
||||
onApply={handleWizardApply}
|
||||
onNotify={onNotify}
|
||||
initialTitle={formId}
|
||||
initialServer={wizardInitialSpec}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -135,10 +135,16 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (id: string, server: McpServer) => {
|
||||
const handleSave = async (
|
||||
id: string,
|
||||
server: McpServer,
|
||||
options?: { syncOtherSide?: boolean },
|
||||
) => {
|
||||
try {
|
||||
const payload: McpServer = { ...server, id };
|
||||
await window.api.upsertMcpServerInConfig(appType, id, payload);
|
||||
await window.api.upsertMcpServerInConfig(appType, id, payload, {
|
||||
syncOtherSide: options?.syncOtherSide,
|
||||
});
|
||||
await reload();
|
||||
setIsFormOpen(false);
|
||||
setEditingId(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Save } from "lucide-react";
|
||||
import { McpServerSpec } from "../../types";
|
||||
@@ -13,6 +13,8 @@ interface McpWizardModalProps {
|
||||
type: "success" | "error",
|
||||
duration?: number,
|
||||
) => void;
|
||||
initialTitle?: string;
|
||||
initialServer?: McpServerSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +74,8 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onClose,
|
||||
onApply,
|
||||
onNotify,
|
||||
initialTitle,
|
||||
initialServer,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
|
||||
@@ -162,6 +166,55 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const title = initialTitle ?? "";
|
||||
setWizardTitle(title);
|
||||
|
||||
const resolvedType =
|
||||
initialServer?.type ??
|
||||
(initialServer?.url ? "http" : "stdio");
|
||||
|
||||
setWizardType(resolvedType);
|
||||
|
||||
if (resolvedType === "http") {
|
||||
setWizardUrl(initialServer?.url ?? "");
|
||||
const headersCandidate = initialServer?.headers;
|
||||
const headers =
|
||||
headersCandidate && typeof headersCandidate === "object"
|
||||
? headersCandidate
|
||||
: undefined;
|
||||
setWizardHeaders(
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
.map(([k, v]) => `${k}: ${v ?? ""}`)
|
||||
.join("\n")
|
||||
: "",
|
||||
);
|
||||
setWizardCommand("");
|
||||
setWizardArgs("");
|
||||
setWizardEnv("");
|
||||
return;
|
||||
}
|
||||
|
||||
setWizardCommand(initialServer?.command ?? "");
|
||||
const argsValue = initialServer?.args;
|
||||
setWizardArgs(Array.isArray(argsValue) ? argsValue.join("\n") : "");
|
||||
const envCandidate = initialServer?.env;
|
||||
const env =
|
||||
envCandidate && typeof envCandidate === "object" ? envCandidate : undefined;
|
||||
setWizardEnv(
|
||||
env
|
||||
? Object.entries(env)
|
||||
.map(([k, v]) => `${k}=${v ?? ""}`)
|
||||
.join("\n")
|
||||
: "",
|
||||
);
|
||||
setWizardUrl("");
|
||||
setWizardHeaders("");
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const preview = generatePreview();
|
||||
|
||||
@@ -298,7 +298,10 @@
|
||||
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
|
||||
"tomlConfig": "TOML Configuration",
|
||||
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
|
||||
"useWizard": "Config Wizard"
|
||||
"useWizard": "Config Wizard",
|
||||
"syncOtherSide": "Mirror to {{target}}",
|
||||
"syncOtherSideHint": "Apply the same settings to {{target}}; existing entries with the same id will be overwritten.",
|
||||
"willOverwriteWarning": "Will overwrite existing config in {{target}}"
|
||||
},
|
||||
"wizard": {
|
||||
"title": "MCP Configuration Wizard",
|
||||
|
||||
@@ -298,7 +298,10 @@
|
||||
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
|
||||
"tomlConfig": "TOML 配置",
|
||||
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
|
||||
"useWizard": "配置向导"
|
||||
"useWizard": "配置向导",
|
||||
"syncOtherSide": "同步到 {{target}}",
|
||||
"syncOtherSideHint": "勾选后会把当前配置同时写入 {{target}},若存在同名配置将被覆盖",
|
||||
"willOverwriteWarning": "将覆盖 {{target}} 中的同名配置"
|
||||
},
|
||||
"wizard": {
|
||||
"title": "MCP 配置向导",
|
||||
|
||||
@@ -354,13 +354,18 @@ export const tauriAPI = {
|
||||
app: AppType = "claude",
|
||||
id: string,
|
||||
spec: McpServer,
|
||||
options?: { syncOtherSide?: boolean },
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke<boolean>("upsert_mcp_server_in_config", {
|
||||
const payload = {
|
||||
app,
|
||||
id,
|
||||
spec,
|
||||
});
|
||||
...(options?.syncOtherSide !== undefined
|
||||
? { syncOtherSide: options.syncOtherSide }
|
||||
: {}),
|
||||
};
|
||||
return await invoke<boolean>("upsert_mcp_server_in_config", payload);
|
||||
} catch (error) {
|
||||
console.error("写入 MCP(config.json)失败:", error);
|
||||
throw error;
|
||||
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -84,6 +84,7 @@ declare global {
|
||||
app: AppType | undefined,
|
||||
id: string,
|
||||
spec: McpServer,
|
||||
options?: { syncOtherSide?: boolean },
|
||||
) => Promise<boolean>;
|
||||
deleteMcpServerInConfig: (
|
||||
app: AppType | undefined,
|
||||
|
||||
Reference in New Issue
Block a user