Compare commits

..

1 Commits

Author SHA1 Message Date
YoVinchen cfe3343f6c fix(provider): include icon fields when duplicating provider 2025-11-25 23:16:03 +08:00
41 changed files with 1039 additions and 1629 deletions
+3 -4
View File
@@ -32,10 +32,7 @@
"prettier": "^3.6.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
"vitest": "^2.0.5"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@@ -59,6 +56,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.3",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
@@ -78,6 +76,7 @@
"smol-toml": "^1.4.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
+255 -578
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-7
View File
@@ -7,14 +7,7 @@ fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// Windows 平台的 AutoLaunch::new 只接受 3 个参数
// Linux/macOS 平台需要 4 个参数(包含 hidden 参数)
#[cfg(target_os = "windows")]
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), &[] as &[&str]);
#[cfg(not(target_os = "windows"))]
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
Ok(auto_launch)
}
+2 -2
View File
@@ -44,7 +44,7 @@ pub async fn import_config_from_file(
// 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state);
if let Err(err) = ProviderService::sync_current_to_live(&app_state) {
if let Err(err) = ProviderService::sync_current_from_db(&app_state) {
log::warn!("导入后同步 live 配置失败: {err}");
}
@@ -69,7 +69,7 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db);
ProviderService::sync_current_to_live(&app_state)?;
ProviderService::sync_current_from_db(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Live configuration synchronized"
+37 -73
View File
@@ -122,98 +122,62 @@ impl Database {
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
/// add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 处理 meta:取出 endpoints 以便单独处理
// 处理 meta endpoints
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current
let existing: Option<bool> = tx
// 检查是否存在保留 is_current
let is_current: bool = tx
.query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type],
|row| row.get(0),
)
.ok();
.unwrap_or(false);
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
tx.execute(
"INSERT OR REPLACE INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
// 同步 endpoints:删除全部后重新插入
tx.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
params![provider.id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
for (url, endpoint) in endpoints {
tx.execute(
"UPDATE providers SET
name = ?1,
settings_config = ?2,
website_url = ?3,
category = ?4,
created_at = ?5,
sort_index = ?6,
notes = ?7,
icon = ?8,
icon_color = ?9,
meta = ?10,
is_current = ?11
WHERE id = ?12 AND app_type = ?13",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
provider.id,
app_type,
],
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![provider.id, app_type, url, endpoint.added_at],
)
.map_err(|e| AppError::Database(e.to_string()))?;
} else {
// 新增模式:使用 INSERT
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 只有新增时才同步 endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![provider.id, app_type, url, endpoint.added_at],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
+9 -11
View File
@@ -153,14 +153,12 @@ impl Database {
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
let migrate_app_prompts = |prompts_map: &std::collections::HashMap<
String,
crate::prompt::Prompt,
>,
app_type: &str|
-> Result<(), AppError> {
for (id, prompt) in prompts_map {
tx.execute(
let migrate_app_prompts =
|prompts_map: &std::collections::HashMap<String, crate::prompt::Prompt>,
app_type: &str|
-> Result<(), AppError> {
for (id, prompt) in prompts_map {
tx.execute(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
@@ -176,9 +174,9 @@ impl Database {
],
)
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
}
Ok(())
};
}
Ok(())
};
migrate_app_prompts(&config.prompts.claude.prompts, "claude")?;
migrate_app_prompts(&config.prompts.codex.prompts, "codex")?;
+11 -10
View File
@@ -226,13 +226,13 @@ impl Database {
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
// skill_repos 表
Self::add_column_if_missing(conn, "skill_repos", "branch", "TEXT NOT NULL DEFAULT 'main'")?;
Self::add_column_if_missing(
conn,
"skill_repos",
"branch",
"TEXT NOT NULL DEFAULT 'main'",
"enabled",
"BOOLEAN NOT NULL DEFAULT 1",
)?;
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
Ok(())
@@ -247,7 +247,9 @@ impl Database {
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
if version < 0 {
return Err(AppError::Database("user_version 不能为负数".to_string()));
return Err(AppError::Database(
"user_version 不能为负数".to_string(),
));
}
let sql = format!("PRAGMA user_version = {version};");
conn.execute(&sql, [])
@@ -259,7 +261,10 @@ impl Database {
if s.is_empty() {
return Err(AppError::Database(format!("{kind} 不能为空")));
}
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(AppError::Database(format!(
"非法{kind}: {s},仅允许字母、数字和下划线"
)));
@@ -287,11 +292,7 @@ impl Database {
Ok(false)
}
pub(crate) fn has_column(
conn: &Connection,
table: &str,
column: &str,
) -> Result<bool, AppError> {
pub(crate) fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, AppError> {
Self::validate_identifier(table, "表名")?;
Self::validate_identifier(column, "列名")?;
+10 -4
View File
@@ -108,8 +108,8 @@ fn migration_rejects_future_version() {
Database::create_tables_on_conn(&conn).expect("create tables");
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
let err =
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
let err = Database::apply_schema_migrations_on_conn(&conn)
.expect_err("should reject higher version");
assert!(
err.to_string().contains("数据库版本过新"),
"unexpected error: {err}"
@@ -168,7 +168,10 @@ fn migration_aligns_column_defaults_and_types() {
let is_current = get_column_info(&conn, "providers", "is_current");
assert_eq!(is_current.r#type, "BOOLEAN");
assert_eq!(is_current.notnull, 1);
assert_eq!(normalize_default(&is_current.default).as_deref(), Some("0"));
assert_eq!(
normalize_default(&is_current.default).as_deref(),
Some("0")
);
let tags = get_column_info(&conn, "mcp_servers", "tags");
assert_eq!(tags.r#type, "TEXT");
@@ -178,7 +181,10 @@ fn migration_aligns_column_defaults_and_types() {
let enabled = get_column_info(&conn, "prompts", "enabled");
assert_eq!(enabled.r#type, "BOOLEAN");
assert_eq!(enabled.notnull, 1);
assert_eq!(normalize_default(&enabled.default).as_deref(), Some("1"));
assert_eq!(
normalize_default(&enabled.default).as_deref(),
Some("1")
);
let installed_at = get_column_info(&conn, "skills", "installed_at");
assert_eq!(installed_at.r#type, "INTEGER");
+2 -1
View File
@@ -307,7 +307,8 @@ fn test_import_prompt_allows_space_in_base64_content() {
let db = Arc::new(Database::memory().expect("create memory db"));
let state = AppState::new(db.clone());
let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
let prompt_id =
import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
let prompts = state.db.get_prompts("codex").expect("get prompts");
let prompt = prompts.get(&prompt_id).expect("prompt saved");
+1 -19
View File
@@ -49,11 +49,6 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
}
/// 读取 Gemini settings.json 中的 mcpServers 映射
///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 保持不变(SSE 类型)
/// - 仅有 command 字段 → 保持不变(stdio 类型)
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path();
if !path.exists() {
@@ -61,25 +56,12 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
}
let root = read_json_value(&path)?;
let mut servers: std::collections::HashMap<String, Value> = root
let servers = root
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
// 反向格式转换:Gemini 特有格式 → 统一 MCP 格式
for (_, spec) in servers.iter_mut() {
if let Some(obj) = spec.as_object_mut() {
// httpUrl → url + type: "http"
if let Some(http_url) = obj.remove("httpUrl") {
obj.insert("url".to_string(), http_url);
obj.insert("type".to_string(), Value::String("http".to_string()));
}
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// 如果有 command 但没有 type,不添加 type(默认为 stdio
}
}
Ok(servers)
}
+29 -147
View File
@@ -43,7 +43,6 @@ pub use services::{
pub use settings::{update_settings, AppSettings};
pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::{
@@ -221,11 +220,10 @@ fn create_tray_menu(
for section in TRAY_SECTIONS.iter() {
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let current_id = app_state
.db
.get_current_provider(app_type_str)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
@@ -548,68 +546,41 @@ pub fn run() {
let has_json = json_path.exists();
let has_db = db_path.exists();
// 如果需要迁移,先验证 config.json 是否可以加载(在创建数据库之前)
// 这样如果加载失败用户选择退出,数据库文件还没被创建,下次可以正常重试
let migration_config = if !has_db && has_json {
log::info!("检测到旧版配置文件,验证配置文件...");
// 循环:支持用户重试加载配置文件
loop {
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
log::info!("✓ 配置文件加载成功");
break Some(config);
}
Err(e) => {
log::error!("加载旧配置文件失败: {e}");
// 弹出系统对话框让用户选择
if !show_migration_error_dialog(app.handle(), &e.to_string()) {
// 用户选择退出(此时数据库还没创建,下次启动可以重试)
log::info!("用户选择退出程序");
std::process::exit(1);
}
// 用户选择重试,继续循环
log::info!("用户选择重试加载配置文件");
}
}
}
} else {
None
};
// 现在创建数据库
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
// 这里的错误处理比较棘手,因为 setup 返回 Result<Box<dyn Error>>
// 我们暂时记录日志并让应用继续运行(可能会崩溃)或者返回错误
return Err(Box::new(e));
}
};
// 如果有预加载的配置,执行迁移
if let Some(config) = migration_config {
log::info!("开始执行数据迁移...");
match db.migrate_from_json(&config) {
Ok(_) => {
log::info!("配置迁移成功");
// 标记迁移成功,供前端显示 Toast
crate::init_status::set_migration_success();
// 归档旧配置文件(重命名而非删除,便于用户恢复)
let archive_path = json_path.with_extension("json.migrated");
if let Err(e) = std::fs::rename(&json_path, &archive_path) {
log::warn!("归档旧配置文件失败: {e}");
// 静默迁移:检测到旧版 config.json 且数据库不存在时自动迁移
if !has_db && has_json {
log::info!("检测到旧版配置文件,开始自动迁移到数据库...");
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
if let Err(e) = db.migrate_from_json(&config) {
log::error!("配置迁移失败: {e},将从现有配置导入");
} else {
log::info!("配置已归档为 config.json.migrated");
log::info!("✓ 配置迁移成功");
// 标记迁移成功,供前端显示 Toast
crate::init_status::set_migration_success();
// 归档旧配置文件(重命名而非删除,便于用户恢复)
let archive_path = json_path.with_extension("json.migrated");
if let Err(e) = std::fs::rename(&json_path, &archive_path) {
log::warn!("归档旧配置文件失败: {e}");
} else {
log::info!("✓ 旧配置已归档为 config.json.migrated");
}
}
}
Err(e) => {
// 配置加载成功但迁移失败的情况极少(磁盘满等),仅记录日志
log::error!("配置迁移失败: {e},将从现有配置导入");
}
Err(e) => log::error!("加载旧配置文件失败: {e},将从现有配置导入"),
}
}
crate::settings::bind_db(db.clone());
let app_state = AppState::new(db);
// 检查是否需要首次导入(数据库为空)
@@ -734,35 +705,10 @@ pub fn run() {
// Linux 和 Windows 调试模式需要显式注册
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
#[cfg(target_os = "linux")]
{
// Use Tauri's path API to get correct path (includes app identifier)
// tauri-plugin-deep-link writes to: ~/.local/share/com.ccswitch.desktop/applications/cc-switch-handler.desktop
// Only register if .desktop file doesn't exist to avoid overwriting user customizations
let should_register = app
.path()
.data_dir()
.map(|d| !d.join("applications/cc-switch-handler.desktop").exists())
.unwrap_or(true);
if should_register {
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux)");
}
} else {
log::info!("⊘ Deep link handler already exists, skipping registration");
}
}
#[cfg(all(debug_assertions, windows))]
{
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Windows debug)");
}
if let Err(e) = app.deep_link().register_all() {
log::error!("✗ Failed to register deep link schemes: {}", e);
} else {
log::info!("✓ Deep link schemes registered (Linux/Windows)");
}
}
@@ -1006,67 +952,3 @@ pub fn run() {
}
});
}
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================
/// 检测是否为中文环境
fn is_chinese_locale() -> bool {
std::env::var("LANG")
.or_else(|_| std::env::var("LC_ALL"))
.or_else(|_| std::env::var("LC_MESSAGES"))
.map(|lang| lang.starts_with("zh"))
.unwrap_or(false)
}
/// 显示迁移错误对话框
/// 返回 true 表示用户选择重试,false 表示用户选择退出
fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
let title = if is_chinese_locale() {
"配置迁移失败"
} else {
"Migration Failed"
};
let message = if is_chinese_locale() {
format!(
"从旧版本迁移配置时发生错误:\n\n{error}\n\n\
您的数据尚未丢失,旧配置文件仍然保留。\n\
建议回退到旧版本 CC Switch 以保护数据。\n\n\
点击「重试」重新尝试迁移\n\
点击「退出」关闭程序(可回退版本后重新打开)"
)
} else {
format!(
"An error occurred while migrating configuration:\n\n{error}\n\n\
Your data is NOT lost - the old config file is still preserved.\n\
Consider rolling back to an older CC Switch version.\n\n\
Click 'Retry' to attempt migration again\n\
Click 'Exit' to close the program"
)
};
let retry_text = if is_chinese_locale() {
"重试"
} else {
"Retry"
};
let exit_text = if is_chinese_locale() {
"退出"
} else {
"Exit"
};
// 使用 blocking_show 同步等待用户响应
// OkCancelCustom: 第一个按钮(重试)返回 true,第二个按钮(退出)返回 false
app.dialog()
.message(&message)
.title(title)
.kind(MessageDialogKind::Error)
.buttons(MessageDialogButtons::OkCancelCustom(
retry_text.to_string(),
exit_text.to_string(),
))
.blocking_show()
}
-10
View File
@@ -2,15 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// 在 Linux 上设置 WebKit 环境变量以解决 DMA-BUF 渲染问题
// 某些 Linux 系统(如 Debian 13.2、Nvidia GPU)上 WebKitGTK 的 DMA-BUF 渲染器可能导致白屏/黑屏
// 参考: https://github.com/tauri-apps/tauri/issues/9394
#[cfg(target_os = "linux")]
{
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
cc_switch_lib::run();
}
+48
View File
@@ -2,6 +2,7 @@ use super::provider::ProviderService;
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
use crate::store::AppState;
use chrono::Utc;
use serde_json::Value;
use std::fs;
@@ -83,6 +84,53 @@ impl ConfigService {
Ok(())
}
/// 将当前 config.json 拷贝到目标路径。
pub fn export_config_to_path(target_path: &Path) -> Result<(), AppError> {
let config_path = crate::config::get_app_config_path();
let config_content =
fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
fs::write(target_path, config_content).map_err(|e| AppError::io(target_path, e))
}
/// 从磁盘文件加载配置并写回 config.json,返回备份 ID 及新配置。
pub fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> {
let import_content =
fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?;
let new_config: MultiAppConfig =
serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?;
let config_path = crate::config::get_app_config_path();
let backup_id = Self::create_backup(&config_path)?;
fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?;
Ok((new_config, backup_id))
}
/// 将外部配置文件内容加载并写入应用状态。
/// TODO: 需要重构以使用数据库而不是 JSON 配置
pub fn import_config_from_path(
_file_path: &Path,
_state: &AppState,
) -> Result<String, AppError> {
// TODO: 实现基于数据库的导入逻辑
Err(AppError::Message(
"配置导入功能正在重构中,暂时不可用".to_string(),
))
/* 旧的实现,需要重构:
let (new_config, backup_id) = Self::load_config_for_import(file_path)?;
{
let mut guard = state.config.write().map_err(AppError::from)?;
*guard = new_config;
}
Ok(backup_id)
*/
}
/// 同步当前供应商到对应的 live 配置。
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
+149 -4
View File
@@ -4,6 +4,7 @@
use crate::error::AppError;
use crate::provider::Provider;
use crate::settings;
/// Gemini authentication type enumeration
///
@@ -18,6 +19,10 @@ pub(crate) enum GeminiAuthType {
Generic,
}
// Authentication type constants
const PACKYCODE_SECURITY_SELECTED_TYPE: &str = "gemini-api-key";
const GOOGLE_OAUTH_SECURITY_SELECTED_TYPE: &str = "oauth-personal";
// Partner Promotion Key constants
const PACKYCODE_PARTNER_KEY: &str = "packycode";
const GOOGLE_OFFICIAL_PARTNER_KEY: &str = "google-official";
@@ -89,22 +94,159 @@ fn contains_packycode_keyword(value: &str) -> bool {
.any(|keyword| lower.contains(keyword))
}
/// Detect if provider is PackyCode Gemini (uses API Key authentication)
///
/// PackyCode is an official partner requiring special security configuration.
///
/// # Detection Rules (priority from high to low)
///
/// 1. **Partner Promotion Key** (most reliable):
/// - `provider.meta.partner_promotion_key == "packycode"`
///
/// 2. **Provider name**:
/// - Name contains "packycode", "packyapi" or "packy" (case-insensitive)
///
/// 3. **Website URL**:
/// - `provider.website_url` contains keywords
///
/// 4. **Base URL**:
/// - `settings_config.env.GOOGLE_GEMINI_BASE_URL` contains keywords
///
/// # Why multiple detection methods
///
/// - Users may manually create providers without `partner_promotion_key`
/// - Meta fields may be modified after copying from presets
/// - Ensure all PackyCode providers get correct security flags
#[allow(dead_code)]
pub(crate) fn is_packycode_gemini(provider: &Provider) -> bool {
// Strategy 1: Check partner_promotion_key (most reliable)
if provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
.is_some_and(|key| key.eq_ignore_ascii_case(PACKYCODE_PARTNER_KEY))
{
return true;
}
// Strategy 2: Check provider name
if contains_packycode_keyword(&provider.name) {
return true;
}
// Strategy 3: Check website URL
if let Some(site) = provider.website_url.as_deref() {
if contains_packycode_keyword(site) {
return true;
}
}
// Strategy 4: Check Base URL
if let Some(base_url) = provider
.settings_config
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
.and_then(|v| v.as_str())
{
if contains_packycode_keyword(base_url) {
return true;
}
}
false
}
/// Detect if provider is Google Official Gemini (uses OAuth authentication)
///
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
///
/// This is a convenience wrapper around `detect_gemini_auth_type`.
/// # Detection Rules (priority from high to low)
///
/// 1. **Partner Promotion Key** (most reliable):
/// - `provider.meta.partner_promotion_key == "google-official"`
///
/// 2. **Provider name**:
/// - Name equals "google" (case-insensitive)
/// - Or name starts with "google " (e.g., "Google Official")
///
/// # OAuth vs API Key
///
/// - **OAuth mode**: `security.auth.selectedType = "oauth-personal"`
/// - User needs to login via browser with Google account
/// - No API Key needed in `.env` file
///
/// - **API Key mode**: `security.auth.selectedType = "gemini-api-key"`
/// - Used for third-party relay services (like PackyCode)
/// - Requires `GEMINI_API_KEY` in `.env` file
#[allow(dead_code)]
pub(crate) fn is_google_official_gemini(provider: &Provider) -> bool {
detect_gemini_auth_type(provider) == GeminiAuthType::GoogleOfficial
// Strategy 1: Check partner_promotion_key (most reliable)
if provider
.meta
.as_ref()
.and_then(|meta| meta.partner_promotion_key.as_deref())
.is_some_and(|key| key.eq_ignore_ascii_case(GOOGLE_OFFICIAL_PARTNER_KEY))
{
return true;
}
// Strategy 2: Check name matching (fallback)
let name_lower = provider.name.to_ascii_lowercase();
name_lower == "google" || name_lower.starts_with("google ")
}
/// Ensure PackyCode Gemini provider security flag is correctly set
///
/// PackyCode is an official partner using API Key authentication mode.
///
/// # Why write to two settings.json files
///
/// 1. **`~/.cc-switch/settings.json`** (application-level config):
/// - CC-Switch application global settings
/// - Ensures app knows current authentication type
/// - Used for UI display and other app logic
///
/// 2. **`~/.gemini/settings.json`** (Gemini client config):
/// - Configuration file read by Gemini CLI client
/// - Directly affects Gemini client authentication behavior
/// - Ensures Gemini uses correct authentication method to connect API
///
/// # Value set
///
/// ```json
/// {
/// "security": {
/// "auth": {
/// "selectedType": "gemini-api-key"
/// }
/// }
/// }
/// ```
///
/// # Error handling
///
/// If provider is not PackyCode, function returns `Ok(())` immediately without any operation.
pub(crate) fn ensure_packycode_security_flag(provider: &Provider) -> Result<(), AppError> {
if !is_packycode_gemini(provider) {
return Ok(());
}
// Write to application-level settings.json (~/.cc-switch/settings.json)
settings::ensure_security_auth_selected_type(PACKYCODE_SECURITY_SELECTED_TYPE)?;
// Write to Gemini directory settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_packycode_settings;
write_packycode_settings()?;
Ok(())
}
/// Ensure Google Official Gemini provider security flag is correctly set (OAuth mode)
///
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
///
/// # What it does
/// # Why write to two settings.json files
///
/// Writes to **`~/.gemini/settings.json`** (Gemini client config).
/// Same as `ensure_packycode_security_flag`, need to configure both app-level and client-level settings.
///
/// # Value set
///
@@ -134,6 +276,9 @@ pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(
return Ok(());
}
// Write to application-level settings.json (~/.cc-switch/settings.json)
settings::ensure_security_auth_selected_type(GOOGLE_OAUTH_SECURITY_SELECTED_TYPE)?;
// Write to Gemini directory settings.json (~/.gemini/settings.json)
use crate::gemini_config::write_google_oauth_settings;
write_google_oauth_settings()?;
+59 -63
View File
@@ -15,7 +15,8 @@ use crate::services::mcp::McpService;
use crate::store::AppState;
use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
GeminiAuthType,
};
use super::normalize_claude_models_in_value;
@@ -100,13 +101,12 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
write_json_file(&path, &provider.settings_config)?;
}
AppType::Codex => {
let obj = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
let auth = obj
.get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let obj = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string())
})?;
let auth = obj.get("auth").ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string())
})?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?;
@@ -114,36 +114,51 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
let auth_path = get_codex_auth_path();
write_json_file(&auth_path, auth)?;
let config_path = get_codex_config_path();
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
std::fs::write(&config_path, config_str)
.map_err(|e| AppError::io(&config_path, e))?;
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
use crate::gemini_config::{
get_gemini_settings_path, json_to_env, write_gemini_env_atomic,
};
// Extract env and config from provider settings
let env_value = provider.settings_config.get("env");
let config_value = provider.settings_config.get("config");
// Write env file
if let Some(env) = env_value {
let env_map = json_to_env(env)?;
write_gemini_env_atomic(&env_map)?;
}
// Write settings file
if let Some(config) = config_value {
let settings_path = get_gemini_settings_path();
write_json_file(&settings_path, config)?;
}
}
}
Ok(())
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
/// Sync current provider from database to live configuration
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// Use validated effective current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let current_id = match state.db.get_current_provider(app_type.as_str())? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
} else {
log::warn!(
"无法同步 live 配置: 当前供应商 {} ({}) 未找到",
current_id,
app_type.as_str()
);
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync
@@ -313,44 +328,28 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
let mut env_map = json_to_env(&provider.settings_config)?;
// Prepare config to write to ~/.gemini/settings.json
// Behavior:
// - config is object: use it (merge with existing to preserve mcpServers etc.)
// - config is null or absent: preserve existing file content
let settings_path = get_gemini_settings_path();
let mut config_to_write: Option<Value> = None;
if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_object() {
// Merge with existing settings to preserve mcpServers and other fields
let mut merged = if settings_path.exists() {
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// Merge provider config into existing settings
if let (Some(merged_obj), Some(config_obj)) =
(merged.as_object_mut(), config_value.as_object())
{
for (k, v) in config_obj {
merged_obj.insert(k.clone(), v.clone());
}
}
config_to_write = Some(merged);
} else if !config_value.is_null() {
// Prepare config to write to ~/.gemini/settings.json (preserve existing file content when absent)
let mut config_to_write = if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_null() {
Some(json!({}))
} else if config_value.is_object() {
Some(config_value.clone())
} else {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
// config is null: don't modify existing settings.json (preserve mcpServers etc.)
}
} else {
None
};
// If no config specified or config is null, preserve existing file
if config_to_write.is_none() && settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
if config_to_write.is_none() {
let settings_path = get_gemini_settings_path();
if settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
}
}
match auth_type {
@@ -372,17 +371,14 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
}
if let Some(config_value) = config_to_write {
let settings_path = get_gemini_settings_path();
write_json_file(&settings_path, &config_value)?;
}
// Set security.auth.selectedType based on auth type
// - Google Official: OAuth mode
// - All others: API Key mode
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
crate::gemini_config::write_packycode_settings()?;
}
GeminiAuthType::Packycode => ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
}
Ok(())
+32 -33
View File
@@ -22,12 +22,16 @@ use crate::settings::CustomEndpoint;
use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
pub use live::{import_default_config, read_live_settings, sync_current_from_db};
// Internal re-exports (pub(crate))
pub(crate) use live::write_live_snapshot;
// Internal re-exports
use gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
GeminiAuthType,
};
use live::write_gemini_live;
use usage::validate_usage_script;
@@ -94,12 +98,10 @@ impl ProviderService {
}
/// Get current provider ID
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
crate::settings::get_effective_current_provider(&state.db, &app_type)
state
.db
.get_current_provider(app_type.as_str())
.map(|opt| opt.unwrap_or_default())
}
@@ -137,10 +139,9 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
// Check if this is current provider
let current_id = state.db.get_current_provider(app_type.as_str())?;
let is_current = current_id.as_deref() == Some(provider.id.as_str());
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
@@ -155,19 +156,13 @@ impl ProviderService {
}
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check both local settings and database
let local_current = crate::settings::get_current_provider(&app_type);
let db_current = state.db.get_current_provider(app_type.as_str())?;
if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {
let current = state.db.get_current_provider(app_type.as_str())?;
if current.as_deref() == Some(id) {
return Err(AppError::Message(
"无法删除当前正在使用的供应商".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)
}
@@ -176,9 +171,9 @@ impl ProviderService {
/// Switch flow:
/// 1. Validate target provider exists
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
/// 3. Update local settings current_provider_xxx (device-level)
/// 4. Update database is_current (as default for new devices)
/// 5. Write target provider config to live files
/// 3. Set new current provider
/// 4. Write target provider config to live files
/// 5. Gemini additional security flag handling
/// 6. Sync MCP configuration
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists
@@ -188,10 +183,7 @@ impl ProviderService {
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Backfill: Backfill current live config to current provider
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
if let Some(current_id) = current_id {
if let Some(current_id) = state.db.get_current_provider(app_type.as_str())? {
if current_id != id {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
@@ -204,24 +196,31 @@ impl ProviderService {
}
}
// Update local settings (device-level, takes priority)
crate::settings::set_current_provider(&app_type, Some(id))?;
// Update database is_current (as default for new devices)
// Set current
state.db.set_current_provider(app_type.as_str(), id)?;
// Sync to live (write_gemini_live handles security flag internally for Gemini)
// Sync to live
write_live_snapshot(&app_type, provider)?;
// Gemini needs additional security flag handling (PackyCode or Google OAuth)
if matches!(app_type, AppType::Gemini) {
let auth_type = detect_gemini_auth_type(provider);
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode => ensure_packycode_security_flag(provider)?,
GeminiAuthType::Generic => {}
}
}
// Sync MCP
McpService::sync_all_enabled(state)?;
Ok(())
}
/// Sync current provider to live configuration (re-export)
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
sync_current_to_live(state)
/// Sync current provider from database to live configuration (re-export)
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
sync_current_from_db(state)
}
/// Import default configuration from live files (re-export)
+3 -1
View File
@@ -168,7 +168,9 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError
if interval > 1440 {
return Err(AppError::localized(
"usage_script.interval_too_large",
format!("自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"),
format!(
"自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"
),
format!(
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
),
+84 -161
View File
@@ -214,8 +214,56 @@ impl SkillService {
temp_dir.clone()
};
// 递归扫描目录查找所有技能
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
// 遍历目标目录
for entry in fs::read_dir(&scan_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
// 解析技能元数据
match self.parse_skill_metadata(&skill_md) {
Ok(meta) => {
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 构建 README URL(考虑 skillsPath
let readme_path = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
directory.clone()
};
skills.push(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory,
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
skills_path: repo.skills_path.clone(),
});
}
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
}
}
// 清理临时目录
let _ = fs::remove_dir_all(&temp_dir);
@@ -223,90 +271,6 @@ impl SkillService {
Ok(skills)
}
/// 递归扫描目录查找 SKILL.md
///
/// 规则:
/// 1. 如果当前目录存在 SKILL.md,则识别为技能,停止扫描其子目录(子目录视为功能文件夹)
/// 2. 如果当前目录不存在 SKILL.md,则递归扫描所有子目录
fn scan_dir_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
repo: &SkillRepo,
skills: &mut Vec<Skill>,
) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
// 发现技能!获取相对路径作为目录名
let directory = if current_dir == base_dir {
// 根目录的 SKILL.md,使用仓库名
repo.name.clone()
} else {
// 子目录的 SKILL.md,使用相对路径
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.to_string()
};
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
skills.push(skill);
}
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
return Ok(());
}
// 未发现 SKILL.md,继续递归扫描所有子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
// 只处理目录
if path.is_dir() {
self.scan_dir_recursive(&path, base_dir, repo, skills)?;
}
}
Ok(())
}
/// 从 SKILL.md 构建技能对象
fn build_skill_from_metadata(
&self,
skill_md: &Path,
directory: &str,
repo: &SkillRepo,
) -> Result<Skill> {
let meta = self.parse_skill_metadata(skill_md)?;
// 构建 README URL
let readme_path = if let Some(ref skills_path) = repo.skills_path {
format!("{}/{}", skills_path.trim_matches('/'), directory)
} else {
directory.to_string()
};
Ok(Skill {
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
name: meta.name.unwrap_or_else(|| directory.to_string()),
description: meta.description.unwrap_or_default(),
directory: directory.to_string(),
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, readme_path
)),
installed: false,
repo_owner: Some(repo.owner.clone()),
repo_name: Some(repo.name.clone()),
repo_branch: Some(repo.branch.clone()),
skills_path: repo.skills_path.clone(),
})
}
/// 解析技能元数据
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
@@ -338,18 +302,25 @@ impl SkillService {
return Ok(());
}
// 收集所有本地技能
let mut local_skills = Vec::new();
self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?;
for entry in fs::read_dir(&self.install_dir)? {
let entry = entry?;
let path = entry.path();
// 处理找到的本地技能
for local_skill in local_skills {
let directory = &local_skill.directory;
if !path.is_dir() {
continue;
}
// 更新已安装状态(匹配远程技能)
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 更新已安装状态
let mut found = false;
for skill in skills.iter_mut() {
if skill.directory.eq_ignore_ascii_case(directory) {
if skill.directory.eq_ignore_ascii_case(&directory) {
skill.installed = true;
found = true;
break;
@@ -358,69 +329,23 @@ impl SkillService {
// 添加本地独有的技能(仅当在仓库中未找到时)
if !found {
skills.push(local_skill);
}
}
Ok(())
}
/// 递归扫描本地目录查找 SKILL.md
fn scan_local_dir_recursive(
&self,
current_dir: &Path,
base_dir: &Path,
skills: &mut Vec<Skill>,
) -> Result<()> {
// 检查当前目录是否包含 SKILL.md
let skill_md = current_dir.join("SKILL.md");
if skill_md.exists() {
// 发现技能!获取相对路径作为目录名
let directory = if current_dir == base_dir {
// 如果是 install_dir 本身,使用最后一段路径名
current_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
// 使用相对于 install_dir 的路径
current_dir
.strip_prefix(base_dir)
.unwrap_or(current_dir)
.to_string_lossy()
.to_string()
};
// 解析元数据并创建本地技能对象
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
skills_path: None,
});
}
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
return Ok(());
}
// 未发现 SKILL.md,继续递归扫描所有子目录
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
// 只处理目录
if path.is_dir() {
self.scan_local_dir_recursive(&path, base_dir, skills)?;
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
skills.push(Skill {
key: format!("local:{directory}"),
name: meta.name.unwrap_or_else(|| directory.clone()),
description: meta.description.unwrap_or_default(),
directory: directory.clone(),
readme_url: None,
installed: true,
repo_owner: None,
repo_name: None,
repo_branch: None,
skills_path: None,
});
}
}
}
}
@@ -428,13 +353,11 @@ impl SkillService {
}
/// 去重技能列表
/// 使用完整的 key (owner/name:directory) 来区分不同仓库的同名技能
fn deduplicate_skills(skills: &mut Vec<Skill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
// 使用完整 key 而非仅 directory,允许不同仓库的同名技能共存
let unique_key = skill.key.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
let key = skill.directory.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
e.insert(true);
true
} else {
+118 -100
View File
@@ -1,12 +1,13 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use std::sync::{Arc, OnceLock, RwLock};
use crate::app_config::AppType;
use crate::database::Database;
use crate::error::AppError;
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints
/// 自定义端点配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomEndpoint {
@@ -16,14 +17,24 @@ pub struct CustomEndpoint {
pub last_used: Option<i64>,
}
/// 应用设置结构
///
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
/// 这确保了云同步场景下多设备可以独立运作。
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecurityAuthSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SecuritySettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<SecurityAuthSettings>,
}
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
// ===== 设备级 UI 设置 =====
#[serde(default = "default_show_in_tray")]
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
@@ -31,30 +42,25 @@ pub struct AppSettings {
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
// ===== 设备级目录覆盖 =====
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_config_dir: Option<String>,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_claude: Option<String>,
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
pub language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_codex: Option<String>,
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_gemini: Option<String>,
pub security: Option<SecuritySettings>,
/// Claude 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
/// Codex 自定义端点列表
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
}
fn default_show_in_tray() -> bool {
@@ -71,14 +77,14 @@ impl Default for AppSettings {
show_in_tray: true,
minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false,
launch_on_startup: false,
language: None,
claude_config_dir: None,
codex_config_dir: None,
gemini_config_dir: None,
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),
}
}
}
@@ -163,7 +169,60 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
static SETTINGS_STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
fn settings_store() -> &'static RwLock<AppSettings> {
SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))
SETTINGS_STORE.get_or_init(|| RwLock::new(load_initial_settings()))
}
static SETTINGS_DB: OnceLock<Arc<Database>> = OnceLock::new();
const APP_SETTINGS_KEY: &str = "app_settings";
pub fn bind_db(db: Arc<Database>) {
if SETTINGS_DB.set(db).is_err() {
return;
}
if let Some(store) = SETTINGS_STORE.get() {
let mut guard = store.write().expect("写入设置锁失败");
*guard = load_initial_settings();
}
}
fn load_initial_settings() -> AppSettings {
if let Some(db) = SETTINGS_DB.get() {
if let Some(from_db) = load_from_db(db.as_ref()) {
return from_db;
}
// 从文件迁移一次并写入数据库
let file_settings = AppSettings::load_from_file();
if let Err(e) = save_to_db(db.as_ref(), &file_settings) {
log::warn!("迁移设置到数据库失败,将继续使用内存副本: {e}");
}
return file_settings;
}
AppSettings::load_from_file()
}
fn load_from_db(db: &Database) -> Option<AppSettings> {
let raw = db.get_setting(APP_SETTINGS_KEY).ok()??;
match serde_json::from_str::<AppSettings>(&raw) {
Ok(mut settings) => {
settings.normalize_paths();
Some(settings)
}
Err(err) => {
log::warn!("解析数据库中 app_settings 失败: {err}");
None
}
}
}
fn save_to_db(db: &Database, settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
let json =
serde_json::to_string(&normalized).map_err(|e| AppError::JsonSerialize { source: e })?;
db.set_setting(APP_SETTINGS_KEY, &json)
}
fn resolve_override_path(raw: &str) -> PathBuf {
@@ -190,22 +249,47 @@ pub fn get_settings() -> AppSettings {
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
if let Some(db) = SETTINGS_DB.get() {
save_to_db(db, &new_settings)?;
} else {
save_settings_file(&new_settings)?;
}
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = new_settings;
Ok(())
}
/// 从文件重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与文件同步
/// 从数据库重新加载设置到内存缓存
/// 用于导入配置等场景,确保内存缓存与数据库同步
pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = AppSettings::load_from_file();
let fresh_settings = load_initial_settings();
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = fresh_settings;
Ok(())
}
pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
let mut settings = get_settings();
let current = settings
.security
.as_ref()
.and_then(|sec| sec.auth.as_ref())
.and_then(|auth| auth.selected_type.as_deref());
if current == Some(selected_type) {
return Ok(());
}
let mut security = settings.security.unwrap_or_default();
let mut auth = security.auth.unwrap_or_default();
auth.selected_type = Some(selected_type.to_string());
security.auth = Some(auth);
settings.security = Some(security);
update_settings(settings)
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
@@ -229,69 +313,3 @@ pub fn get_gemini_override_dir() -> Option<PathBuf> {
.as_ref()
.map(|p| resolve_override_path(p))
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
///
/// 这是设备级别的设置,不随数据库同步。
/// 如果本地没有设置,调用者应该 fallback 到数据库的 `is_current` 字段。
pub fn get_current_provider(app_type: &AppType) -> Option<String> {
let settings = settings_store().read().ok()?;
match app_type {
AppType::Claude => settings.current_provider_claude.clone(),
AppType::Codex => settings.current_provider_codex.clone(),
AppType::Gemini => settings.current_provider_gemini.clone(),
}
}
/// 设置指定应用类型的当前供应商 ID(保存到本地 settings
///
/// 这是设备级别的设置,不随数据库同步。
/// 传入 `None` 会清除当前供应商设置。
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
let mut settings = get_settings();
match app_type {
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
}
update_settings(settings)
}
/// 获取有效的当前供应商 ID(验证存在性)
///
/// 逻辑:
/// 1. 从本地 settings 读取当前供应商 ID
/// 2. 验证该 ID 在数据库中存在
/// 3. 如果不存在则清理本地 settingsfallback 到数据库的 is_current
///
/// 这确保了返回的 ID 一定是有效的(在数据库中存在)。
/// 多设备云同步场景下,配置导入后本地 ID 可能失效,此函数会自动修复。
pub fn get_effective_current_provider(
db: &crate::database::Database,
app_type: &AppType,
) -> Result<Option<String>, AppError> {
// 1. 从本地 settings 读取
if let Some(local_id) = get_current_provider(app_type) {
// 2. 验证该 ID 在数据库中存在
let providers = db.get_all_providers(app_type.as_str())?;
if providers.contains_key(&local_id) {
// 存在,直接返回
return Ok(Some(local_id));
}
// 3. 不存在,清理本地 settings
log::warn!(
"本地 settings 中的供应商 {} ({}) 在数据库中不存在,将清理并 fallback 到数据库",
local_id,
app_type.as_str()
);
let _ = set_current_provider(app_type, None);
}
// Fallback 到数据库的 is_current
db.get_current_provider(app_type.as_str())
}
+27 -15
View File
@@ -3,15 +3,13 @@ use std::fs;
use std::path::PathBuf;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
Provider, ProviderMeta,
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService,
MultiAppConfig, Provider, ProviderMeta,
};
#[path = "support.rs"]
mod support;
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
#[test]
fn sync_claude_provider_writes_live_settings() {
@@ -845,22 +843,21 @@ fn sync_gemini_packycode_sets_security_selected_type() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
"syncing PackyCode Gemini should enforce security.auth.selectedType"
);
}
@@ -896,7 +893,22 @@ fn sync_gemini_google_official_sets_oauth_security() {
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let cc_settings = home.join(".cc-switch").join("settings.json");
assert!(
cc_settings.exists(),
"app settings should exist at {}",
cc_settings.display()
);
let cc_raw = std::fs::read_to_string(&cc_settings).expect("read .cc-switch settings");
let cc_value: serde_json::Value = serde_json::from_str(&cc_raw).expect("parse app settings");
assert_eq!(
cc_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"syncing Google official should set oauth-personal in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -911,7 +923,7 @@ fn sync_gemini_google_official_sets_oauth_security() {
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings should record oauth-personal for Google Official"
"Gemini settings should also record oauth-personal"
);
}
+12 -13
View File
@@ -42,13 +42,9 @@ fn import_default_config_claude_persists_provider() {
.expect("import default config succeeds");
// 验证内存状态
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
let providers = state.db.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
let current_id = state.db.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(current_id.as_deref(), Some("default"));
let default_provider = providers.get("default").expect("default provider");
@@ -91,9 +87,7 @@ fn import_default_config_without_live_file_returns_error() {
// 使用数据库架构,不再检查 config.json
// 失败的导入不应该向数据库写入任何供应商
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
let providers = state.db.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!(
providers.is_empty(),
@@ -131,7 +125,8 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
"import should report inserted or normalized entries"
);
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let servers = state.db.get_all_mcp_servers()
.expect("get all mcp servers");
let entry = servers
.get("echo")
.expect("server imported into unified structure");
@@ -173,7 +168,8 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
}
// 使用数据库架构,检查 MCP 服务器未被写入
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let servers = state.db.get_all_mcp_servers()
.expect("get all mcp servers");
assert!(
servers.is_empty(),
"failed import should not persist any MCP servers to database"
@@ -228,8 +224,11 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("codex-server").expect("codex server exists");
let servers = state.db.get_all_mcp_servers()
.expect("get all mcp servers");
let entry = servers
.get("codex-server")
.expect("codex server exists");
assert!(
entry.apps.codex,
"server should have Codex app enabled after toggle"
+16 -34
View File
@@ -7,8 +7,8 @@ use cc_switch_lib::{
#[path = "support.rs"]
mod support;
use std::collections::HashMap;
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
use std::collections::HashMap;
#[test]
fn switch_provider_updates_codex_live_and_state() {
@@ -104,22 +104,16 @@ command = "say"
"config.toml should contain synced MCP servers"
);
let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
.expect("get all providers");
let new_provider = providers.get("new-provider").expect("new provider exists");
let new_provider = providers
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
@@ -171,9 +165,7 @@ fn switch_provider_missing_provider_returns_error() {
let err_str = err.to_string();
assert!(
err_str.contains("供应商不存在")
|| err_str.contains("Provider not found")
|| err_str.contains("missing-provider"),
err_str.contains("供应商不存在") || err_str.contains("Provider not found") || err_str.contains("missing-provider"),
"error message should mention missing provider, got: {err_str}"
);
}
@@ -249,19 +241,11 @@ fn switch_provider_updates_claude_live_and_state() {
"live settings.json should reflect new provider auth"
);
let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let legacy_provider = providers
@@ -274,7 +258,9 @@ fn switch_provider_updates_claude_live_and_state() {
"previous provider should be backfilled with live config"
);
let new_provider = providers.get("new-provider").expect("new provider exists");
let new_provider = providers
.get("new-provider")
.expect("new provider exists");
assert_eq!(
new_provider
.settings_config
@@ -297,9 +283,7 @@ fn switch_provider_updates_claude_live_and_state() {
);
// 验证当前供应商已更新
let current_id = app_state
.db
.get_current_provider(AppType::Claude.as_str())
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
@@ -344,9 +328,7 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
other => panic!("expected config error, got {other:?}"),
}
let current_id = app_state
.db
.get_current_provider(AppType::Codex.as_str())
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
// 但由于 live 配置写入失败,状态应该回滚
+44 -60
View File
@@ -1,15 +1,13 @@
use serde_json::json;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,
McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType,
McpApps, McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
};
#[path = "support.rs"]
mod support;
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
fn sanitize_provider_name(name: &str) -> String {
name.chars()
@@ -71,10 +69,7 @@ command = "say"
}
// 使用新的统一 MCP 结构(v3.7.0+
let servers = initial_config
.mcp
.servers
.get_or_insert_with(Default::default);
let servers = initial_config.mcp.servers.get_or_insert_with(Default::default);
servers.insert(
"echo-server".into(),
McpServer {
@@ -116,22 +111,16 @@ command = "say"
"config.toml should contain synced MCP servers"
);
let current_id = state
.db
.get_current_provider(AppType::Codex.as_str())
let current_id = state.db.get_current_provider(AppType::Codex.as_str())
.expect("read current provider after switch");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
let providers = state.db.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
let new_provider = providers.get("new-provider").expect("new provider exists");
let new_provider = providers
.get("new-provider")
.expect("new provider exists");
let new_config_text = new_provider
.settings_config
.get("config")
@@ -196,16 +185,15 @@ fn switch_packycode_gemini_updates_security_selected_type() {
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"Gemini settings.json should exist at {}",
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
@@ -251,16 +239,15 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"Gemini settings.json should exist at {}",
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
serde_json::from_str(&raw).expect("parse settings.json after switch");
assert_eq!(
value
@@ -305,7 +292,23 @@ fn switch_google_official_gemini_sets_oauth_security() {
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Google official Gemini should set oauth-personal selectedType in app settings"
);
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -321,7 +324,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings json should reflect oauth-personal for Google Official"
"Gemini settings json should also reflect oauth-personal"
);
}
@@ -396,19 +399,11 @@ fn provider_service_switch_claude_updates_live_and_state() {
"live settings.json should reflect new provider auth"
);
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
let providers = state.db.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
let current_id = state
.db
.get_current_provider(AppType::Claude.as_str())
let current_id = state.db.get_current_provider(AppType::Claude.as_str())
.expect("get current provider");
assert_eq!(
current_id.as_deref(),
Some("new-provider"),
"current provider updated"
);
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
let legacy_provider = providers
.get("old-provider")
@@ -528,9 +523,7 @@ fn provider_service_delete_codex_removes_provider_and_files() {
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
.expect("delete provider should succeed");
let providers = app_state
.db
.get_all_providers(AppType::Codex.as_str())
let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
.expect("get all providers");
assert!(
!providers.contains_key("to-delete"),
@@ -588,9 +581,7 @@ fn provider_service_delete_claude_removes_provider_files() {
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
let providers = app_state
.db
.get_all_providers(AppType::Claude.as_str())
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
.expect("get all providers");
assert!(
!providers.contains_key("delete"),
@@ -602,10 +593,6 @@ fn provider_service_delete_claude_removes_provider_files() {
#[test]
fn provider_service_delete_current_provider_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
@@ -631,18 +618,15 @@ fn provider_service_delete_current_provider_returns_error() {
.expect_err("deleting current provider should fail");
match err {
AppError::Localized { zh, .. } => assert!(
zh.contains("不能删除当前正在使用的供应商")
|| zh.contains("无法删除当前正在使用的供应商"),
zh.contains("不能删除当前正在使用的供应商") || zh.contains("无法删除当前正在使用的供应商"),
"unexpected message: {zh}"
),
AppError::Config(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
AppError::Message(msg) => assert!(
msg.contains("不能删除当前正在使用的供应商")
|| msg.contains("无法删除当前正在使用的供应商"),
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
"unexpected message: {msg}"
),
other => panic!("expected Config/Message error, got {other:?}"),
+26 -29
View File
@@ -6,7 +6,7 @@ import {
Plus,
Settings,
ArrowLeft,
// Bot, // TODO: Agents 功能开发中,暂时不需要
Bot,
Book,
Wrench,
Server,
@@ -24,7 +24,6 @@ import {
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -405,6 +404,7 @@ function App() {
>
CC Switch
</a>
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
<Button
variant="ghost"
size="icon"
@@ -470,34 +470,9 @@ function App() {
<>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="h-8 w-[1px] bg-black/10 dark:bg-white/10 mx-1" />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
isClaudeApp
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="h-4 w-4 flex-shrink-0" />
</Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)} */}
<Button
variant="ghost"
size="sm"
@@ -507,6 +482,17 @@ function App() {
>
<Book className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -516,6 +502,17 @@ function App() {
>
<Server className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)}
</div>
<Button
+3 -7
View File
@@ -40,12 +40,8 @@ export function ConfirmDialog({
}
}}
>
<DialogContent
className="max-w-sm"
zIndex="alert"
overlayClassName="bg-background/80"
>
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogContent className="max-w-sm">
<DialogHeader className="space-y-3">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{title}
@@ -54,7 +50,7 @@ export function ConfirmDialog({
{message}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<DialogFooter className="flex gap-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</Button>
@@ -140,24 +140,15 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
// 先添加现有端点(来自预设,isCustom 可能为 false
// 先添加现有端点
prev.forEach((entry) => {
map.set(entry.url, entry);
});
// 合并从后端加载的自定义端点
// 关键:如果 URL 已存在(与预设重合),需要将 isCustom 更新为 true
// 因为它存在于数据库中,需要在 handleSave 时被正确识别
// 添加从后端加载的自定义端点
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) {
// URL 已存在,更新 isCustom 为 true(因为它在数据库中)
existing.isCustom = true;
} else {
// URL 不存在,添加新条目
if (sanitized && !map.has(sanitized)) {
map.set(sanitized, {
id: randomId(),
url: sanitized,
+18 -94
View File
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
@@ -327,11 +326,11 @@ export function ProviderForm({
configError: geminiConfigError,
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
handleGeminiModelChange: originalHandleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
@@ -369,22 +368,6 @@ export function ProviderForm({
[originalHandleGeminiBaseUrlChange, form],
);
const handleGeminiModelChange = useCallback(
(model: string) => {
originalHandleGeminiModelChange(model);
// 同步更新 settingsConfig
try {
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {
// ignore
}
},
[originalHandleGeminiModelChange, form],
);
// 使用 Gemini 通用配置 hook (仅 Gemini 模式)
const {
useCommonConfig: useGeminiCommonConfigFlag,
@@ -405,82 +388,17 @@ export function ProviderForm({
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
toast.error(
t("providerForm.fillParameter", {
form.setError("settingsConfig", {
type: "manual",
message: t("providerForm.fillParameter", {
label: validation.missingField.label,
defaultValue: `请填写 ${validation.missingField.label}`,
}),
);
});
return;
}
}
// 供应商名称必填校验
if (!values.name.trim()) {
toast.error(
t("providerForm.fillSupplierName", {
defaultValue: "请填写供应商名称",
}),
);
return;
}
// 非官方供应商必填校验:端点和 API Key
if (category !== "official") {
if (appId === "claude") {
if (!baseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!apiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
} else if (appId === "codex") {
if (!codexBaseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!codexApiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
} else if (appId === "gemini") {
if (!geminiBaseUrl.trim()) {
toast.error(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
return;
}
if (!geminiApiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
return;
}
}
}
let settingsConfig: string;
// Codex: 组合 auth 和 config
@@ -698,8 +616,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({ auth, config }, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -717,8 +633,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
@@ -733,8 +647,6 @@ export function ProviderForm({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
};
@@ -846,7 +758,19 @@ export function ProviderForm({
onCustomEndpointsChange={setDraftCustomEndpoints}
shouldShowModelField={true}
model={geminiModel}
onModelChange={handleGeminiModelChange}
onModelChange={(model) => {
// 同时更新 form.settingsConfig 和 geminiEnv
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
// 同步更新 geminiEnv,确保提交时不丢失
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_MODEL = model.trim();
const newEnv = envObjToString(envObj);
handleGeminiEnvChange(newEnv);
}}
speedTestEndpoints={speedTestEndpoints}
/>
)}
@@ -22,32 +22,18 @@ export function useGeminiConfigState({
const [configError, setConfigError] = useState("");
// 将 JSON env 对象转换为 .env 格式字符串
// 保留所有环境变量,已知 key 优先显示
const envObjToString = useCallback(
(envObj: Record<string, unknown>): string => {
const priorityKeys = [
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
"GEMINI_MODEL",
];
const lines: string[] = [];
const addedKeys = new Set<string>();
// 先添加已知 key(按顺序)
for (const key of priorityKeys) {
if (typeof envObj[key] === "string" && envObj[key]) {
lines.push(`${key}=${envObj[key]}`);
addedKeys.add(key);
}
if (typeof envObj.GOOGLE_GEMINI_BASE_URL === "string") {
lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`);
}
// 再添加其他自定义 key(保留用户添加的环境变量)
for (const [key, value] of Object.entries(envObj)) {
if (!addedKeys.has(key) && typeof value === "string") {
lines.push(`${key}=${value}`);
}
if (typeof envObj.GEMINI_API_KEY === "string") {
lines.push(`GEMINI_API_KEY=${envObj.GEMINI_API_KEY}`);
}
if (typeof envObj.GEMINI_MODEL === "string") {
lines.push(`GEMINI_MODEL=${envObj.GEMINI_MODEL}`);
}
return lines.join("\n");
},
[],
@@ -178,20 +164,6 @@ export function useGeminiConfigState({
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
);
// 处理 Gemini Model 变化
const handleGeminiModelChange = useCallback(
(model: string) => {
const trimmed = model.trim();
setGeminiModel(trimmed);
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_MODEL = trimmed;
const newEnv = envObjToString(envObj);
setGeminiEnv(newEnv);
},
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
);
// 处理 env 变化
const handleGeminiEnvChange = useCallback(
(value: string) => {
@@ -251,7 +223,6 @@ export function useGeminiConfigState({
setGeminiConfig,
handleGeminiApiKeyChange,
handleGeminiBaseUrlChange,
handleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
+3 -30
View File
@@ -76,8 +76,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "deepseek",
iconColor: "#1E88E5",
},
{
name: "Zhipu GLM",
@@ -96,8 +94,6 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Z.ai GLM",
@@ -116,8 +112,6 @@ export const providerPresets: ProviderPreset[] = [
category: "cn_official",
isPartner: true, // 合作伙伴
partnerPromotionKey: "zhipu", // 促销信息 i18n key
icon: "zhipu",
iconColor: "#0F62FE",
},
{
name: "Qwen Coder",
@@ -134,8 +128,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "qwen",
iconColor: "#FF6A00",
},
{
name: "Kimi k2",
@@ -151,8 +143,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "Kimi For Coding",
@@ -168,8 +158,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "kimi",
iconColor: "#6366F1",
},
{
name: "ModelScope",
@@ -185,8 +173,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "aggregator",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "KAT-Coder",
@@ -234,7 +220,7 @@ export const providerPresets: ProviderPreset[] = [
{
name: "MiniMax",
websiteUrl: "https://platform.minimaxi.com",
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
@@ -248,14 +234,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "minimax",
theme: {
backgroundColor: "#f64551",
textColor: "#FFFFFF",
},
icon: "minimax",
iconColor: "#FF6B6B",
},
{
name: "DouBaoSeed",
@@ -273,8 +251,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "doubao",
iconColor: "#3370FF",
},
{
name: "BaiLing",
@@ -290,8 +266,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
icon: "alibaba",
iconColor: "#FF6A00",
},
{
name: "AiHubMix",
@@ -316,11 +290,11 @@ export const providerPresets: ProviderPreset[] = [
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://www.dmxapi.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_API_KEY: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
category: "aggregator",
},
{
@@ -341,6 +315,5 @@ export const providerPresets: ProviderPreset[] = [
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
-8
View File
@@ -20,9 +20,6 @@ export interface CodexProviderPreset {
endpointCandidates?: string[];
// 新增:视觉主题配置
theme?: PresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
/**
@@ -74,8 +71,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [
backgroundColor: "#1F2937", // gray-800
textColor: "#FFFFFF",
},
icon: "openai",
iconColor: "#00A67E",
},
{
name: "Azure OpenAI",
@@ -102,8 +97,6 @@ requires_openai_auth = true`,
backgroundColor: "#0078D4",
textColor: "#FFFFFF",
},
icon: "azure",
iconColor: "#0078D4",
},
{
name: "AiHubMix",
@@ -149,6 +142,5 @@ requires_openai_auth = true`,
],
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
];
-6
View File
@@ -25,9 +25,6 @@ export interface GeminiProviderPreset {
partnerPromotionKey?: string;
endpointCandidates?: string[];
theme?: GeminiPresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
export const geminiProviderPresets: GeminiProviderPreset[] = [
@@ -46,8 +43,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
backgroundColor: "#4285F4",
textColor: "#FFFFFF",
},
icon: "gemini",
iconColor: "#4285F4",
},
{
name: "PackyCode",
@@ -69,7 +64,6 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
"https://api-slb.packyapi.com",
"https://www.packyapi.com",
],
icon: "packycode",
},
{
name: "自定义",
+1 -4
View File
@@ -261,8 +261,7 @@
"getApiKey": "Get API Key",
"partnerPromotion": {
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
"minimax": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)"
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -276,8 +275,6 @@
"fillConfigContent": "Please fill in configuration content",
"fillParameter": "Please fill in {{label}}",
"fillTemplateValue": "Please fill in {{label}}",
"endpointRequired": "API endpoint is required for non-official providers",
"apiKeyRequired": "API Key is required for non-official providers",
"configJsonError": "Config JSON format error, please check syntax",
"authJsonRequired": "auth.json must be a JSON object",
"authJsonError": "auth.json format error, please check JSON syntax",
+1 -4
View File
@@ -261,8 +261,7 @@
"getApiKey": "获取 API Key",
"partnerPromotion": {
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -276,8 +275,6 @@
"fillConfigContent": "请填写配置内容",
"fillParameter": "请填写 {{label}}",
"fillTemplateValue": "请填写 {{label}}",
"endpointRequired": "非官方供应商请填写 API 端点",
"apiKeyRequired": "非官方供应商请填写 API Key",
"configJsonError": "配置JSON格式错误,请检查语法",
"authJsonRequired": "auth.json 必须是 JSON 对象",
"authJsonError": "auth.json 格式错误,请检查JSON语法",
+5 -4
View File
@@ -1,7 +1,8 @@
/* Tailwind CSS v3 指令 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 引入 Tailwind v4 内建样式与工具 */
@import "tailwindcss";
/* 覆盖 Tailwind v4 默认的 dark 变体为"类选择器"模式 */
@custom-variant dark (&:where(.dark, .dark *));
/* shadcn/ui 主题变量 - 蓝色主题 */
@layer base {
+1 -9
View File
@@ -95,13 +95,6 @@ export const useUsageQuery = (
) => {
const { enabled = true, autoQueryInterval = 0 } = options || {};
// 计算 staleTime:如果有自动刷新间隔,使用该间隔;否则默认 5 分钟
// 这样可以避免切换 app 页面时重复触发查询
const staleTime =
autoQueryInterval > 0
? autoQueryInterval * 60 * 1000 // 与刷新间隔保持一致
: 5 * 60 * 1000; // 默认 5 分钟
const query = useQuery<UsageResult>({
queryKey: ["usage", providerId, appId],
queryFn: async () => usageApi.query(providerId, appId),
@@ -113,8 +106,7 @@ export const useUsageQuery = (
refetchIntervalInBackground: true, // 后台也继续定时查询
refetchOnWindowFocus: false,
retry: false,
staleTime, // 使用动态计算的缓存时间
gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后)
staleTime: 0, // 使用缓存策略,确保 refetchInterval 准确执行
});
return {
+1 -1
View File
@@ -36,7 +36,7 @@ function parseJsonError(error: unknown): string {
}
export const providerSchema = z.object({
name: z.string(), // 必填校验移至 handleSubmit 中用 toast 提示
name: z.string().min(1, "请填写供应商名称"),
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
notes: z.string().optional(),
settingsConfig: z
+3 -11
View File
@@ -8,22 +8,14 @@ const directorySchema = z
.or(z.literal(""));
export const settingsSchema = z.object({
// 设备级 UI 设置
showInTray: z.boolean(),
minimizeToTrayOnClose: z.boolean(),
enableClaudePluginIntegration: z.boolean().optional(),
launchOnStartup: z.boolean().optional(),
language: z.enum(["en", "zh"]).optional(),
// 设备级目录覆盖
claudeConfigDir: directorySchema.nullable().optional(),
codexConfigDir: directorySchema.nullable().optional(),
geminiConfigDir: directorySchema.nullable().optional(),
// 当前供应商 ID(设备级)
currentProviderClaude: z.string().optional(),
currentProviderCodex: z.string().optional(),
currentProviderGemini: z.string().optional(),
language: z.enum(["en", "zh"]).optional(),
customEndpointsClaude: z.record(z.string(), z.unknown()).optional(),
customEndpointsCodex: z.record(z.string(), z.unknown()).optional(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;
+14 -16
View File
@@ -97,35 +97,33 @@ export interface ProviderMeta {
}
// 应用设置类型(用于设置对话框与 Tauri API)
// 存储在本地 ~/.cc-switch/settings.json,不随数据库同步
export interface Settings {
// ===== 设备级 UI 设置 =====
// 是否在系统托盘(macOS 菜单栏)显示图标
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey
enableClaudePluginIntegration?: boolean;
// 是否开机自启
launchOnStartup?: boolean;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// ===== 设备级目录覆盖 =====
// 覆盖 Claude Code 配置目录(可选)
claudeConfigDir?: string;
// 覆盖 Codex 配置目录(可选)
codexConfigDir?: string;
// 覆盖 Gemini 配置目录(可选)
geminiConfigDir?: string;
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current
currentProviderClaude?: string;
// 当前 Codex 供应商 ID(优先于数据库 is_current
currentProviderCodex?: string;
// 当前 Gemini 供应商 ID(优先于数据库 is_current
currentProviderGemini?: string;
// 首选语言(可选,默认中文)
language?: "en" | "zh";
// 是否开机自启
launchOnStartup?: boolean;
// Claude 自定义端点列表
customEndpointsClaude?: Record<string, CustomEndpoint>;
// Codex 自定义端点列表
customEndpointsCodex?: Record<string, CustomEndpoint>;
// 安全设置(兼容未来扩展)
security?: {
auth?: {
selectedType?: string;
};
};
}
// MCP 服务器连接参数(宽松:允许扩展字段)
-1
View File
@@ -4,7 +4,6 @@ export default {
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: "selector",
theme: {
extend: {
colors: {
+2 -1
View File
@@ -1,10 +1,11 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: "src",
plugins: [react()],
plugins: [react(), tailwindcss()],
base: "./",
build: {
outDir: "../dist",