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:
YoVinchen
2025-11-12 23:38:43 +08:00
committed by GitHub
parent 2f02514a14
commit b9743a463d
11 changed files with 271 additions and 262 deletions

View File

@@ -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`

View File

@@ -47,7 +47,7 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
**核心功能**
- **供应商管理**:一键切换 Claude CodeCodex 的 API 配置
- **供应商管理**:一键切换 Claude CodeCodex 与 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`

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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.jsonOAuth 模式)
///
///
/// 设置 `~/.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]

View File

@@ -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(&section.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}");
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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(())
}

View File

@@ -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))
}
}

View File

@@ -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 {