feat(tray): add Gemini support to system tray menu (#209)
Refactor tray menu system to support three applications (Claude/Codex/Gemini): - Introduce generic TrayAppSection structure and TRAY_SECTIONS array - Implement append_provider_section and handle_provider_tray_event helper functions - Enhance Gemini provider service with .env config read/write support - Implement Gemini LiveSnapshot for atomic operations and rollback - Update README documentation to reflect Gemini tray quick switching feature
This commit is contained in:
12
README.md
12
README.md
@@ -47,7 +47,7 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
|
||||
|
||||
**Core Capabilities**
|
||||
|
||||
- **Provider Management**: One-click switching between Claude Code & Codex API configurations
|
||||
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
|
||||
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
|
||||
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
||||
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
||||
@@ -115,8 +115,8 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
||||
2. **Switch Provider**:
|
||||
- Main UI: Select provider → Click "Enable"
|
||||
- System Tray: Click provider name directly (instant effect)
|
||||
3. **Takes Effect**: Restart terminal or Claude Code/Codex to apply changes
|
||||
4. **Back to Official**: Select "Official Login" preset, restart terminal, then use `/login` (Claude) or official login flow (Codex)
|
||||
3. **Takes Effect**: Restart your terminal or Claude Code / Codex / Gemini clients to apply changes
|
||||
4. **Back to Official**: Select the "Official Login" preset (Claude/Codex) or "Google Official" preset (Gemini), restart the corresponding client, then follow its login/OAuth flow
|
||||
|
||||
### MCP Management
|
||||
|
||||
@@ -139,6 +139,12 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
||||
- API key field: `OPENAI_API_KEY` in `auth.json`
|
||||
- MCP servers: `~/.codex/config.toml` → `[mcp.servers]`
|
||||
|
||||
**Gemini**
|
||||
|
||||
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth type for quick switching)
|
||||
- API key field: `GEMINI_API_KEY` inside `.env`
|
||||
- Tray quick switch: each provider switch rewrites `~/.gemini/.env` so the Gemini CLI picks up the new credentials immediately
|
||||
|
||||
**CC Switch Storage**
|
||||
|
||||
- Main config (SSOT): `~/.cc-switch/config.json`
|
||||
|
||||
12
README_ZH.md
12
README_ZH.md
@@ -47,7 +47,7 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
|
||||
|
||||
**核心功能**
|
||||
|
||||
- **供应商管理**:一键切换 Claude Code 与 Codex 的 API 配置
|
||||
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
||||
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
|
||||
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
||||
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
||||
@@ -115,8 +115,8 @@ brew upgrade --cask cc-switch
|
||||
2. **切换供应商**:
|
||||
- 主界面:选择供应商 → 点击"启用"
|
||||
- 系统托盘:直接点击供应商名称(立即生效)
|
||||
3. **生效方式**:重启终端或 Claude Code/Codex 以应用更改
|
||||
4. **恢复官方登录**:选择"官方登录"预设,重启终端后使用 `/login`(Claude)或官方登录流程(Codex)
|
||||
3. **生效方式**:重启终端或 Claude Code / Codex / Gemini 客户端以应用更改
|
||||
4. **恢复官方登录**:选择"官方登录"预设(Claude/Codex)或"Google 官方"预设(Gemini),重启对应客户端后按照其登录/OAuth 流程操作
|
||||
|
||||
### MCP 管理
|
||||
|
||||
@@ -139,6 +139,12 @@ brew upgrade --cask cc-switch
|
||||
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
||||
- MCP 服务器:`~/.codex/config.toml` → `[mcp.servers]`
|
||||
|
||||
**Gemini**
|
||||
|
||||
- Live 配置:`~/.gemini/.env`(API Key)+ `~/.gemini/settings.json`(保存认证模式,支持托盘快速切换)
|
||||
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY`
|
||||
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,Gemini CLI 无需额外操作即可使用新配置
|
||||
|
||||
**CC Switch 存储**
|
||||
|
||||
- 主配置(SSOT):`~/.cc-switch/config.json`
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct McpRoot {
|
||||
#[serde(default)]
|
||||
pub codex: McpConfig,
|
||||
#[serde(default)]
|
||||
pub gemini: McpConfig, // Gemini MCP 配置(预留)
|
||||
pub gemini: McpConfig, // Gemini MCP 配置(预留)
|
||||
}
|
||||
|
||||
/// Prompt 配置:单客户端维度
|
||||
@@ -49,7 +49,7 @@ use crate::provider::ProviderManager;
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
Codex,
|
||||
Gemini, // 新增
|
||||
Gemini, // 新增
|
||||
}
|
||||
|
||||
impl AppType {
|
||||
@@ -57,7 +57,7 @@ impl AppType {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini", // 新增
|
||||
AppType::Gemini => "gemini", // 新增
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ impl FromStr for AppType {
|
||||
match normalized.as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini), // 新增
|
||||
"gemini" => Ok(AppType::Gemini), // 新增
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
||||
@@ -105,7 +105,7 @@ impl Default for MultiAppConfig {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||||
|
||||
Self {
|
||||
version: 2,
|
||||
@@ -150,13 +150,16 @@ impl MultiAppConfig {
|
||||
}
|
||||
|
||||
// 解析 v2 结构
|
||||
let mut config: Self = serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||
|
||||
let mut config: Self =
|
||||
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||||
|
||||
// 确保 gemini 应用存在(兼容旧配置文件)
|
||||
if !config.apps.contains_key("gemini") {
|
||||
config.apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
config
|
||||
.apps
|
||||
.insert("gemini".to_string(), ProviderManager::default());
|
||||
}
|
||||
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,7 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||
Some(path)
|
||||
}
|
||||
Some(_) => {
|
||||
log::warn!(
|
||||
"Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串"
|
||||
);
|
||||
log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串");
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
|
||||
@@ -149,8 +149,7 @@ pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| AppError::io(&path, e))?;
|
||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
|
||||
Ok(parse_env_file(&content))
|
||||
}
|
||||
@@ -161,8 +160,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| AppError::io(parent, e))?;
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
|
||||
// 设置目录权限为 700(仅所有者可读写执行)
|
||||
#[cfg(unix)]
|
||||
@@ -172,8 +170,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
||||
.map_err(|e| AppError::io(parent, e))?
|
||||
.permissions();
|
||||
perms.set_mode(0o700);
|
||||
fs::set_permissions(parent, perms)
|
||||
.map_err(|e| AppError::io(parent, e))?;
|
||||
fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +185,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
|
||||
.map_err(|e| AppError::io(&path, e))?
|
||||
.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&path, perms)
|
||||
.map_err(|e| AppError::io(&path, e))?;
|
||||
fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -243,66 +239,66 @@ pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
/// 获取 Gemini settings.json 文件路径
|
||||
///
|
||||
///
|
||||
/// 返回路径:`~/.gemini/settings.json`(与 `.env` 文件同级)
|
||||
pub fn get_gemini_settings_path() -> PathBuf {
|
||||
get_gemini_dir().join("settings.json")
|
||||
}
|
||||
|
||||
/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段
|
||||
///
|
||||
///
|
||||
/// 此函数会:
|
||||
/// 1. 读取现有的 settings.json(如果存在)
|
||||
/// 2. 只更新 `security.auth.selectedType` 字段,保留其他所有字段
|
||||
/// 3. 原子性写入文件
|
||||
///
|
||||
///
|
||||
/// # 参数
|
||||
/// - `selected_type`: 要设置的 selectedType 值(如 "gemini-api-key" 或 "oauth-personal")
|
||||
fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
|
||||
let settings_path = get_gemini_settings_path();
|
||||
|
||||
|
||||
// 确保目录存在
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| AppError::io(parent, e))?;
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
|
||||
// 读取现有的 settings.json(如果存在)
|
||||
let mut settings_content = if settings_path.exists() {
|
||||
let content = fs::read_to_string(&settings_path)
|
||||
.map_err(|e| AppError::io(&settings_path, e))?;
|
||||
serde_json::from_str::<Value>(&content)
|
||||
.unwrap_or_else(|_| serde_json::json!({}))
|
||||
let content =
|
||||
fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;
|
||||
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
|
||||
// 只更新 security.auth.selectedType 字段
|
||||
if let Some(obj) = settings_content.as_object_mut() {
|
||||
let security = obj.entry("security")
|
||||
let security = obj
|
||||
.entry("security")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
|
||||
if let Some(security_obj) = security.as_object_mut() {
|
||||
let auth = security_obj.entry("auth")
|
||||
let auth = security_obj
|
||||
.entry("auth")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
|
||||
if let Some(auth_obj) = auth.as_object_mut() {
|
||||
auth_obj.insert(
|
||||
"selectedType".to_string(),
|
||||
Value::String(selected_type.to_string())
|
||||
Value::String(selected_type.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 写入文件
|
||||
crate::config::write_json_file(&settings_path, &settings_content)?;
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 为 Packycode Gemini 供应商写入 settings.json
|
||||
///
|
||||
///
|
||||
/// 设置 `~/.gemini/settings.json` 中的:
|
||||
/// ```json
|
||||
/// {
|
||||
@@ -313,14 +309,14 @@ fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
///
|
||||
/// 保留文件中的其他所有字段。
|
||||
pub fn write_packycode_settings() -> Result<(), AppError> {
|
||||
update_selected_type("gemini-api-key")
|
||||
}
|
||||
|
||||
/// 为 Google 官方 Gemini 供应商写入 settings.json(OAuth 模式)
|
||||
///
|
||||
///
|
||||
/// 设置 `~/.gemini/settings.json` 中的:
|
||||
/// ```json
|
||||
/// {
|
||||
@@ -331,7 +327,7 @@ pub fn write_packycode_settings() -> Result<(), AppError> {
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
///
|
||||
/// 保留文件中的其他所有字段。
|
||||
pub fn write_google_oauth_settings() -> Result<(), AppError> {
|
||||
update_selected_type("oauth-personal")
|
||||
@@ -355,7 +351,10 @@ GEMINI_MODEL=gemini-2.5-pro
|
||||
let map = parse_env_file(content);
|
||||
|
||||
assert_eq!(map.len(), 3);
|
||||
assert_eq!(map.get("GOOGLE_GEMINI_BASE_URL"), Some(&"https://example.com".to_string()));
|
||||
assert_eq!(
|
||||
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||
Some(&"https://example.com".to_string())
|
||||
);
|
||||
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
|
||||
}
|
||||
@@ -380,7 +379,10 @@ GEMINI_MODEL=gemini-2.5-pro
|
||||
let json = env_to_json(&env_map);
|
||||
let converted = json_to_env(&json).unwrap();
|
||||
|
||||
assert_eq!(converted.get("GEMINI_API_KEY"), Some(&"test-key".to_string()));
|
||||
assert_eq!(
|
||||
converted.get("GEMINI_API_KEY"),
|
||||
Some(&"test-key".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -400,7 +402,10 @@ GEMINI_MODEL=gemini-2.5-pro
|
||||
|
||||
let map = result.unwrap();
|
||||
assert_eq!(map.len(), 3);
|
||||
assert_eq!(map.get("GOOGLE_GEMINI_BASE_URL"), Some(&"https://example.com".to_string()));
|
||||
assert_eq!(
|
||||
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||||
Some(&"https://example.com".to_string())
|
||||
);
|
||||
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||||
assert_eq!(map.get("GEMINI_MODEL"), Some(&"gemini-2.5-pro".to_string()));
|
||||
}
|
||||
@@ -502,17 +507,19 @@ KEY_WITH-DASH=value";
|
||||
|
||||
// 模拟更新 selectedType
|
||||
if let Some(obj) = existing_settings.as_object_mut() {
|
||||
let security = obj.entry("security")
|
||||
let security = obj
|
||||
.entry("security")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
|
||||
if let Some(security_obj) = security.as_object_mut() {
|
||||
let auth = security_obj.entry("auth")
|
||||
let auth = security_obj
|
||||
.entry("auth")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
|
||||
if let Some(auth_obj) = auth.as_object_mut() {
|
||||
auth_obj.insert(
|
||||
"selectedType".to_string(),
|
||||
Value::String("gemini-api-key".to_string())
|
||||
Value::String("gemini-api-key".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -521,8 +528,14 @@ KEY_WITH-DASH=value";
|
||||
// 验证所有字段都被保留
|
||||
assert_eq!(existing_settings["otherField"], "should-be-kept");
|
||||
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
|
||||
assert_eq!(existing_settings["security"]["auth"]["otherAuth"], "preserved");
|
||||
assert_eq!(existing_settings["security"]["auth"]["selectedType"], "gemini-api-key");
|
||||
assert_eq!(
|
||||
existing_settings["security"]["auth"]["otherAuth"],
|
||||
"preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
existing_settings["security"]["auth"]["selectedType"],
|
||||
"gemini-api-key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@ mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod error;
|
||||
mod gemini_config; // 新增
|
||||
mod gemini_config; // 新增
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod prompt;
|
||||
@@ -63,6 +63,129 @@ impl TrayTexts {
|
||||
}
|
||||
}
|
||||
|
||||
struct TrayAppSection {
|
||||
app_type: AppType,
|
||||
prefix: &'static str,
|
||||
header_id: &'static str,
|
||||
empty_id: &'static str,
|
||||
header_label: &'static str,
|
||||
log_name: &'static str,
|
||||
}
|
||||
|
||||
const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
header_id: "claude_header",
|
||||
empty_id: "claude_empty",
|
||||
header_label: "─── Claude ───",
|
||||
log_name: "Claude",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::Codex,
|
||||
prefix: "codex_",
|
||||
header_id: "codex_header",
|
||||
empty_id: "codex_empty",
|
||||
header_label: "─── Codex ───",
|
||||
log_name: "Codex",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::Gemini,
|
||||
prefix: "gemini_",
|
||||
header_id: "gemini_header",
|
||||
empty_id: "gemini_empty",
|
||||
header_label: "─── Gemini ───",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
fn append_provider_section<'a>(
|
||||
app: &'a tauri::AppHandle,
|
||||
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||
manager: Option<&crate::provider::ProviderManager>,
|
||||
section: &TrayAppSection,
|
||||
tray_texts: &TrayTexts,
|
||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||
let Some(manager) = manager else {
|
||||
return Ok(menu_builder);
|
||||
};
|
||||
|
||||
let header = MenuItem::with_id(
|
||||
app,
|
||||
section.header_id,
|
||||
section.header_label,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&header);
|
||||
|
||||
if manager.providers.is_empty() {
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
section.empty_id,
|
||||
tray_texts.no_provider_hint,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||
return Ok(menu_builder.item(&empty_hint));
|
||||
}
|
||||
|
||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
match (a.sort_index, b.sort_index) {
|
||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (a.created_at, b.created_at) {
|
||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
a.name.cmp(&b.name)
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
|
||||
Ok(menu_builder)
|
||||
}
|
||||
|
||||
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
||||
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
||||
let app_handle = app.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
let app_type = section.app_type.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
||||
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 创建动态托盘菜单
|
||||
fn create_tray_menu(
|
||||
app: &tauri::AppHandle,
|
||||
@@ -82,116 +205,14 @@ fn create_tray_menu(
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
|
||||
// 添加Claude标题(禁用状态,仅作为分组标识)
|
||||
let claude_header =
|
||||
MenuItem::with_id(app, "claude_header", "─── Claude ───", false, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建Claude标题失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&claude_header);
|
||||
|
||||
if !claude_manager.providers.is_empty() {
|
||||
// Sort providers by sortIndex, then by createdAt, then by name
|
||||
let mut sorted_providers: Vec<_> = claude_manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
// Priority 1: sortIndex
|
||||
match (a.sort_index, b.sort_index) {
|
||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||
_ => {}
|
||||
}
|
||||
// Priority 2: createdAt
|
||||
match (a.created_at, b.created_at) {
|
||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||
_ => {}
|
||||
}
|
||||
// Priority 3: name
|
||||
a.name.cmp(&b.name)
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = claude_manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("claude_{id}"),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建菜单项失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
} else {
|
||||
// 没有供应商时显示提示
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
"claude_empty",
|
||||
tray_texts.no_provider_hint,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建Claude空提示失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&empty_hint);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(codex_manager) = config.get_manager(&crate::app_config::AppType::Codex) {
|
||||
// 添加Codex标题(禁用状态,仅作为分组标识)
|
||||
let codex_header =
|
||||
MenuItem::with_id(app, "codex_header", "─── Codex ───", false, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建Codex标题失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&codex_header);
|
||||
|
||||
if !codex_manager.providers.is_empty() {
|
||||
// Sort providers by sortIndex, then by createdAt, then by name
|
||||
let mut sorted_providers: Vec<_> = codex_manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
// Priority 1: sortIndex
|
||||
match (a.sort_index, b.sort_index) {
|
||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||
_ => {}
|
||||
}
|
||||
// Priority 2: createdAt
|
||||
match (a.created_at, b.created_at) {
|
||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||
_ => {}
|
||||
}
|
||||
// Priority 3: name
|
||||
a.name.cmp(&b.name)
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = codex_manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("codex_{id}"),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建菜单项失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
} else {
|
||||
// 没有供应商时显示提示
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
"codex_empty",
|
||||
tray_texts.no_provider_hint,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建Codex空提示失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&empty_hint);
|
||||
}
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
menu_builder = append_provider_section(
|
||||
app,
|
||||
menu_builder,
|
||||
config.get_manager(§ion.app_type),
|
||||
section,
|
||||
&tray_texts,
|
||||
)?;
|
||||
}
|
||||
|
||||
// 分隔符和退出菜单
|
||||
@@ -246,47 +267,10 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
log::info!("退出应用");
|
||||
app.exit(0);
|
||||
}
|
||||
id if id.starts_with("claude_") => {
|
||||
let Some(provider_id) = id.strip_prefix("claude_") else {
|
||||
log::error!("无效的 Claude 菜单项 ID: {id}");
|
||||
return;
|
||||
};
|
||||
log::info!("切换到Claude供应商: {provider_id}");
|
||||
|
||||
// 执行切换
|
||||
let app_handle = app.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(e) = switch_provider_internal(
|
||||
&app_handle,
|
||||
crate::app_config::AppType::Claude,
|
||||
provider_id,
|
||||
) {
|
||||
log::error!("切换Claude供应商失败: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
id if id.starts_with("codex_") => {
|
||||
let Some(provider_id) = id.strip_prefix("codex_") else {
|
||||
log::error!("无效的 Codex 菜单项 ID: {id}");
|
||||
return;
|
||||
};
|
||||
log::info!("切换到Codex供应商: {provider_id}");
|
||||
|
||||
// 执行切换
|
||||
let app_handle = app.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(e) = switch_provider_internal(
|
||||
&app_handle,
|
||||
crate::app_config::AppType::Codex,
|
||||
provider_id,
|
||||
) {
|
||||
log::error!("切换Codex供应商失败: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
if handle_provider_tray_event(app, event_id) {
|
||||
return;
|
||||
}
|
||||
log::warn!("未处理的菜单事件: {event_id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +136,7 @@ fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
|
||||
continue;
|
||||
}
|
||||
if map.contains_key(&new_key) {
|
||||
log::warn!(
|
||||
"MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键"
|
||||
);
|
||||
log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键");
|
||||
if let Some(value) = map.get_mut(&old_key) {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
if obj
|
||||
|
||||
@@ -173,9 +173,7 @@ impl ConfigService {
|
||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
||||
})?;
|
||||
let auth = settings.get("auth").ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"供应商 {provider_id} 的 Codex 配置缺少 auth 字段"
|
||||
))
|
||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
||||
})?;
|
||||
if !auth.is_object() {
|
||||
return Err(AppError::Config(format!(
|
||||
@@ -231,7 +229,9 @@ impl ConfigService {
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{json_to_env, write_gemini_env_atomic, read_gemini_env, env_to_json};
|
||||
use crate::gemini_config::{
|
||||
env_to_json, json_to_env, read_gemini_env, write_gemini_env_atomic,
|
||||
};
|
||||
|
||||
let env_path = crate::gemini_config::get_gemini_env_path();
|
||||
if let Some(parent) = env_path.parent() {
|
||||
@@ -265,7 +265,7 @@ impl ConfigService {
|
||||
// 读回实际写入的内容并更新到配置中
|
||||
let live_after_env = read_gemini_env()?;
|
||||
let live_after = env_to_json(&live_after_env);
|
||||
|
||||
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
target.settings_config = live_after;
|
||||
|
||||
@@ -121,7 +121,7 @@ impl McpService {
|
||||
match app {
|
||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||
AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步
|
||||
AppType::Gemini => {} // Gemini 暂不支持 MCP 同步
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ impl McpService {
|
||||
match app {
|
||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||
AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步
|
||||
AppType::Gemini => {} // Gemini 暂不支持 MCP 同步
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ impl McpService {
|
||||
match app {
|
||||
AppType::Claude => mcp::sync_enabled_to_claude(&snapshot)?,
|
||||
AppType::Codex => mcp::sync_enabled_to_codex(&snapshot)?,
|
||||
AppType::Gemini => {}, // Gemini 暂不支持 MCP 同步
|
||||
AppType::Gemini => {} // Gemini 暂不支持 MCP 同步
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ enum LiveSnapshot {
|
||||
config: Option<String>,
|
||||
},
|
||||
Gemini {
|
||||
env: Option<HashMap<String, String>>, // 新增
|
||||
env: Option<HashMap<String, String>>, // 新增
|
||||
},
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ impl LiveSnapshot {
|
||||
delete_file(&config_path)?;
|
||||
}
|
||||
}
|
||||
LiveSnapshot::Gemini { env } => { // 新增
|
||||
LiveSnapshot::Gemini { env } => {
|
||||
// 新增
|
||||
use crate::gemini_config::{get_gemini_env_path, write_gemini_env_atomic};
|
||||
let path = get_gemini_env_path();
|
||||
if let Some(env_map) = env {
|
||||
@@ -348,11 +349,11 @@ impl ProviderService {
|
||||
|
||||
// 写入应用级别的 settings.json (~/.cc-switch/settings.json)
|
||||
settings::ensure_security_auth_selected_type(Self::PACKYCODE_SECURITY_SELECTED_TYPE)?;
|
||||
|
||||
|
||||
// 写入 Gemini 目录的 settings.json (~/.gemini/settings.json)
|
||||
use crate::gemini_config::write_packycode_settings;
|
||||
write_packycode_settings()?;
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -394,11 +395,11 @@ impl ProviderService {
|
||||
|
||||
// 写入应用级别的 settings.json (~/.cc-switch/settings.json)
|
||||
settings::ensure_security_auth_selected_type(Self::GOOGLE_OAUTH_SECURITY_SELECTED_TYPE)?;
|
||||
|
||||
|
||||
// 写入 Gemini 目录的 settings.json (~/.gemini/settings.json)
|
||||
use crate::gemini_config::write_google_oauth_settings;
|
||||
write_google_oauth_settings()?;
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -502,9 +503,7 @@ impl ProviderService {
|
||||
return Err(AppError::localized(
|
||||
"config.save.rollback_failed",
|
||||
format!("保存配置失败: {save_err};回滚失败: {rollback_err}"),
|
||||
format!(
|
||||
"Failed to save config: {save_err}; rollback failed: {rollback_err}"
|
||||
),
|
||||
format!("Failed to save config: {save_err}; rollback failed: {rollback_err}"),
|
||||
));
|
||||
}
|
||||
return Err(save_err);
|
||||
@@ -518,9 +517,7 @@ impl ProviderService {
|
||||
return Err(AppError::localized(
|
||||
"post_commit.rollback_failed",
|
||||
format!("后置操作失败: {err};回滚失败: {rollback_err}"),
|
||||
format!(
|
||||
"Post-commit step failed: {err}; rollback failed: {rollback_err}"
|
||||
),
|
||||
format!("Post-commit step failed: {err}; rollback failed: {rollback_err}"),
|
||||
));
|
||||
}
|
||||
return Err(err);
|
||||
@@ -618,8 +615,8 @@ impl ProviderService {
|
||||
state.save()?;
|
||||
}
|
||||
AppType::Gemini => {
|
||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json};
|
||||
|
||||
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||
|
||||
let env_path = get_gemini_env_path();
|
||||
if !env_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
@@ -630,7 +627,7 @@ impl ProviderService {
|
||||
}
|
||||
let env_map = read_gemini_env()?;
|
||||
let live_after = env_to_json(&env_map);
|
||||
|
||||
|
||||
{
|
||||
let mut guard = state.config.write().map_err(AppError::from)?;
|
||||
if let Some(manager) = guard.get_manager_mut(app_type) {
|
||||
@@ -674,7 +671,8 @@ impl ProviderService {
|
||||
};
|
||||
Ok(LiveSnapshot::Codex { auth, config })
|
||||
}
|
||||
AppType::Gemini => { // 新增
|
||||
AppType::Gemini => {
|
||||
// 新增
|
||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env};
|
||||
let path = get_gemini_env_path();
|
||||
let env = if path.exists() {
|
||||
@@ -851,9 +849,10 @@ impl ProviderService {
|
||||
let _ = Self::normalize_claude_models_in_value(&mut v);
|
||||
v
|
||||
}
|
||||
AppType::Gemini => { // 新增
|
||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json};
|
||||
|
||||
AppType::Gemini => {
|
||||
// 新增
|
||||
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||
|
||||
let path = get_gemini_env_path();
|
||||
if !path.exists() {
|
||||
return Err(AppError::localized(
|
||||
@@ -917,9 +916,10 @@ impl ProviderService {
|
||||
}
|
||||
read_json_file(&path)
|
||||
}
|
||||
AppType::Gemini => { // 新增
|
||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json};
|
||||
|
||||
AppType::Gemini => {
|
||||
// 新增
|
||||
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||
|
||||
let path = get_gemini_env_path();
|
||||
if !path.exists() {
|
||||
return Err(AppError::localized(
|
||||
@@ -928,7 +928,7 @@ impl ProviderService {
|
||||
"Gemini .env file not found",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
let env_map = read_gemini_env()?;
|
||||
Ok(env_to_json(&env_map))
|
||||
}
|
||||
@@ -1429,8 +1429,8 @@ impl ProviderService {
|
||||
config: &mut MultiAppConfig,
|
||||
next_provider: &str,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, env_to_json};
|
||||
|
||||
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
||||
|
||||
let env_path = get_gemini_env_path();
|
||||
if !env_path.exists() {
|
||||
return Ok(());
|
||||
@@ -1464,7 +1464,9 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{json_to_env, validate_gemini_settings, write_gemini_env_atomic};
|
||||
use crate::gemini_config::{
|
||||
json_to_env, validate_gemini_settings, write_gemini_env_atomic,
|
||||
};
|
||||
|
||||
// 一次性检测认证类型,避免重复检测
|
||||
let auth_type = Self::detect_gemini_auth_type(provider);
|
||||
@@ -1498,7 +1500,7 @@ impl ProviderService {
|
||||
match app_type {
|
||||
AppType::Codex => Self::write_codex_live(provider),
|
||||
AppType::Claude => Self::write_claude_live(provider),
|
||||
AppType::Gemini => Self::write_gemini_live(provider), // 新增
|
||||
AppType::Gemini => Self::write_gemini_live(provider), // 新增
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1553,7 +1555,8 @@ impl ProviderService {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppType::Gemini => { // 新增
|
||||
AppType::Gemini => {
|
||||
// 新增
|
||||
use crate::gemini_config::validate_gemini_settings;
|
||||
validate_gemini_settings(&provider.settings_config)?
|
||||
}
|
||||
@@ -1667,25 +1670,25 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::Gemini => { // 新增
|
||||
AppType::Gemini => {
|
||||
// 新增
|
||||
use crate::gemini_config::json_to_env;
|
||||
|
||||
|
||||
let env_map = json_to_env(&provider.settings_config)?;
|
||||
|
||||
let api_key = env_map
|
||||
.get("GEMINI_API_KEY")
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::localized(
|
||||
|
||||
let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"gemini.missing_api_key",
|
||||
"缺少 GEMINI_API_KEY",
|
||||
"Missing GEMINI_API_KEY",
|
||||
))?;
|
||||
|
||||
)
|
||||
})?;
|
||||
|
||||
let base_url = env_map
|
||||
.get("GOOGLE_GEMINI_BASE_URL")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
|
||||
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,9 +220,7 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
|
||||
partner_promotion_key: Some("packycode".to_string()),
|
||||
..ProviderMeta::default()
|
||||
});
|
||||
manager
|
||||
.providers
|
||||
.insert("packy-meta".to_string(), provider);
|
||||
manager.providers.insert("packy-meta".to_string(), provider);
|
||||
}
|
||||
|
||||
let state = AppState {
|
||||
|
||||
Reference in New Issue
Block a user